296 lines
11 KiB
Markdown
296 lines
11 KiB
Markdown
# 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.
|