chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
+295
View File
@@ -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.