chore: initial import
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
# Plugins — die Bauanleitung
|
||||
|
||||
Ein Tool wird zum Plugin, indem sein Ordner hier abgelegt wird. Beim Start liest
|
||||
die Suite jede `plugin.json`, lädt das Backend und hängt es unter `/plugins/<id>`
|
||||
ein. Das Dashboard listet es danach automatisch auf. Es gibt keine zentrale
|
||||
Liste, in die man ein Tool eintragen müsste.
|
||||
|
||||
```
|
||||
plugins/
|
||||
└── mein-tool/
|
||||
├── plugin.json Pflicht — Metadaten
|
||||
├── backend.py Pflicht — create_app(ctx) gibt eine ASGI-App zurück
|
||||
├── static/
|
||||
│ └── index.html die Oberfläche
|
||||
└── … der Rest des Tools, unverändert
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mein-tool",
|
||||
"name": "Mein Tool",
|
||||
"summary": "Eine Zeile für den Tool-Umschalter",
|
||||
"description": "Zwei bis drei Zeilen für die Dashboard-Karte.",
|
||||
"icon": "🔧",
|
||||
"category": "Tandoor",
|
||||
"version": "1.0.0",
|
||||
"entrypoint": "backend:create_app",
|
||||
"order": 50,
|
||||
"requires": ["tandoor"],
|
||||
"features": ["Stichpunkt eins", "Stichpunkt zwei"],
|
||||
"docs": "TOOL-README.md",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `id` | identisch zum Ordnernamen, bestimmt Mount-Pfad und Datenordner |
|
||||
| `category` | gruppiert die Karten im Dashboard |
|
||||
| `order` | Sortierung (kleiner = weiter oben) |
|
||||
| `requires` | `tandoor`, `openai` — erscheint als Kennzeichen auf der Karte |
|
||||
| `mount` | optional, Standard ist `/plugins/<id>` |
|
||||
| `enabled` | auf `false` setzen, um ein Tool vorübergehend stillzulegen |
|
||||
|
||||
## 2. backend.py
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
def create_app(ctx):
|
||||
app = FastAPI(title=ctx.meta.name)
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(ctx.path("static", "index.html"))
|
||||
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
return {"tandoor": ctx.settings.status()["tandoor"]}
|
||||
|
||||
return app
|
||||
```
|
||||
|
||||
Der Kontext `ctx`:
|
||||
|
||||
| Attribut | Inhalt |
|
||||
|---|---|
|
||||
| `ctx.id`, `ctx.meta` | ID und Metadaten aus der `plugin.json` |
|
||||
| `ctx.dir`, `ctx.path(*teile)` | Pfade **im** Plugin-Ordner (nur lesen) |
|
||||
| `ctx.data_dir` | eigener Datenordner `data/<id>/` (hier schreiben) |
|
||||
| `ctx.settings` | zentrale Zugänge: `.get("TANDOOR_URL")`, `.status()`, `.tool_env()` |
|
||||
| `ctx.jobs` | Job-Runner für Kommandozeilen-Tools |
|
||||
| `ctx.mount` | Mount-Präfix, z. B. `/plugins/mein-tool` |
|
||||
|
||||
Zurückgeben lässt sich jede ASGI-App. Eine **Flask**-App wird eingepackt:
|
||||
|
||||
```python
|
||||
from a2wsgi import WSGIMiddleware
|
||||
return WSGIMiddleware(flask_app)
|
||||
```
|
||||
|
||||
### Namenskollisionen vermeiden
|
||||
|
||||
Gewachsene Tools benutzen naheliegende Modulnamen (`app.py`, `storage.py`).
|
||||
Damit sich zwei Plugins nicht ins Gehege kommen, werden ihre Module über
|
||||
`core.loader` unter eindeutigem Namen geladen — statt mit `import app`:
|
||||
|
||||
```python
|
||||
from core.loader import load_module, load_package, load_submodule
|
||||
|
||||
modul = load_module(ctx.path("app.py"), f"btp_{ctx.id}_app") # app.py
|
||||
paket = load_package(ctx.path("app"), f"btp_{ctx.id}") # app/
|
||||
main = load_submodule(ctx.path("app"), f"btp_{ctx.id}", "main") # app/main.py
|
||||
```
|
||||
|
||||
## 3. Die Oberfläche
|
||||
|
||||
Damit ein Tool aussieht wie der Rest der Suite, bindet seine `index.html` das
|
||||
gemeinsame Design-System ein und bringt selbst nur noch Layout mit:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||
<script src="/shared/boehmi.js"></script>
|
||||
<style>
|
||||
/* nur noch das Layout dieses Tools */
|
||||
</style>
|
||||
</head>
|
||||
<body data-bt-title="Mein Tool" data-bt-icon="🔧">
|
||||
<main class="bt-main"> … </main>
|
||||
```
|
||||
|
||||
`boehmi.js` ergänzt die App-Bar mit Tool-Umschalter und Design-Wechsel und
|
||||
stellt bereit:
|
||||
|
||||
| Aufruf | Zweck |
|
||||
|---|---|
|
||||
| `BT.url("/api/x")` | Plugin-Pfad → vollständige URL (Mount-Präfix) |
|
||||
| `BT.api("/api/x", {method:"POST", body:…})` | fetch mit JSON- und Fehlerbehandlung |
|
||||
| `BT.toast("Text", "ok"\|"err")` | Rückmeldung |
|
||||
| `BT.escape(text)` | HTML-sicher ausgeben |
|
||||
| `BT.plugins`, `BT.ready` | Liste aller Tools, Promise nach dem Aufbau |
|
||||
|
||||
**Wichtig:** API-Pfade nie fest verdrahten (`fetch("/api/x")`), sondern immer
|
||||
über `BT.url()` bzw. `BT.api()` — sonst zeigt das Tool auf den Host statt auf
|
||||
sich selbst.
|
||||
|
||||
### Bausteine aus `/shared/boehmi.css`
|
||||
|
||||
`bt-main` (`.wide`, `.narrow`) · `bt-pagehead` · `bt-card` · `bt-btnrow` ·
|
||||
`bt-row` / `bt-grid2` / `bt-grid3` · `bt-tabs` + `bt-tab` · `bt-badge`
|
||||
(`ok warn err info accent`) · `bt-notice` (`ok warn err info`) · `bt-status` ·
|
||||
`bt-item` · `bt-table` · `bt-log` · `bt-empty` · `bt-muted` · `bt-hidden`
|
||||
|
||||
Buttons und Formularfelder sind direkt gestylt — `<button class="primary">`,
|
||||
`<button class="ghost mini">`, `<button class="danger">`.
|
||||
|
||||
## 4. Kommandozeilen-Tools einbinden
|
||||
|
||||
Ein bestehendes Skript wird nicht umgeschrieben. Das Backend baut die
|
||||
Argumentliste und startet es über den Job-Runner:
|
||||
|
||||
```python
|
||||
from core.jobs import job_router
|
||||
|
||||
@app.post("/api/run")
|
||||
async def run(request: RunRequest): # pydantic-Modell = geprüfte Eingabe
|
||||
argv = [sys.executable, str(ctx.path("tool", "skript.py"))]
|
||||
if request.apply:
|
||||
argv += ["--apply", "--yes"]
|
||||
job = await ctx.jobs.start(
|
||||
plugin=ctx.id, label="Dry-Run", argv=argv,
|
||||
cwd=ctx.data_dir, env=ctx.settings.tool_env(),
|
||||
)
|
||||
return job.info()
|
||||
|
||||
app.include_router(job_router(ctx.jobs, ctx.id)) # /api/jobs inkl. Live-Stream
|
||||
```
|
||||
|
||||
Die Oberfläche bekommt das Protokoll mit drei Zeilen:
|
||||
|
||||
```html
|
||||
<script src="/shared/boehmi-runner.js"></script>
|
||||
<div id="status" class="bt-status">Bereit.</div>
|
||||
<div id="log" class="bt-log"></div>
|
||||
<script>
|
||||
const runner = BT.Runner({ log: log, status: status });
|
||||
runner.start({ apply: false });
|
||||
</script>
|
||||
```
|
||||
|
||||
Die Argumentliste baut **immer** das Backend aus geprüften Feldern. Es gibt
|
||||
bewusst keinen Endpunkt, der ein beliebiges Kommando entgegennimmt.
|
||||
|
||||
## 5. Zwei Regeln
|
||||
|
||||
1. **Nur nach `ctx.data_dir` schreiben.** Der Plugin-Ordner ist Programmcode und
|
||||
im Container praktisch unveränderlich; nur `data/` liegt im Volume. Bringt ein
|
||||
Tool Startdaten mit, werden sie beim ersten Start dorthin gespiegelt.
|
||||
2. **Zugänge nicht selbst abfragen.** Tandoor- und OpenAI-Daten kommen aus
|
||||
`ctx.settings` bzw. stehen als Umgebungsvariablen bereit.
|
||||
|
||||
## 6. Checkliste für ein neues Tool
|
||||
|
||||
- [ ] Ordner `plugins/<id>/` angelegt, Tool-Code unverändert hineinkopiert
|
||||
- [ ] `plugin.json` geschrieben
|
||||
- [ ] `backend.py` mit `create_app(ctx)`
|
||||
- [ ] `index.html` bindet `boehmi.css` + `boehmi.js` ein, `data-bt-title` gesetzt
|
||||
- [ ] alle API-Aufrufe laufen über `BT.url()` / `BT.api()`
|
||||
- [ ] Schreibzugriffe zeigen auf `ctx.data_dir`
|
||||
- [ ] neue Abhängigkeiten in der `requirements.txt` der Suite ergänzt
|
||||
- [ ] Suite neu gestartet — die Karte erscheint im Dashboard
|
||||
|
||||
|
||||
## Geteilte Bausteine für neue Plugins
|
||||
|
||||
Die drei mitgebrachten Original-Tools bringen ihren eigenen Tandoor-Zugang mit
|
||||
und bleiben unangetastet. Neue Plugins nutzen stattdessen:
|
||||
|
||||
core/tandoor.py TandoorClient: Paginierung, Wiederholversuche,
|
||||
get/patch/put/post/delete, merge().
|
||||
Dazu food_property_map() und
|
||||
merged_properties_payload() — Letzteres ist Pflicht,
|
||||
wenn Nährwerte geschrieben werden: Tandoor ersetzt die
|
||||
properties-Liste komplett, wer nur den neuen Wert
|
||||
schickt, verliert den Bestand.
|
||||
|
||||
core/foodmatch.py Namensabgleich. duplicate_groups() nur für praktisch
|
||||
Sicheres, similar_pairs() für Grenzfälle. Bewusst
|
||||
konservativ — Zusammenführen ist nicht umkehrbar.
|
||||
|
||||
Ein CLI-Skript unter `tool/` findet die Suite so:
|
||||
|
||||
SUITE_ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(SUITE_ROOT))
|
||||
from core.tandoor import TandoorClient
|
||||
|
||||
Damit bleibt es auch ohne laufende Suite direkt aufrufbar.
|
||||
|
||||
## Fallstrick: DATA_DIR
|
||||
|
||||
Der Plugin-Ordner ist im Container schreibgeschützt. Wer ein Tool per
|
||||
`ctx.jobs.start()` startet, muss ihm ausdrücklich sagen, wohin es schreiben
|
||||
darf — `ctx.settings.tool_env()` enthält das **nicht**:
|
||||
|
||||
def tool_env():
|
||||
env = ctx.settings.tool_env()
|
||||
env["DATA_DIR"] = str(ctx.data_dir)
|
||||
return env
|
||||
|
||||
Ohne das schreibt das Skript in seinen Rückfallpfad neben die Anwendung, und
|
||||
die Oberfläche findet das Ergebnis nie.
|
||||
|
||||
|
||||
## Sicherungen zurückspielen (core/backups.py)
|
||||
|
||||
Wer ein Plugin baut, das etwas in Tandoor verändert, sollte es auch
|
||||
zurücknehmen können. Dafür gibt es einen gemeinsamen Unterbau:
|
||||
|
||||
core/backups.py manifest schreiben/lesen, Läufe auflisten, einen
|
||||
Lauf sicher auflösen, als „zurückgespielt“ markieren.
|
||||
backup_router(ctx, "laeufe", restore_argv) liefert
|
||||
fertig /api/backups, /api/backups/{id} und
|
||||
/api/run/restore.
|
||||
shared/boehmi-backups.js BT.Backups({mount, runner, detail, note}) baut
|
||||
den immer gleichen Reiter „Sicherungen“.
|
||||
|
||||
Konvention für ein veränderndes Tool:
|
||||
|
||||
1. Vor jedem Schreibschritt den Zustand *vorher* ins Manifest schreiben, mit
|
||||
einem ehrlichen `restore_level` (voll / neue_id / teilweise / nein).
|
||||
2. Einen Unterbefehl `zurueck --lauf <id> [--apply] [--force]` anbieten, der
|
||||
das Manifest liest und rückwärts abarbeitet. Zurückspielen läuft über
|
||||
dasselbe Skript wie das Ändern — kein zweiter, ungetesteter Pfad.
|
||||
3. Im Backend `backup_router` einhängen und im Frontend `BT.Backups`
|
||||
einbinden. `restore_argv(run, apply, force)` liefert die Kommandozeile.
|
||||
|
||||
Wichtig bei Tandoor: Ein per POST neu angelegter Eintrag kann eine **neue ID**
|
||||
bekommen, und Tandoor gibt bei Namensgleichheit einen vorhandenen Eintrag
|
||||
zurück (Food/Unit case-insensitiv und gegen den Plural, Keyword case-sensitiv).
|
||||
Wiederherstellen heißt deshalb: neu anlegen, prüfen was zurückkam, notfalls
|
||||
über einen Zwischennamen gehen, dann die Verweise zurückhängen.
|
||||
|
||||
## Woran ein Plugin erkannt wird
|
||||
|
||||
Die Identität ist die **`id` aus der `plugin.json`** — nicht der Ordnername.
|
||||
An ihr hängt alles Dauerhafte:
|
||||
|
||||
| Was | Wo |
|
||||
|---|---|
|
||||
| Datenordner | `data/<id>/` |
|
||||
| Adresse | `/plugins/<id>` |
|
||||
| Modul-Namensraum | `boehmitools_plugin_<id>_backend` |
|
||||
| Zuordnung laufender Aufgaben | über `ctx.id` |
|
||||
|
||||
Daraus folgen zwei Regeln:
|
||||
|
||||
* **Die `id` bleibt, was sie ist.** Ändert man sie, gilt das Plugin als ein
|
||||
anderes: neuer Datenordner, neue Adresse, alte Sicherungen finden nicht mehr
|
||||
zurück. Wer wirklich umbenennen will, benennt Anzeigenamen und Ordner um und
|
||||
lässt die `id` stehen.
|
||||
* **Der Ordnername ist beliebig.** Beim Import wird das vorhandene Plugin
|
||||
anhand der `id` gesucht und genau dessen Ordner aktualisiert — auch wenn das
|
||||
Archiv einen anderen Ordnernamen mitbringt. So entstehen keine Doppelgänger.
|
||||
|
||||
Erlaubte Zeichen für die `id`: Kleinbuchstaben, Ziffern, Punkt, Bindestrich und
|
||||
Unterstrich; sie beginnt mit Buchstabe oder Ziffer. Sie wird zum Ordnernamen,
|
||||
zum URL-Bestandteil und zum Modulnamen — deshalb bewusst eng gefasst.
|
||||
|
||||
Die **`version`** entscheidet, ob ein Upload als Aktualisierung durchgeht.
|
||||
Gleiche oder ältere Version verlangt eine ausdrückliche Bestätigung.
|
||||
Reference in New Issue
Block a user