chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 9b5425851a
28 changed files with 4020 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
dist/
build/
*.egg-info/
.env
.env.*
!.env.example
logs/
backups/
data/
plugins/
*.pem
*.key
secrets/
.idea/
.vscode/
.DS_Store
+156
View File
@@ -0,0 +1,156 @@
{
"release": "training-tools-2.0.0",
"components": {
"trainingsplan": "2.0.0",
"trainingstracker": "2.0.0"
},
"contracts": {
"plan_schema": 3,
"training_contract": 2,
"tracker_schema": 7,
"result_data": 2
},
"analysis_storage": "one-current-file-per-week-and-overall; overwrite on regeneration",
"files": [
{
"path": "MIGRATION.md",
"size": 2397,
"sha256": "691c9ce3c9bbc2c535896b9b1cacc51e54b691099d538fe5372c02ff696b1107"
},
{
"path": "RELEASE-NOTES.md",
"size": 1926,
"sha256": "e9cee71dfb96d619e8f013f0b174681c4304f07aaa6ae5c7f61dd0f3304ca3db"
},
{
"path": "data/trainingsplan/plans/calihoss-newbie-8-wochen.json",
"size": 419707,
"sha256": "8365b612ed2b0d6d49db5844c1db8b9d1e001ce1b5d09fdda25c0612374cffbd"
},
{
"path": "data/trainingsplan/plans/phase-1.json",
"size": 575838,
"sha256": "f61d90bedb1bff15bdb667ef7c49428ddd1d8e36ad5f39acf52085fbf641c618"
},
{
"path": "plugins/trainingsplan/TOOL-README.md",
"size": 4055,
"sha256": "1c26eb25d57eca965cb71d7ac0baf0a93a53ba94c89521b76330d4011a6b5ba3"
},
{
"path": "plugins/trainingsplan/app.py",
"size": 21571,
"sha256": "367b30e3cf62cb816c34099c67417495fe58fd243975beb46a22e6e000031e87"
},
{
"path": "plugins/trainingsplan/backend.py",
"size": 1180,
"sha256": "9efa3cac72842c5c8548e3dcc4cd337579890eafd11d64be462c3581fad9f689"
},
{
"path": "plugins/trainingsplan/plan_builder.py",
"size": 46127,
"sha256": "f8a9ade995272402bb32350e2686c0374945581a6bfbfbad9adb4a553159f49a"
},
{
"path": "plugins/trainingsplan/plugin.json",
"size": 1292,
"sha256": "cac5da0eb04e320b07904ada1786f7b9c5cd43fd7aaac8bd584918e0cbc7db9e"
},
{
"path": "plugins/trainingsplan/presets/couchsnacks.json",
"size": 74860,
"sha256": "73e70d563f32a540b3e140888adf30e56c39babb7aff6118ab8e0690a389f93b"
},
{
"path": "plugins/trainingsplan/presets/fruehstueck.json",
"size": 60871,
"sha256": "1bace5aa1d97346600408020cefcc23481e40adb577c939bda0da781432935b0"
},
{
"path": "plugins/trainingsplan/presets/granola.json",
"size": 39330,
"sha256": "73983b3ffd72a998f0136fea2df9e6278b1076366adf89bd60f2d5f5c7327d17"
},
{
"path": "plugins/trainingsplan/presets/nachmittags.json",
"size": 66551,
"sha256": "65c7e11a9728597c725ae8af128f62ee25a70095b8f78f99972a879688c528ec"
},
{
"path": "plugins/trainingsplan/recipe_builder.py",
"size": 18387,
"sha256": "c9738a7c5e0e178cd8f91be5cbdacd13ee100eea4413d4ed4e101c33f6881e48"
},
{
"path": "plugins/trainingsplan/schema_contract.py",
"size": 20726,
"sha256": "4b02ed3b22f014c17b4f960521ee89bd0081309587c065619f67950923814a75"
},
{
"path": "plugins/trainingsplan/static/index.html",
"size": 69985,
"sha256": "f86b38f18eaba2bcf10683d0cf4e8c32061c5a35f8dd53ab56b46d16df7d06e8"
},
{
"path": "plugins/trainingsplan/storage.py",
"size": 1091,
"sha256": "a5ce0c9159d4a102b465e436e715e6c820cfbfeae0489773ea2d007f553e510c"
},
{
"path": "plugins/trainingsplan/tests/test_contract.py",
"size": 3301,
"sha256": "7d6c4f807c3ce28dcb84d0ab918aae77c8ac9419a84ea72c132f755fa1cb1061"
},
{
"path": "plugins/trainingstracker/README.md",
"size": 4240,
"sha256": "a237661fb657cabdc32592bcc65d948b3cacd837bd8dc9aef74af1e8619733b1"
},
{
"path": "plugins/trainingstracker/analysis_store.py",
"size": 832,
"sha256": "b13402ddf8fd4b8d049ffa0200c02b7f32ef329aa9c93c9fd2156ef469e84293"
},
{
"path": "plugins/trainingstracker/app.py",
"size": 143226,
"sha256": "f7f87f43ca7d13c09b98b7e5364d350ad6e11d6a9da3ecca4b78dbfcae0de761"
},
{
"path": "plugins/trainingstracker/backend.py",
"size": 1453,
"sha256": "57490ca4d1283ad6084817e50e1331e2a2be80138f172b050905d1f2b8996c63"
},
{
"path": "plugins/trainingstracker/contract.py",
"size": 269,
"sha256": "5b73a9698f41300b5a21390bab1393bdca431676aead6d986520da492c97bac3"
},
{
"path": "plugins/trainingstracker/plugin.json",
"size": 1531,
"sha256": "8e3f10ac7a8325e925ad222561cd6d1d537c21059bebd517aa3afaea4937a604"
},
{
"path": "plugins/trainingstracker/session_store.py",
"size": 1186,
"sha256": "4af7c49cdaaf3acea311e1f54a3f922b8503111550a2dc0c1c7a3089b6463b1f"
},
{
"path": "plugins/trainingstracker/static/index.html",
"size": 116266,
"sha256": "3a37bf462667350f2104b362c90d4dde3eca92e2ecfe4a71f58f0fb39f213e40"
},
{
"path": "plugins/trainingstracker/tests/test_app.py",
"size": 21391,
"sha256": "d101b1a64ba7f954148d2da82a15a85843bd77f9b2144381c1b74c2920683e4c"
},
{
"path": "plugins/trainingstracker/tests/test_stability.py",
"size": 2012,
"sha256": "30f8af88f8f43b0294b860b8e35c4325aadfb477934a115b12eba1c84d0c58e4"
}
]
}
+63
View File
@@ -0,0 +1,63 @@
# Migration auf Trainingsplan / Trainingstracker 2.0
## Enthaltene Komponenten
```text
plugins/trainingsplan/
plugins/trainingstracker/
data/trainingsplan/plans/phase-1.json
data/trainingsplan/plans/calihoss-newbie-8-wochen.json
```
Es werden keine anderen boehmitools-Komponenten verändert.
## Installation
1. boehmitools beziehungsweise den betroffenen Container stoppen.
2. Die beiden bestehenden Plugin-Ordner sichern.
3. `plugins/trainingsplan/` und `plugins/trainingstracker/` durch die Ordner aus dem Paket ersetzen.
4. Die beiden mitgelieferten Plan-JSONs nach `data/trainingsplan/plans/` kopieren.
5. boehmitools wieder starten.
## Bestehende Sessiondateien
Die Dateien unter `data/trainingstracker/sessions/` müssen nicht manuell geändert werden.
- Alte Positionsschlüssel wie `d1-r0-e0` bleiben lesbar.
- Die neuen Pläne speichern den ursprünglichen Positionsschlüssel dauerhaft als `legacy_id`.
- Beim Öffnen und nächsten Speichern einer Session wird der Eintrag auf die stabile Übungs-ID migriert.
- Bereits dokumentierte Sessiondaten erhalten beim nächsten Speichern die aktuelle `plan_id` und `plan_revision`, ohne Ergebnisse oder Notizen zu verwerfen.
Vorhandene Sessiondateien sollten trotzdem einmal separat gesichert werden.
## Bestehende Analysen
Der Tracker führt künftig genau eine aktuelle Analyse je Woche und eine Gesamtanalyse:
```text
analyses/<Plan-Dateiname>/week-01.json
analyses/<Plan-Dateiname>/week-02.json
analyses/<Plan-Dateiname>/overall.json
```
Beim ersten Lesen eines alten Analyseordners wird die im bisherigen `index.json` als aktuell markierte Analyse übernommen. Ältere Zeitstempeldateien und das alte History-Feld werden entfernt. Eine neue Analyse überschreibt anschließend immer dieselbe Datei.
## Planrevisionen
Jede Plandatei enthält jetzt:
- `config`: die veröffentlichte, vom Tracker gelesene Fassung
- `draft`: den bearbeitbaren Entwurf
- `revision`: Bearbeitungsrevision für Konfliktschutz
- `published_revision`: veröffentlichte Planrevision
Das Speichern eines Entwurfs verändert laufende Sessions nicht. Erst **Entwurf veröffentlichen** stellt die neue Fassung dem Tracker bereit.
## Bewusste Nicht-Funktionen
Nicht eingebaut wurden:
- strukturierte Technik-, RIR- oder RPE-Werte
- unterschiedliches Gewicht pro Satz
- Trainings- oder Tabata-Timer
- mehrere Versionen oder Historien von KI-Analysen
+216
View File
@@ -0,0 +1,216 @@
# boehmitools
Ein Dashboard, unter dem alle Tools zusammenlaufen. Jedes Tool bleibt dabei
eigenständig — es liegt als Plugin in `plugins/<name>/`, hat sein eigenes
Backend und seinen eigenen Datenordner. Gemeinsam sind das Design, die
Zugangsdaten und das Dach.
```
Dashboard /
├── Trainingsplan- & Rezept-Editor /plugins/trainingsplan
├── Tandoor AI Import /plugins/tandoor-ai-import
├── Rezept-Inventur /plugins/tandoor-lint
├── Stammdaten aufräumen /plugins/tandoor-cleanup
├── Nährwerte vervollständigen /plugins/tandoor-nutrition
├── Zutaten-Kategorien /plugins/tandoor-categories
└── Einheiten-Umrechnungen /plugins/tandoor-conversions
```
---
## Starten
### Docker (empfohlen)
```bash
cd boehmitools
cp .env.example .env
nano .env # mindestens APP_PASSWORD setzen
docker compose up -d --build
```
Dann im Browser: `http://<server-ip>:8080`
Port ändern: in der `.env` `APP_PORT=9090` setzen.
Logs: `docker compose logs -f` · Stoppen: `docker compose down`
### Direkt mit Python
```bash
pip install -r requirements.txt
python run.py # Standard-Port 8080
PORT=9090 python run.py # anderer Port
```
Für die Entwicklung mit automatischem Neuladen:
```bash
uvicorn core.app:app --reload --port 8080
```
## Einrichten
Nach dem ersten Start einmal auf **Einstellungen** (⚙ oben rechts):
| Gruppe | wird gebraucht von |
|---|---|
| **Tandoor** — URL, Token, Auth-Schema | AI Import, Rezept-Inventur, Stammdaten, Nährwerte |
| **OpenAI** — API-Key, Modell | AI Import |
| **Rezeptquellen** — TLS, Timeouts, private IPs | AI Import |
Die Werte landen in `data/settings.json` und werden den Tools als
Umgebungsvariablen übergeben — genau so, wie die Tools sie als Einzelprogramm
erwarten. Wer lieber alles in der `.env` pflegt, kann das weiterhin tun: Diese
Werte gelten als Vorgabe, solange in der Oberfläche nichts eingetragen ist.
> Ohne `APP_PASSWORD` ist die Oberfläche ungeschützt. Der Schutz gilt dann für
> das Dashboard und alle Tools gemeinsam.
## Die Tools
### 🏋 Trainingsplan- & Rezept-Editor
Der 8-Wochen-Calisthenics-Plan und die vier Rezeptsammlungen, im Browser
bearbeitbar und per Klick als PDF. Phasen anlegen, duplizieren, exportieren.
Der ReportLab-Renderer ist unverändert der aus dem Einzeltool — das Layout
bleibt identisch.
### 🥕 Tandoor AI Import (v1.1)
URL einfügen → Seite sicher auslesen → OpenAI strukturiert das Rezept →
Vorschau, Warnungen und Zuordnungen prüfen → bewusst importieren → Verifikation,
bei Fehlern Rollback. Jeder Durchlauf wird unter
`data/tandoor-ai-import/runs/<RUN-ID>/` protokolliert.
Jede Zutat hat ein **Dropdown** mit den passenden Tandoor-Einträgen, der beste
Treffer ist vorausgewählt. Gesucht wird über Singular, Plural und Teilwörter —
`Kartoffeln` findet `Kartoffel`. Passt nichts, lässt sich über das Textfeld mit
einem anderen Namen erneut suchen oder der Eintrag per ` Neu anlegen` beim
Import erzeugen. Scheitert der Import, werden Rezept **und** die in diesem Lauf
neu angelegten Stammdaten zurückgerollt.
### 🔍 Rezept-Inventur
Geht die Sammlung durch und meldet, was fehlt: Rezepte ohne Quelle, Bild,
Portionsangabe oder Arbeitszeit, Zutaten ohne Einheit oder Menge, Schritte ohne
Text, doppelte Rezeptnamen, verwaiste Zutaten/Einheiten/Schlagworte. **Liest
ausschließlich** — es gibt keinen schreibenden Endpunkt. Befunde filterbar,
Export als CSV.
### 🧹 Stammdaten aufräumen
Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet
Karteileichen. Vorgeschlagen wird nur, was praktisch sicher dasselbe ist
(`Zwiebel`/`Zwiebeln`, `Vegan`/`vegan`); Grenzfälle wie `Sahne`/`Schlagsahne`
stehen nur zur Ansicht. Tandoor verwirft beim Zusammenführen die Nährwerte des
Quelleintrags — dieses Tool rettet sie vorher ans Ziel. Trockenübung,
Einzelfreigabe und Sicherung je Schritt. Löschen ist nie vorausgewählt.
### 🥑 Nährwerte vervollständigen
Zeigt, bei welchen Zutaten Eigenschaften fehlen (Abdeckung je Eigenschaft,
Lücken nach Rezeptzahl sortiert), lässt sie je 100 g schätzen und schreibt sie
nach Prüfung zurück. Jeder Wert ist vor dem Schreiben editierbar. Vorhandene
Werte bleiben unangetastet, fehlende Bezugsmengen werden mitgesetzt.
Die Zahlen sind Schätzungen eines Sprachmodells, keine Laboranalysen.
### 🏷 Zutaten-Kategorien
Ordnet Zutaten ohne Supermarkt-Kategorie einer der vorhandenen
Kategorien zu — auf Wunsch von ChatGPT vorgeschlagen. Es werden nur
bestehende Kategorien vergeben, jede Zuweisung ist freizugeben und voll
rückspielbar.
### 🔁 Einheiten-Umrechnungen
Findet Zutaten, die in Rezepten mit mehreren, nicht ohne Weiteres
umrechenbaren Einheiten vorkommen (z. B. Stück und g) und wofür noch
keine Umrechnung hinterlegt ist. ChatGPT schlägt sinnvolle Werte vor —
diese sind Schätzungen und vor dem Eintragen zu prüfen. Angelegte
Umrechnungen sind über die Sicherungen wieder löschbar.
## Ein neues Tool hinzufügen
Es gibt zwei Wege:
**Hochladen.** Das Plugin als ZIP packen und im Dashboard auf **„Plugin
importieren“** — fertig. Erkannt wird es an der `id` aus seiner `plugin.json`;
ist die schon vorhanden, wird jene Fassung aktualisiert. Dabei wird nur ergänzt
und überschrieben, **nie gelöscht**, und der Datenordner bleibt unberührt. Der
bisherige Stand wandert vorher in eine Sicherung unter
`data/_plugin-sicherungen/<id>/`.
**Direkt ablegen.** Ordner unter `plugins/<name>/` anlegen, `plugin.json` und
`backend.py` dazulegen und im Dashboard **„Plugins neu laden“** klicken — die
Karte erscheint sofort, ohne die Suite neu zu starten. Der Knopf liest `plugins/` neu
ein, hängt neue oder geänderte Tools frisch ein und entfernt gelöschte. (Nur
Änderungen unter `core/` selbst brauchen weiterhin einen echten Neustart.)
Die vollständige Bauanleitung samt Design-Bausteinen steht in
**[`plugins/README.md`](plugins/README.md)**.
## Aufbau
```
boehmitools/
├── run.py Start
├── core/ Host: Dashboard, Plugin-Lader, Einstellungen, Jobs
│ ├── app.py mountet jedes Plugin unter /plugins/<id>
│ ├── registry.py liest plugin.json, lädt backend.py
│ ├── settings.py data/settings.json ⇄ Umgebungsvariablen
│ ├── jobs.py Subprozess-Läufe mit Live-Protokoll (SSE)
│ ├── loader.py kollisionsfreier Import der Tool-Module
│ └── static/ Dashboard, Einstellungen, Läufe
├── shared/ das gemeinsame Aussehen
│ ├── boehmi.css Design-System (hell/dunkel)
│ ├── boehmi.js App-Bar, Tool-Umschalter, BT.api/BT.toast
│ └── boehmi-runner.js Protokoll-Ansicht für Kommandozeilen-Tools
├── plugins/ ein Ordner je Tool ← hier wächst der Baukasten
└── data/ alles Veränderliche (Volume)
├── settings.json
├── trainingsplan/plans/
├── tandoor-ai-import/runs/
├── tandoor-cleanup/laeufe/
└── tandoor-nutrition/laeufe/
```
Ein Backup ist das Sichern des Ordners `data/`.
## Tools weiterhin einzeln starten
Jedes Plugin ist für sich lauffähig geblieben:
```bash
cd plugins/trainingsplan && python app.py
cd plugins/tandoor-ai-import && uvicorn app.main:app --port 8091
```
Die Original-Anleitungen der Tools liegen als `TOOL-README.md` bzw.
`TOOL-README.txt` in den jeweiligen Plugin-Ordnern; beim AI-Import steht in
`TOOL-UPGRADE.md` zusätzlich, was v1.1 geändert hat.
## Hinweise
- Gedacht für das **lokale Netzwerk**. Wenn erreichbar: `APP_PASSWORD` setzen
oder einen Reverse Proxy davorsetzen.
- Die Tokens verlassen das Backend nicht; OpenAI bekommt ausschließlich die
extrahierten Rezeptdaten und die Quell-URL.
- Läuft die Suite hinter einem Reverse Proxy in einem Unterpfad, in den
HTML-Seiten `<meta name="bt-root" content="/unterpfad">` ergänzen.
## Sicherungen und Zurückspielen
Die beiden Plugins, die etwas in Tandoor verändern, schreiben vor jeder Änderung
eine Sicherung und lassen sich über einen Reiter „Sicherungen“ wieder
zurückspielen. Jeder Lauf, der wirklich schreibt, landet unter
`data/<plugin>/laeufe/<zeit>/` samt einer `manifest.json`, in der Schritt für
Schritt steht, was geändert wurde und wie der Zustand vorher war.
Wie weit ein Lauf zurückgeholt werden kann, hängt vom Eingriff ab und steht
ehrlich an jedem Lauf:
* **🥑 Nährwerte** *vollständig rückspielbar.* Die alten Werte werden exakt
zurückgeschrieben. Wurde eine Zutat seither von Hand geändert, wird sie
erkannt und übersprungen.
* **🧹 Stammdaten** *teilweise rückspielbar.* Zusammengeführte und gelöschte
Einträge werden neu angelegt und die aufgezeichneten Rezeptverweise
zurückgehängt. Die alte ID ist in Tandoor vergeben und kommt nicht zurück;
Einkaufslisten, Umrechnungen und Automatisierungen bleiben beim Ziel. Das
Werkzeug zeichnet dafür vor jedem Zusammenführen alle Rezeptverweise auf.
Zurückspielen gibt es immer als Trockenübung zuerst. Jeder Lauf, der schon
einmal zurückgespielt wurde, ist markiert und muss ausdrücklich bestätigt
werden.
+41
View File
@@ -0,0 +1,41 @@
# Trainingstools 2.0
## Datenmodell und Stabilität
- stabile IDs für Plan, Phasen, Tage, Rotationen, Übungsinstanzen, Bewegungen, Progressionen und Progressionsstufen
- dauerhaft gespeicherte `legacy_id` für die sichere Migration alter Positionsschlüssel
- Progressionsstufen als Objekte mit Name, Schema, Cluster, Faktor und Phasenbezug
- gemeinsamer, versionierter Datenvertrag zwischen Planeditor und Tracker
- veröffentlichte Planfassung und bearbeitbarer Entwurf getrennt
- Planrevision pro Session gespeichert
- atomische Plan- und Tracker-Schreibvorgänge
- Konfliktschutz über Revisionsnummern und Session-Patches
## Planeditor
- Validierung vor Veröffentlichung
- stabil-ID-basierter Änderungsbericht
- Tracker-Vorschau für Ergebnisfelder und feste Intervalle
- automatische, anschließend editierbare Migration fehlender Ergebnisschemata
- planbezogene Übungsbibliothek mit Bewegungsclustern und Progressionsfaktoren
- KI-Vorschläge mit Ziel-IDs, direkter Navigation zum Ziel und manueller Bestätigung
## Tracker
- Übungen können erledigt, teilweise erledigt oder mit Grund übersprungen werden
- Woche kann explizit geöffnet oder abgeschlossen werden
- Sessiondaten werden nur als Patch gespeichert
- historische Sessions bleiben ihrer Planrevision zugeordnet
- robuste Analysejobs mit Dateisperre, Heartbeat und Ablauf-Lease
- Analysehash berücksichtigt Planrevision, Schema-, Prompt-, Modell- und Normalisierungsversion
- genau eine aktuelle Analyse je Woche und eine Gesamtanalyse
- Neuberechnung überschreibt die vorhandene Analyse
- alte Analysehistorien werden automatisch auf das Ein-Datei-Modell reduziert
- strukturierte Planvorschläge werden an den Planeditor übergeben
## Nicht enthalten
- keine strukturierten Technik-, RIR- oder RPE-Werte
- kein unterschiedliches Gewicht pro Satz
- kein Trainings- oder Intervalltimer
- keine Analysehistorie und keine Analyseversionierung
+12
View File
@@ -0,0 +1,12 @@
# boehmitools-core
Einzige Quelle für das gemeinsame boehmitools-Grundgerüst.
Der Core enthält keine fachlichen Instanz-Plugins und keine Laufzeitdaten.
Beide Prozesse verwenden denselben Core-Code und setzen nur unterschiedliche
Verzeichnisse über Umgebungsvariablen:
- BOEHMITOOLS_PLUGINS
- BOEHMITOOLS_DATA
- PORT
- HOST
+1
View File
@@ -0,0 +1 @@
"""boehmitools gemeinsame Host-Anwendung für alle Tool-Plugins."""
+112
View File
@@ -0,0 +1,112 @@
"""
Gemeinsamer, robuster OpenAI-Zugang für die Plugins.
Kapselt zwei wiederkehrende Sorgen:
* **Parameter-Verträglichkeit.** Neuere Modelle (o-Reihe, GPT-5-Reihe) lehnen
`temperature` ungleich dem Standard und teils `response_format` ab. Statt
daran zu scheitern, wird von der genauesten zur schlichtesten Variante
durchprobiert — aber nur bei Parameter-Fehlern. Echte Fehler (falsches
Modell, Auth, Netz) werden sofort durchgereicht.
* **Antwort lesen.** Modelle schludern manchmal mit Markdown-Zäunen oder packen
das Ergebnis in einen Wrapper. `parse_json` gleicht das aus.
Der Schlüssel kommt aus der Umgebung (OPENAI_API_KEY). Das Modell aus dem
Aufrufer oder OPENAI_MODEL, Vorgabe „gpt-5.5“.
"""
from __future__ import annotations
import json
import os
import traceback
from typing import Any
DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.5")
# Merkt sich prozessweit, welche Aufruf-Variante das Modell akzeptiert, damit
# nicht jede Anfrage erneut durchprobiert wird.
_CHAT_VARIANT: dict[str, Any] | None = None
def parse_json(text: str) -> Any:
"""Antworttext zu Daten machen, auch bei Markdown-Zaun oder Wrapper."""
text = (text or "").strip()
if text.startswith("```"):
text = text.strip("`")
if text[:4].lower() == "json":
text = text[4:]
text = text.strip()
data = json.loads(text)
# Einzeln verschachtelten Wrapper auspacken, aber nur wenn die Werte selbst
# Tabellen sind (sonst würde ein echtes einelementiges Ergebnis zerstört).
if isinstance(data, dict) and len(data) == 1:
(only,) = data.values()
if isinstance(only, dict) and only and all(isinstance(v, (dict, list)) for v in only.values()):
return only
return data
def _chat_completion(client, model: str, messages: list[dict[str, str]]):
global _CHAT_VARIANT
varianten = [
{"response_format": {"type": "json_object"}, "temperature": 0},
{"response_format": {"type": "json_object"}},
{"temperature": 0},
{},
]
if _CHAT_VARIANT is not None:
varianten = [_CHAT_VARIANT]
letzter = None
for extra in varianten:
try:
antwort = client.chat.completions.create(model=model, messages=messages, **extra)
_CHAT_VARIANT = extra
return antwort
except Exception as exc: # noqa: BLE001
letzter = exc
text = str(exc).lower()
parameterfehler = any(w in text for w in (
"temperature", "response_format", "unsupported", "not supported",
"unknown_parameter", "invalid_request", "unexpected keyword",
))
if not parameterfehler:
raise
raise letzter
def chat_json(messages: list[dict[str, str]], model: str | None = None) -> Any:
"""Eine Chat-Abfrage, deren Antwort als JSON gelesen zurückkommt."""
from openai import OpenAI
client = OpenAI()
response = _chat_completion(client, model or DEFAULT_MODEL, messages)
text = response.choices[0].message.content or "{}"
return parse_json(text)
def probe(model: str | None = None) -> tuple[bool, str]:
"""
Eine einzelne Testabfrage. Rückgabe: (erfolg, Klartext-Meldung).
Braucht kein Tandoor — nur OPENAI_API_KEY.
"""
model = model or DEFAULT_MODEL
try:
ergebnis = chat_json(
[{"role": "system", "content": "Antworte ausschließlich mit JSON."},
{"role": "user", "content": 'Gib genau zurück: {"ok": true}'}],
model,
)
except Exception as exc: # noqa: BLE001
low = str(exc).lower()
hinweis = ""
if "model" in low and ("not" in low or "exist" in low or "unknown" in low):
hinweis = " → Modell nicht vorhanden/freigeschaltet. In den Einstellungen ändern."
elif "api key" in low or "authentication" in low or "401" in low:
hinweis = " → API-Schlüssel wird nicht akzeptiert. OPENAI_API_KEY prüfen."
elif "quota" in low or "insufficient" in low or "429" in low:
hinweis = " → Kontingent/Guthaben erschöpft."
return False, f"OpenAI nicht erreichbar: {exc}{hinweis}\n\n{traceback.format_exc().strip()}"
return True, f"OpenAI antwortet. Verwendete Aufruf-Variante: {_CHAT_VARIANT}. Antwort: {ergebnis}"
+233
View File
@@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
"""
boehmitools Host-Anwendung.
Startet das Dashboard, lädt alle Plugins aus plugins/ und hängt sie unter
ihrem Mount-Pfad ein. Danach verhält sich jedes Tool so, als liefe es allein:
eigene Routen, eigenes Backend, eigener Datenordner nur eben unter einem
gemeinsamen Dach und mit einheitlicher Oberfläche.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.routing import Mount
from . import importer, registry, settings as settings_module
from .jobs import JobManager
from .registry import PluginContext
from .security import basic_auth_middleware
from .settings import Settings
BASE_DIR = Path(__file__).resolve().parent.parent
PLUGINS_DIR = Path(
os.environ.get("BOEHMITOOLS_PLUGINS", BASE_DIR / "plugins")
).resolve()
SHARED_DIR = BASE_DIR / "shared"
STATIC_DIR = BASE_DIR / "core" / "static"
DATA_DIR = Path(os.environ.get("BOEHMITOOLS_DATA", BASE_DIR / "data")).resolve()
VERSION = "1.0.0"
def create_app() -> FastAPI:
DATA_DIR.mkdir(parents=True, exist_ok=True)
app_settings = Settings.load(DATA_DIR / "settings.json")
jobs = JobManager()
app = FastAPI(
title="boehmitools",
version=VERSION,
docs_url="/api/docs",
redoc_url=None,
)
app.middleware("http")(basic_auth_middleware)
app.state.settings = app_settings
app.state.jobs = jobs
app.state.plugins = []
# ------------------------------------------------------------- Plugins
loaded: list[registry.LoadedPlugin] = []
def mount_plugins(*, reload: bool = False) -> dict[str, Any]:
"""
Lädt alle Plugins aus plugins/ und hängt sie ein. Bei ``reload=True``
werden vorher die bisherigen Plugin-Mounts entfernt und die zugehörigen
Module aus dem Cache geworfen, damit geänderter oder neuer Code (und neu
hinzugekommene bzw. entfernte Plugins) ohne Neustart wirksam werden.
Statik (HTML/CSS/JS) wird ohnehin bei jedem Aufruf frisch von der Platte
gelesen; dafür genügt ein Neuladen im Browser. Kernänderungen unter
core/ erfordern weiterhin einen echten Neustart.
"""
if reload:
# Bisherige Plugin-Mounts aus dem Router nehmen (in place, damit der
# Router dieselbe Liste weiterbenutzt).
app.router.routes[:] = [
r for r in app.router.routes
if not (isinstance(r, Mount) and (r.name or "").startswith("plugin_"))
]
for old in loaded:
registry.unload(old.meta)
neu: list[registry.LoadedPlugin] = []
for meta in registry.discover(PLUGINS_DIR):
plugin_data = DATA_DIR / meta.id
plugin_data.mkdir(parents=True, exist_ok=True)
ctx = PluginContext(
meta=meta,
settings=app_settings,
jobs=jobs,
data_dir=plugin_data,
shared_dir=SHARED_DIR,
)
result = registry.load(meta, ctx)
neu.append(result)
if result.ok:
app.mount(meta.mount, result.app, name=f"plugin_{meta.id}")
print(f"[plugins] {meta.id:<24}{meta.mount}")
else:
print(f"[plugins] {meta.id:<24} → FEHLER: {result.error}")
loaded[:] = neu
app.state.plugins = loaded
return {
"gesamt": len(loaded),
"geladen": sum(1 for p in loaded if p.ok),
"fehler": [
{"id": p.meta.id, "error": p.error} for p in loaded if not p.ok
],
}
mount_plugins()
def plugin_payload() -> list[dict[str, Any]]:
return [
{**p.meta.public(), "ok": p.ok, "error": p.error}
for p in loaded
]
# -------------------------------------------------------------- Statik
app.mount("/shared", StaticFiles(directory=SHARED_DIR), name="shared")
# -------------------------------------------------------------- Seiten
@app.get("/", include_in_schema=False)
def dashboard():
return FileResponse(STATIC_DIR / "dashboard.html")
@app.get("/settings", include_in_schema=False)
def settings_page():
return FileResponse(STATIC_DIR / "settings.html")
@app.get("/jobs", include_in_schema=False)
def jobs_page():
return FileResponse(STATIC_DIR / "jobs.html")
# ----------------------------------------------------------------- API
@app.get("/api/plugins")
def api_plugins() -> dict[str, Any]:
return {"plugins": plugin_payload(), "version": VERSION}
@app.post("/api/plugins/reload")
def api_plugins_reload() -> dict[str, Any]:
"""Plugins ohne Neustart neu einlesen und einhängen."""
bericht = mount_plugins(reload=True)
return {
"ok": True,
"plugins": plugin_payload(),
"version": VERSION,
**bericht,
}
@app.post("/api/plugins/import")
async def api_plugins_import(
file: UploadFile = File(...),
force: bool = Form(False),
pruefen_only: bool = Form(False),
) -> dict[str, Any]:
"""
Ein Plugin als ZIP hochladen.
Erkannt wird das Plugin an der ``id`` aus seiner ``plugin.json``. Ist
diese id bereits vorhanden, wird genau jener Ordner ergänzt und
überschrieben — niemals gelöscht. Der Datenordner (``data/<id>/``)
liegt außerhalb und bleibt in jedem Fall unberührt.
"""
daten = await file.read()
try:
if pruefen_only:
return {"ok": True, "vorschau": importer.pruefe(daten, PLUGINS_DIR)}
bericht = importer.importiere(
daten, PLUGINS_DIR, DATA_DIR / "_plugin-sicherungen", force=force)
except importer.ImportFehler as exc:
raise HTTPException(400, str(exc))
# Frisch eingelesen, damit das Plugin sofort nutzbar ist.
neu_geladen = mount_plugins(reload=True)
eigen = next((p for p in loaded if p.meta.id == bericht["id"]), None)
# Achtung: der Reload-Bericht führt selbst ein Feld „geladen“ (Anzahl).
# Der Zustand DIESES Plugins heißt deshalb „aktiv“.
bericht["aktiv"] = bool(eigen and eigen.ok)
bericht["ladefehler"] = None if not eigen or eigen.ok else eigen.error
return {**neu_geladen, **bericht, "plugins": plugin_payload()}
@app.get("/api/health")
def api_health() -> dict[str, Any]:
status = app_settings.status()
return {
"status": "ok",
"version": VERSION,
"plugins": {p.meta.id: ("ok" if p.ok else "error") for p in loaded},
"tandoor_configured": status["tandoor"],
"openai_configured": status["openai"],
"authentication_enabled": bool(os.environ.get("APP_PASSWORD")),
"jobs_running": len(jobs.running()),
}
@app.get("/api/settings")
def api_settings_get() -> dict[str, Any]:
return {
"schema": settings_module.schema(),
"values": app_settings.public(),
"status": app_settings.status(),
}
@app.post("/api/settings")
async def api_settings_post(request: Request) -> dict[str, Any]:
payload = await request.json()
if not isinstance(payload, dict):
raise HTTPException(400, "Ungültige Daten.")
app_settings.update(payload)
return {
"ok": True,
"values": app_settings.public(),
"status": app_settings.status(),
}
@app.get("/api/jobs")
def api_jobs_all() -> dict[str, Any]:
names = {p.meta.id: p.meta.name for p in loaded}
return {
"jobs": [
{**job.info(), "plugin_name": names.get(job.plugin, job.plugin)}
for job in jobs.list()
]
}
@app.exception_handler(404)
async def not_found(request: Request, exc):
if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "Nicht gefunden."}, status_code=404)
return FileResponse(STATIC_DIR / "404.html", status_code=404)
return app
app = create_app()
+219
View File
@@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
"""
Gemeinsame Sicherungs-Verwaltung für die schreibenden Plugins.
Jeder verändernde Lauf legt einen Ordner unter `data/<plugin>/laeufe/<zeit>/`
an und schreibt dort eine `manifest.json`. Darin steht Schritt für Schritt, was
geändert wurde und wie der Zustand vorher aussah — genug, um es zurückzudrehen.
Was ein Rückspielen leisten kann, unterscheidet sich je nach Eingriff. Das steht
ehrlich im Manifest, damit die Oberfläche es anzeigen kann statt zu behaupten,
alles ließe sich rückstandsfrei rückgängig machen:
"voll" Der vorherige Zustand lässt sich exakt wiederherstellen.
Beispiel: geänderte Nährwerte — der alte Wert wird
zurückgeschrieben.
"neue_id" Der Eintrag kommt zurück, bekommt aber eine neue ID. Bei
Einträgen, auf die nichts zeigte (unbenutzt gelöscht), ist das
folgenlos.
"teilweise" Der Eintrag kommt mit neuer ID zurück und die aufgezeichneten
Verweise werden zurückgehängt. Was nicht aufgezeichnet wurde
(Einkaufslisten, Automatisierungen, Umrechnungen), bleibt beim
Ziel.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pydantic import BaseModel
class RestoreRequest(BaseModel):
run: str
apply: bool = False
force: bool = False
MANIFEST = "manifest.json"
RESTORE_MARK = "zurueckgespielt.json"
RESTORE_LEVELS = {
"voll": "vollständig rückspielbar",
"neue_id": "rückspielbar, Eintrag bekommt neue ID",
"teilweise": "nur teilweise rückspielbar",
"nein": "nicht rückspielbar",
}
def stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def write_json(path: Path, payload: Any) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
temporary.replace(path)
return path
def write_manifest(run_dir: Path, payload: dict[str, Any]) -> Path:
return write_json(run_dir / MANIFEST, payload)
def read_manifest(run_dir: Path) -> dict[str, Any] | None:
file = run_dir / MANIFEST
if not file.is_file():
return None
try:
return json.loads(file.read_text(encoding="utf-8"))
except Exception:
return None
def mark_restored(run_dir: Path, info: dict[str, Any]) -> None:
"""Vermerkt, dass dieser Lauf zurückgespielt wurde."""
existing = []
file = run_dir / RESTORE_MARK
if file.is_file():
try:
existing = json.loads(file.read_text(encoding="utf-8"))
except Exception:
existing = []
if not isinstance(existing, list):
existing = [existing]
existing.append({"at": datetime.now(timezone.utc).isoformat(), **info})
write_json(file, existing)
def restore_history(run_dir: Path) -> list[dict[str, Any]]:
file = run_dir / RESTORE_MARK
if not file.is_file():
return []
try:
data = json.loads(file.read_text(encoding="utf-8"))
return data if isinstance(data, list) else [data]
except Exception:
return []
def summarize(manifest: dict[str, Any]) -> dict[str, Any]:
"""Kurzfassung eines Laufs für die Übersicht."""
steps = manifest.get("steps") or []
done = [s for s in steps if s.get("status") == "done"]
levels = {s.get("restore_level", "nein") for s in done}
worst = ("nein" if "nein" in levels
else "teilweise" if "teilweise" in levels
else "neue_id" if "neue_id" in levels
else "voll" if levels else "nein")
return {
"steps": len(steps),
"done": len(done),
"restore_level": worst,
"restore_label": RESTORE_LEVELS[worst],
}
def list_runs(runs_dir: Path) -> list[dict[str, Any]]:
"""Alle Läufe eines Plugins, neueste zuerst."""
if not runs_dir.is_dir():
return []
runs: list[dict[str, Any]] = []
for path in sorted(runs_dir.iterdir(), reverse=True):
if not path.is_dir():
continue
manifest = read_manifest(path)
if manifest is None:
continue
history = restore_history(path)
runs.append({
"id": path.name,
"created_at": manifest.get("created_at"),
"tandoor": manifest.get("tandoor"),
"label": manifest.get("label") or "",
**summarize(manifest),
"restored": bool(history),
"restored_at": history[-1]["at"] if history else None,
})
return runs
def resolve_run(runs_dir: Path, name: str) -> Path:
"""Lauf-Ordner sicher auflösen — kein Ausbrechen aus dem Datenordner."""
candidate = runs_dir / Path(name).name
if not candidate.is_dir() or not (candidate / MANIFEST).is_file():
raise FileNotFoundError(f"Lauf „{name}“ gibt es nicht.")
return candidate
# ------------------------------------------------------- Router fürs Plugin
def backup_router(ctx, runs_subdir: str = "laeufe", restore_argv=None):
"""
Fertiger Satz Endpunkte für die Sicherungen eines Plugins.
Damit sieht der Reiter „Sicherungen“ in jedem schreibenden Plugin gleich
aus und verhält sich gleich:
GET /api/backups Läufe auflisten
GET /api/backups/{id} ein Lauf im Detail
POST /api/run/restore zurückspielen (Trockenübung oder echt)
`restore_argv(run_id, apply, force)` liefert die Kommandozeile für den
Job-Runner. Das Zurückspielen läuft damit über dasselbe Skript und
denselben Weg wie das Ändern selbst — nicht über einen zweiten,
ungetesteten Pfad.
"""
from fastapi import APIRouter, HTTPException
runs_dir = ctx.data_dir / runs_subdir
router = APIRouter()
@router.get("/api/backups")
def backups_list() -> dict[str, Any]:
return {"runs": list_runs(runs_dir), "dir": str(runs_dir)}
@router.get("/api/backups/{name}")
def backup_detail(name: str) -> dict[str, Any]:
try:
run = resolve_run(runs_dir, name)
except FileNotFoundError as exc:
raise HTTPException(404, str(exc)) from exc
manifest = read_manifest(run) or {}
return {
"id": run.name,
"manifest": manifest,
"summary": summarize(manifest),
"history": restore_history(run),
"files": sorted(p.name for p in run.glob("*.json")),
}
@router.post("/api/run/restore")
async def run_restore(request: RestoreRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["tandoor"]:
raise HTTPException(400, "Tandoor-URL und Token fehlen.")
try:
run = resolve_run(runs_dir, request.run)
except FileNotFoundError as exc:
raise HTTPException(404, str(exc)) from exc
env = ctx.settings.tool_env()
env["DATA_DIR"] = str(ctx.data_dir)
job = await ctx.jobs.start(
plugin=ctx.id,
label=("Zurückspielen" if request.apply else "Trockenübung Zurückspielen")
+ f": {run.name}",
argv=restore_argv(run.name, request.apply, request.force),
cwd=ctx.data_dir, env=env,
)
return job.info()
return router
+297
View File
@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""
Namensabgleich für deutsche Zutaten-, Einheiten- und Schlagwortnamen.
Der AI-Import fragt: „Welcher vorhandene Eintrag könnte gemeint sein?“ — dort
darf großzügig vorgeschlagen werden, ein Mensch entscheidet im Dropdown.
Hier ist die Frage eine andere: „Welche Einträge sind dasselbe und dürfen
zusammengeführt werden?“ Zusammenführen ist in Tandoor nicht umkehrbar, also
gilt hier das Gegenteil: im Zweifel nicht.
Deshalb bewusst zwei getrennte Stufen:
duplicate_groups() nur was praktisch sicher dasselbe ist — gleicher Name
oder Singular/Plural. Nur das wird zum Zusammenführen
vorgeschlagen.
similar_pairs() alles Grenzwertige — „Sahne / Schlagsahne“. Wird nur
aufgelistet, nie vorausgewählt.
Bekannte Lücke: unregelmäßige Plurale mit Umlaut (Ei/Eier, Apfel/Äpfel)
erkennt die Heuristik nicht. Sie tauchen dann in keiner der beiden Listen auf.
"""
from __future__ import annotations
import re
import unicodedata
from difflib import SequenceMatcher
from typing import Any, Iterable
_SPACES = re.compile(r"\s+")
_NOISE = re.compile(r"[^\w\s]", re.UNICODE)
# Wörter, die zwei Einträge inhaltlich verschieden machen. Tauchen sie nur auf
# einer Seite auf, ist es keine Dublette „Rote Zwiebel“ ist nicht „Zwiebel“.
DISTINGUISHING = {
"rot", "rote", "roter", "rotes", "gruen", "gruene", "gruener", "gelb",
"gelbe", "weiss", "weisse", "weisser", "schwarz", "schwarze", "braun",
"braune", "gross", "grosse", "klein", "kleine", "frisch", "frische",
"getrocknet", "getrocknete", "gemahlen", "gemahlene", "geraeuchert",
"geraeucherte", "tiefgekuehlt", "roh", "rohe", "gekocht", "gekochte",
"suess", "suesse", "sauer", "saure", "fettarm", "fettarme", "vollfett",
"halbfett", "mager", "magere", "light", "vegan", "vegetarisch", "bio",
"glatt", "glatte", "kraus", "geschaelt", "geschaelte", "ganz", "ganze",
}
# Beschreibende Endungen, die einen Eintrag zu einer eigenen Sorte machen:
# laktoseFREI, fettARM, eiweissREICH, fettREDUZIERT, zuckerHALTIG …
_DISTINGUISHING_MARKERS = ("frei", "arm", "reich", "reduziert", "haltig")
_MARKER_INFLECTIONS = ("", "e", "er", "es", "en", "em")
def is_distinguishing_word(word: str) -> bool:
"""
Macht dieses Wort zwei Einträge inhaltlich verschieden?
Entweder es steht auf der festen Liste, oder es endet auf einen
beschreibenden Marker (mit Beugung). Der Wortstamm davor muss lang genug
sein, damit z. B. „Marmelade“ nicht wegen „arm“ anschlägt.
"""
if word in DISTINGUISHING:
return True
for marker in _DISTINGUISHING_MARKERS:
for inflection in _MARKER_INFLECTIONS:
suffix = marker + inflection
if word.endswith(suffix) and len(word) - len(suffix) >= 3:
return True
return False
def folded(value: Any) -> str:
"""Kleinschreibung, Umlaute als ae/oe/ue/ss, Akzente weg."""
text = str(value or "").strip().casefold()
for source, target in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
text = text.replace(source, target)
text = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in text if not unicodedata.combining(ch))
def comparable(value: Any) -> str:
return _SPACES.sub(" ", _NOISE.sub(" ", folded(value))).strip()
def word_forms(word: str) -> set[str]:
"""Häufige deutsche Singular-/Pluralformen eines einzelnen Wortes."""
word = word.strip()
if len(word) <= 2:
return {word} if word else set()
forms = {word}
if word.endswith("eln") or word.endswith("ern"):
forms.add(word[:-1])
if word.endswith("en") and len(word) > 4:
forms.add(word[:-2])
forms.add(word[:-1])
if word.endswith("n") and len(word) > 4:
forms.add(word[:-1])
if word.endswith("e"):
forms.add(word + "n")
else:
forms.update({word + "e", word + "en", word + "n", word + "s"})
return {form for form in forms if len(form) >= 3}
def same_word(a: str, b: str) -> bool:
"""Zwei Wörter sind gleich oder Singular/Plural voneinander."""
return a == b or b in word_forms(a) or a in word_forms(b)
def looks_like_plural(base: str, candidate: str) -> bool:
"""
Ist `candidate` eine plausible Pluralform von `base`?
Nur bei gleicher Wortzahl und wenn jedes abweichende Wort eine erkennbare,
nicht kürzere Pluralform ist. Bewusst konservativ: „Zwiebel“ → „Zwiebeln“
ja, „Sahne“ → „Schlagsahne“ nein. Umlaut-Plurale (Apfel → Äpfel) erkennt
die Heuristik nicht.
"""
base_c, cand_c = comparable(base), comparable(candidate)
if not base_c or not cand_c or base_c == cand_c:
return False
bw, cw = base_c.split(), cand_c.split()
if len(bw) != len(cw):
return False
veraendert = False
for x, y in zip(bw, cw):
if x == y:
continue
if y in word_forms(x) and len(y) >= len(x):
veraendert = True
continue
return False
return veraendert
def guess_plural(name: str) -> str:
"""
Grober Pluralvorschlag für ein einzelnes deutsches Wort — nur als
editierbare Vorbelegung gedacht, nicht als Wahrheit.
Bei Mehrwortnamen und unsicheren Endungen bewusst leer, damit nichts
Falsches vorgeschlagen wird.
"""
n = (name or "").strip()
if not n or " " in n or "-" in n:
return ""
low = n.casefold()
# -e -> -en (Tomate -> Tomaten, Zwiebel bleibt aber Sache des Nutzers)
if low.endswith("e"):
return n + "n"
# -el/-er/-en oft unverändert; kein sicherer Vorschlag
if low.endswith(("el", "er", "en", "chen", "lein")):
return ""
if low.endswith(("a", "o", "i", "u", "y")):
return n + "s"
return n + "e"
def is_variant(a: str, b: str) -> bool:
"""
Gleiche Wortzahl und Wort für Wort dieselbe Bedeutung.
Die Wortzahl-Bedingung ist der Kern: Sie verhindert, dass „Rote Zwiebel“
auf „Zwiebel“ oder „Mehl Type 550“ auf „Mehl“ zusammenfällt.
"""
left, right = comparable(a).split(), comparable(b).split()
if not left or len(left) != len(right):
return False
return all(same_word(x, y) for x, y in zip(left, right))
def similarity(left: str, right: str) -> tuple[float, str]:
"""Wie ähnlich sind zwei Namen? Rückgabe: (0100, Begründung)."""
a, b = comparable(left), comparable(right)
if not a or not b:
return 0.0, "leer"
if a == b:
return 100.0, "gleicher Name"
if is_variant(a, b):
return 96.0, "Singular/Plural"
a_words, b_words = set(a.split()), set(b.split())
unterscheidend = {w for w in (a_words ^ b_words) if is_distinguishing_word(w)}
if unterscheidend:
return 40.0, f"unterscheidet sich durch „{sorted(unterscheidend)[0]}"
# Alles Weitere ist bestenfalls ein Hinweis. Die Obergrenze liegt bewusst
# unter der Dublettenschwelle: Teilwörter sind in Zusammensetzungen die
# Regel (Sahne/Schlagsahne, Kartoffel/Süßkartoffel) und keine Dubletten.
#
# Wichtig: „Teilwort“ meint eine echte WORT-Teilmenge — jedes Wort der
# kürzeren Seite kommt als ganzes Wort in der längeren vor. Eine bloße
# Zeichenketten-Enthaltung reicht NICHT, sonst gelten „Mango“/„Mangold“,
# „Buchweizen“/„Buchweizenmehl“ oder „getrocknete Tomate“/„getrocknete
# Tomaten in Öl“ fälschlich als ähnlich.
if a_words < b_words or b_words < a_words:
short = min(len(a_words), len(b_words))
long = max(len(a_words), len(b_words))
return min(70.0 + (short / long) * 15.0, 85.0), "Teilwort"
overlap = len(a_words & b_words) / max(1, len(a_words | b_words))
ratio = SequenceMatcher(None, a, b).ratio()
return min(max(ratio * 76.0, overlap * 80.0), 85.0), "ähnlich"
def names_of(obj: dict[str, Any]) -> list[str]:
return [value for value in (obj.get("name"), obj.get("plural_name")) if value]
def best_match(left: dict[str, Any], right: dict[str, Any]) -> tuple[float, str]:
best = (0.0, "")
for a in names_of(left):
for b in names_of(right):
score, reason = similarity(a, b)
if score > best[0]:
best = (score, reason)
return best
def _rank_target(member: dict[str, Any]) -> tuple:
"""Ziel einer Zusammenführung: der Eintrag, der am meisten zu verlieren hat."""
return (
-(member.get("numrecipe") or 0), # in den meisten Rezepten
-len(member.get("properties") or []), # hat Nährwerte
-bool(member.get("plural_name")), # Plural gepflegt
-bool(member.get("supermarket_category")),
len(member.get("name") or ""),
member.get("id") or 0,
)
DUPLICATE_THRESHOLD = 90.0
SIMILAR_THRESHOLD = 74.0
def duplicate_groups(objects: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
"""Gruppen von Einträgen, die praktisch sicher dasselbe sind."""
items = [obj for obj in objects if isinstance(obj.get("id"), int)]
parent: dict[int, int] = {obj["id"]: obj["id"] for obj in items}
reasons: dict[tuple[int, int], tuple[float, str]] = {}
def root(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for index, left in enumerate(items):
for right in items[index + 1:]:
score, reason = best_match(left, right)
if score >= DUPLICATE_THRESHOLD:
reasons[tuple(sorted((left["id"], right["id"])))] = (score, reason)
parent[root(left["id"])] = root(right["id"])
clusters: dict[int, list[dict[str, Any]]] = {}
for obj in items:
clusters.setdefault(root(obj["id"]), []).append(obj)
groups: list[dict[str, Any]] = []
for members in clusters.values():
if len(members) < 2:
continue
target = sorted(members, key=_rank_target)[0]
best_score, best_reason = 0.0, "ähnlich"
for member in members:
if member["id"] == target["id"]:
continue
key = tuple(sorted((member["id"], target["id"])))
score, reason = reasons.get(key, (0.0, "über einen dritten Eintrag"))
if score > best_score:
best_score, best_reason = score, reason
groups.append({
"target_id": target["id"],
"target": target,
"members": sorted(members, key=_rank_target),
"sources": [m for m in members if m["id"] != target["id"]],
"score": round(best_score, 1),
"reason": best_reason,
"recipes_affected": sum((m.get("numrecipe") or 0) for m in members),
})
groups.sort(key=lambda g: -g["recipes_affected"])
return groups
def similar_pairs(objects: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
"""Grenzwertige Paare — nur zum Anschauen, nie zum Zusammenführen."""
items = [obj for obj in objects if isinstance(obj.get("id"), int)]
pairs: list[dict[str, Any]] = []
for index, left in enumerate(items):
for right in items[index + 1:]:
score, reason = best_match(left, right)
if SIMILAR_THRESHOLD <= score < DUPLICATE_THRESHOLD:
pairs.append({
"left": left, "right": right,
"score": round(score, 1), "reason": reason,
})
pairs.sort(key=lambda p: -p["score"])
return pairs
+275
View File
@@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
"""
Plugins als ZIP hochladen.
Identität eines Plugins ist die ``id`` aus ``plugin.json`` — nicht der
Ordnername. Daran hängen Datenordner (``data/<id>/``), Adresse
(``/plugins/<id>``), Modul-Namensraum und Job-Zuordnung. Wird ein Plugin mit
bekannter ``id`` hochgeladen, ersetzt es genau dessen Ordner, egal wie der
hochgeladene Ordner heißt.
Grundregel beim Ersetzen: **nur ergänzen und überschreiben, nie löschen.**
Dateien, die im Archiv nicht vorkommen, bleiben unangetastet. Der Datenordner
liegt ohnehin außerhalb des Plugin-Ordners und wird nie berührt.
Sicherheit: Ein Plugin ist ausführbarer Code, den der Host importiert. Dieser
Import ist damit bewusst eine Administrator-Funktion. Geprüft wird trotzdem
alles, was sich prüfen lässt — Pfadausbrüche, Symlinks, Größen, Bomben.
"""
from __future__ import annotations
import io
import json
import re
import shutil
import stat
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Was eine gültige Plugin-Kennung sein darf. Bewusst eng: sie wird zum
# Ordnernamen, zum URL-Bestandteil und zum Modulnamen.
ID_MUSTER = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
MAX_ARCHIV = 25 * 1024 * 1024 # 25 MB gepackt
MAX_ENTPACKT = 120 * 1024 * 1024 # 120 MB entpackt (gegen ZIP-Bomben)
MAX_EINTRAEGE = 3000
# Diese Namen dürfen nie aus einem Archiv geschrieben werden.
VERBOTEN = {".git", ".ssh", "__pycache__"}
class ImportFehler(ValueError):
"""Fachlicher Fehler mit einer Meldung, die man dem Menschen zeigen kann."""
def version_tupel(text: str) -> tuple:
"""„1.10.2“ → (1, 10, 2). Nicht-Zahlen landen hinten und zählen als 0."""
teile = re.split(r"[.\-+]", str(text or "0"))
zahlen = []
for t in teile:
m = re.match(r"^(\d+)", t)
zahlen.append(int(m.group(1)) if m else 0)
while len(zahlen) < 3:
zahlen.append(0)
return tuple(zahlen[:4])
def _pfad_ok(name: str) -> bool:
"""Verhindert Pfadausbrüche („zip slip“) und absolute Pfade."""
if not name or name.startswith("/") or name.startswith("\\"):
return False
if ":" in name.split("/")[0] and len(name.split("/")[0]) == 2:
return False # Laufwerksbuchstabe C:
teile = name.replace("\\", "/").split("/")
for t in teile:
if t in ("", ".", ".."):
return False
if t in VERBOTEN:
return False
return True
def _ist_symlink(info: zipfile.ZipInfo) -> bool:
return stat.S_ISLNK(info.external_attr >> 16)
def lies_archiv(daten: bytes) -> dict[str, Any]:
"""
Prüft das Archiv und liest die ``plugin.json``, ohne irgendetwas zu
schreiben. Liefert die Beschreibung des Plugins und die Liste der Dateien.
"""
if len(daten) > MAX_ARCHIV:
raise ImportFehler(
f"Archiv ist zu groß ({len(daten)//1024//1024} MB, erlaubt sind "
f"{MAX_ARCHIV//1024//1024} MB).")
try:
zf = zipfile.ZipFile(io.BytesIO(daten))
except zipfile.BadZipFile:
raise ImportFehler("Das ist keine lesbare ZIP-Datei.")
infos = [i for i in zf.infolist() if not i.is_dir()]
if not infos:
raise ImportFehler("Das Archiv ist leer.")
if len(infos) > MAX_EINTRAEGE:
raise ImportFehler(f"Zu viele Dateien im Archiv ({len(infos)}).")
gesamt = 0
for i in infos:
if _ist_symlink(i):
raise ImportFehler(f"Symbolische Verknüpfungen sind nicht erlaubt: {i.filename}")
if not _pfad_ok(i.filename):
raise ImportFehler(f"Unerlaubter Pfad im Archiv: {i.filename}")
gesamt += i.file_size
if gesamt > MAX_ENTPACKT:
raise ImportFehler("Das Archiv wäre entpackt zu groß.")
# plugin.json mit dem kürzesten Pfad bestimmt den Wurzelordner.
kandidaten = [i.filename for i in infos
if i.filename.replace("\\", "/").split("/")[-1] == "plugin.json"]
if not kandidaten:
raise ImportFehler("Im Archiv fehlt eine plugin.json.")
kandidaten.sort(key=lambda n: (n.count("/"), len(n)))
manifest_pfad = kandidaten[0]
wurzel = manifest_pfad.rsplit("/", 1)[0] + "/" if "/" in manifest_pfad else ""
try:
roh = json.loads(zf.read(manifest_pfad).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ImportFehler(f"plugin.json ist nicht lesbar: {exc}")
if not isinstance(roh, dict):
raise ImportFehler("plugin.json muss ein Objekt enthalten.")
pid = str(roh.get("id") or "").strip()
if not pid:
raise ImportFehler("In der plugin.json fehlt die „id“ — daran wird das "
"Plugin erkannt.")
if not ID_MUSTER.match(pid):
raise ImportFehler(
f"Die id „{pid}“ ist nicht zulässig. Erlaubt sind Kleinbuchstaben, "
"Ziffern, Punkt, Bindestrich und Unterstrich (Beginn mit Buchstabe "
"oder Ziffer).")
dateien = [i.filename for i in infos if i.filename.startswith(wurzel)]
if not any(f[len(wurzel):] == "backend.py" for f in dateien) \
and not roh.get("entrypoint"):
raise ImportFehler("Im Archiv fehlt die backend.py.")
return {
"zf": zf,
"wurzel": wurzel,
"dateien": dateien,
"manifest": roh,
"id": pid,
"name": str(roh.get("name") or pid),
"version": str(roh.get("version") or "0.0.0"),
}
def finde_vorhandenes(plugins_dir: Path, pid: str) -> Path | None:
"""
Sucht den Ordner eines bereits vorhandenen Plugins **anhand der id**, nicht
anhand des Ordnernamens. Nur so wird ein umbenanntes Plugin sauber ersetzt
statt doppelt angelegt.
"""
for manifest in sorted(plugins_dir.glob("*/plugin.json")):
try:
roh = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
vorhandene_id = str(roh.get("id") or manifest.parent.name).strip()
if vorhandene_id == pid:
return manifest.parent
return None
def pruefe(daten: bytes, plugins_dir: Path) -> dict[str, Any]:
"""Vorschau: Was würde der Import tun? Schreibt nichts."""
a = lies_archiv(daten)
vorhanden = finde_vorhandenes(plugins_dir, a["id"])
alt_version = None
if vorhanden:
try:
alt = json.loads((vorhanden / "plugin.json").read_text(encoding="utf-8"))
alt_version = str(alt.get("version") or "0.0.0")
except (OSError, json.JSONDecodeError):
alt_version = "?"
neu = version_tupel(a["version"])
alt_t = version_tupel(alt_version) if alt_version else None
if not vorhanden:
art, warnung = "neu", None
elif alt_t is None or neu > alt_t:
art, warnung = "aktualisierung", None
elif neu == alt_t:
art = "gleiche_version"
warnung = (f"Version {a['version']} ist bereits installiert. Beim "
"Fortfahren werden die Dateien überschrieben.")
else:
art = "aelter"
warnung = (f"Die hochgeladene Version {a['version']} ist ÄLTER als die "
f"installierte {alt_version}.")
return {
"id": a["id"],
"name": a["name"],
"version": a["version"],
"version_alt": alt_version,
"art": art,
"warnung": warnung,
"ordner": vorhanden.name if vorhanden else a["id"],
"dateien": len(a["dateien"]),
"bestaetigung_noetig": art in ("gleiche_version", "aelter"),
}
def importiere(daten: bytes, plugins_dir: Path, sicherungen: Path,
force: bool = False) -> dict[str, Any]:
"""
Schreibt das Plugin nach ``plugins/``.
* Vorhandenes wird **anhand der id** gefunden und im selben Ordner ergänzt.
* Es wird nur geschrieben und überschrieben — **nichts gelöscht**. Dateien,
die das Archiv nicht enthält, bleiben liegen.
* Vorher wandert der bisherige Stand vollständig in eine Sicherung.
"""
vorschau = pruefe(daten, plugins_dir)
if vorschau["bestaetigung_noetig"] and not force:
raise ImportFehler(vorschau["warnung"] or "Bestätigung nötig.")
a = lies_archiv(daten)
zf, wurzel = a["zf"], a["wurzel"]
pid = a["id"]
vorhanden = finde_vorhandenes(plugins_dir, pid)
ziel = vorhanden if vorhanden else (plugins_dir / pid)
ziel_aufgeloest = ziel.resolve()
plugins_aufgeloest = plugins_dir.resolve()
if plugins_aufgeloest not in ziel_aufgeloest.parents:
raise ImportFehler("Zielordner liegt außerhalb von plugins/.")
# Sicherung des bisherigen Standes (macht den Import umkehrbar).
sicherung = None
if ziel.is_dir():
stempel = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
basis = sicherungen / pid
basis.mkdir(parents=True, exist_ok=True)
sicherung = basis / stempel
# Mehrere Importe in derselben Sekunde dürfen sich nicht überschreiben.
zaehler = 2
while sicherung.exists():
sicherung = basis / f"{stempel}-{zaehler}"
zaehler += 1
shutil.copytree(ziel, sicherung)
ziel.mkdir(parents=True, exist_ok=True)
geschrieben, uebersprungen = 0, 0
for name in a["dateien"]:
rel = name[len(wurzel):]
if not rel or rel.endswith("/"):
continue
if not _pfad_ok(rel):
uebersprungen += 1
continue
pfad = (ziel / rel).resolve()
if ziel_aufgeloest != pfad and ziel_aufgeloest not in pfad.parents:
uebersprungen += 1 # Ausbruchsversuch — überspringen
continue
pfad.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as quelle, open(pfad, "wb") as ablage:
shutil.copyfileobj(quelle, ablage, length=64 * 1024)
geschrieben += 1
return {
"ok": True,
"id": pid,
"name": a["name"],
"version": a["version"],
"version_alt": vorschau["version_alt"],
"art": vorschau["art"],
"ordner": ziel.name,
"geschrieben": geschrieben,
"uebersprungen": uebersprungen,
"sicherung": str(sicherung) if sicherung else None,
}
+226
View File
@@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
"""
Job-Runner für Plugins, die ein bestehendes Kommandozeilen-Tool kapseln.
Die Original-Skripte bleiben unverändert. Sie werden als Subprozess gestartet,
ihre Ausgabe wird zeilenweise gepuffert und per Server-Sent-Events live in die
Oberfläche gestreamt. Damit sieht ein CLI-Tool im Dashboard genauso aus wie
ein Tool mit eigenem Webinterface.
Sicherheit: Die Argumentliste baut immer das Plugin-Backend aus geprüften
Eingaben. Es gibt bewusst keinen Endpunkt, der beliebige Kommandos annimmt.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
MAX_LINES = 4000
@dataclass
class Job:
id: str
plugin: str
label: str
argv: list[str]
cwd: str
status: str = "running" # running | done | failed | cancelled
returncode: int | None = None
started_at: float = field(default_factory=time.time)
ended_at: float | None = None
lines: list[dict[str, str]] = field(default_factory=list)
artifacts: list[dict[str, str]] = field(default_factory=list)
_process: Any = None
_waiters: list[asyncio.Queue] = field(default_factory=list)
def add(self, stream: str, text: str) -> None:
entry = {"stream": stream, "text": text, "t": round(time.time() - self.started_at, 2)}
self.lines.append(entry)
if len(self.lines) > MAX_LINES:
del self.lines[: len(self.lines) - MAX_LINES]
for queue in list(self._waiters):
queue.put_nowait(entry)
def finish(self, status: str, returncode: int | None) -> None:
self.status = status
self.returncode = returncode
self.ended_at = time.time()
for queue in list(self._waiters):
queue.put_nowait(None)
def info(self) -> dict[str, Any]:
return {
"id": self.id,
"plugin": self.plugin,
"label": self.label,
"command": " ".join(self.argv),
"status": self.status,
"returncode": self.returncode,
"started_at": self.started_at,
"ended_at": self.ended_at,
"duration": round((self.ended_at or time.time()) - self.started_at, 1),
"artifacts": self.artifacts,
}
class JobManager:
"""Hält alle Läufe der Suite im Speicher (bewusst kein Zustand auf Platte)."""
def __init__(self, keep: int = 40) -> None:
self._jobs: dict[str, Job] = {}
self._keep = keep
# ------------------------------------------------------------- Starten
async def start(
self,
*,
plugin: str,
label: str,
argv: Iterable[str],
cwd: Path,
env: dict[str, str] | None = None,
) -> Job:
argv = [str(a) for a in argv]
job = Job(id=uuid.uuid4().hex[:12], plugin=plugin, label=label,
argv=argv, cwd=str(cwd))
self._jobs[job.id] = job
self._prune()
run_env = dict(os.environ)
run_env.update(env or {})
run_env.setdefault("PYTHONUNBUFFERED", "1")
run_env.setdefault("PYTHONIOENCODING", "utf-8")
job.add("meta", "$ " + " ".join(argv))
try:
process = await asyncio.create_subprocess_exec(
*argv,
cwd=str(cwd),
env=run_env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except Exception as exc:
job.add("stderr", f"Start fehlgeschlagen: {exc}")
job.finish("failed", None)
return job
job._process = process
asyncio.create_task(self._pump(job, process))
return job
async def _pump(self, job: Job, process) -> None:
async def read(stream, name):
while True:
raw = await stream.readline()
if not raw:
break
job.add(name, raw.decode("utf-8", "replace").rstrip("\n"))
await asyncio.gather(read(process.stdout, "stdout"), read(process.stderr, "stderr"))
code = await process.wait()
if job.status == "cancelled":
job.finish("cancelled", code)
else:
job.finish("done" if code == 0 else "failed", code)
# -------------------------------------------------------------- Zugriff
def get(self, job_id: str) -> Job | None:
return self._jobs.get(job_id)
def list(self, plugin: str | None = None) -> list[Job]:
jobs = [j for j in self._jobs.values() if plugin is None or j.plugin == plugin]
return sorted(jobs, key=lambda j: j.started_at, reverse=True)
def running(self, plugin: str | None = None) -> list[Job]:
return [j for j in self.list(plugin) if j.status == "running"]
async def cancel(self, job_id: str) -> bool:
job = self._jobs.get(job_id)
if not job or job.status != "running" or job._process is None:
return False
job.status = "cancelled"
job.add("meta", "Abbruch angefordert …")
try:
job._process.terminate()
except ProcessLookupError:
pass
return True
def _prune(self) -> None:
finished = [j for j in self.list() if j.status != "running"]
for job in finished[self._keep:]:
self._jobs.pop(job.id, None)
def job_router(jobs: JobManager, plugin: str) -> APIRouter:
"""Standard-Endpunkte, die jedes CLI-Plugin einbinden kann."""
router = APIRouter()
@router.get("/api/jobs")
def list_jobs() -> dict[str, Any]:
return {"jobs": [j.info() for j in jobs.list(plugin)]}
@router.get("/api/jobs/{job_id}")
def job_detail(job_id: str) -> dict[str, Any]:
job = jobs.get(job_id)
if not job or job.plugin != plugin:
raise HTTPException(404, "Lauf nicht gefunden.")
return {**job.info(), "lines": job.lines}
@router.post("/api/jobs/{job_id}/cancel")
async def cancel_job(job_id: str) -> dict[str, Any]:
job = jobs.get(job_id)
if not job or job.plugin != plugin:
raise HTTPException(404, "Lauf nicht gefunden.")
ok = await jobs.cancel(job_id)
return {"ok": ok, "status": job.status}
@router.get("/api/jobs/{job_id}/events")
async def job_events(job_id: str):
job = jobs.get(job_id)
if not job or job.plugin != plugin:
raise HTTPException(404, "Lauf nicht gefunden.")
async def stream():
queue: asyncio.Queue = asyncio.Queue()
backlog = list(job.lines)
job._waiters.append(queue)
try:
for entry in backlog:
yield "event: line\ndata: " + json.dumps(entry, ensure_ascii=False) + "\n\n"
if job.status != "running":
yield "event: end\ndata: " + json.dumps(job.info(), ensure_ascii=False) + "\n\n"
return
while True:
try:
entry = await asyncio.wait_for(queue.get(), timeout=20)
except asyncio.TimeoutError:
yield ": ping\n\n"
continue
if entry is None:
yield "event: end\ndata: " + json.dumps(job.info(), ensure_ascii=False) + "\n\n"
return
yield "event: line\ndata: " + json.dumps(entry, ensure_ascii=False) + "\n\n"
finally:
if queue in job._waiters:
job._waiters.remove(queue)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
return router
+70
View File
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""
Import-Helfer für Plugins.
Die eingebundenen Tools sind eigenständig gewachsen und benutzen deshalb
naheliegende Modulnamen (app.py, storage.py, models.py …). Würden sie normal
importiert, käme sich das zweite Tool mit dem ersten ins Gehege.
Diese Helfer laden ein Modul bzw. ein Paket unter einem eindeutigen Namen
direkt aus dem Plugin-Ordner. Die Tools selbst müssen dafür nichts ändern:
paketinterne Importe (from .auth import ...) funktionieren unverändert.
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
def load_module(file: Path, alias: str) -> ModuleType:
"""Lädt eine einzelne .py-Datei unter eindeutigem Namen."""
file = Path(file)
if not file.is_file():
raise FileNotFoundError(f"{file} fehlt")
if alias in sys.modules:
return sys.modules[alias]
spec = importlib.util.spec_from_file_location(alias, file)
if spec is None or spec.loader is None:
raise ImportError(f"{file} kann nicht geladen werden")
module = importlib.util.module_from_spec(spec)
sys.modules[alias] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(alias, None)
raise
return module
def load_package(directory: Path, alias: str) -> ModuleType:
"""Lädt ein Paket-Verzeichnis (mit __init__.py) unter eindeutigem Namen."""
directory = Path(directory)
init = directory / "__init__.py"
if not init.is_file():
raise FileNotFoundError(f"{init} fehlt")
if alias in sys.modules:
return sys.modules[alias]
spec = importlib.util.spec_from_file_location(
alias, init, submodule_search_locations=[str(directory)]
)
if spec is None or spec.loader is None:
raise ImportError(f"{directory} kann nicht geladen werden")
package = importlib.util.module_from_spec(spec)
sys.modules[alias] = package
try:
spec.loader.exec_module(package)
except Exception:
sys.modules.pop(alias, None)
raise
return package
def load_submodule(directory: Path, package_alias: str, submodule: str) -> ModuleType:
"""Lädt <directory>/<submodule>.py als Teil des Pakets <package_alias>."""
load_package(directory, package_alias)
return importlib.import_module(f"{package_alias}.{submodule}")
+215
View File
@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
"""
Plugin-Registry das Baukasten-Prinzip.
Ein neues Tool wird angelegt, indem ein Ordner unter plugins/<id>/ abgelegt
wird. Mehr ist nicht nötig: Beim Start liest die Suite jede plugin.json ein,
importiert das darin genannte Backend und hängt dessen ASGI-App unter
/plugins/<id> ein. Das Dashboard listet das Tool automatisch auf.
plugins/<id>/plugin.json
-------------------------
{
"id": "mein-tool", Pflicht, identisch zum Ordnernamen
"name": "Mein Tool", Pflicht, Anzeigename
"summary": "Kurz in einer Zeile",
"description": "Längerer Text für die Dashboard-Karte",
"icon": "🔧",
"category": "Tandoor",
"version": "1.0.0",
"entrypoint": "backend:create_app", Modul:Funktion, relativ zum Plugin-Ordner
"mount": "/plugins/mein-tool", optional, Standard ist /plugins/<id>
"requires": ["tandoor"], optional: tandoor | openai
"features": ["Stichpunkt", "..."], optional, für die Dashboard-Karte
"enabled": true
}
backend.py
----------
def create_app(ctx: PluginContext):
...
return app # FastAPI-, Starlette- oder (via a2wsgi) WSGI-App
"""
from __future__ import annotations
import importlib.util
import json
import sys
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .jobs import JobManager
from .settings import Settings
# --------------------------------------------------------------------- Modell
@dataclass
class PluginMeta:
id: str
name: str
directory: Path
summary: str = ""
description: str = ""
icon: str = ""
category: str = "Allgemein"
version: str = "1.0.0"
entrypoint: str = "backend:create_app"
mount: str = ""
requires: list[str] = field(default_factory=list)
features: list[str] = field(default_factory=list)
docs: str = ""
enabled: bool = True
order: int = 100
@classmethod
def from_file(cls, manifest: Path) -> "PluginMeta":
raw: dict[str, Any] = json.loads(manifest.read_text(encoding="utf-8"))
directory = manifest.parent
pid = str(raw.get("id") or directory.name)
meta = cls(
id=pid,
name=str(raw.get("name") or pid),
directory=directory,
summary=str(raw.get("summary", "")),
description=str(raw.get("description", "")),
icon=str(raw.get("icon", "")),
category=str(raw.get("category", "Allgemein")),
version=str(raw.get("version", "1.0.0")),
entrypoint=str(raw.get("entrypoint", "backend:create_app")),
mount=str(raw.get("mount") or f"/plugins/{pid}"),
requires=list(raw.get("requires", []) or []),
features=list(raw.get("features", []) or []),
docs=str(raw.get("docs", "")),
enabled=bool(raw.get("enabled", True)),
order=int(raw.get("order", 100)),
)
if not meta.mount.startswith("/"):
meta.mount = "/" + meta.mount
return meta
def public(self) -> dict[str, Any]:
return {
"id": self.id, "name": self.name, "summary": self.summary,
"description": self.description, "icon": self.icon,
"category": self.category, "version": self.version,
"mount": self.mount, "requires": self.requires,
"features": self.features, "docs": self.docs,
}
@dataclass
class PluginContext:
"""Wird jedem Plugin beim Start übergeben."""
meta: PluginMeta
settings: Settings
jobs: JobManager
data_dir: Path
shared_dir: Path
@property
def id(self) -> str:
return self.meta.id
@property
def dir(self) -> Path:
return self.meta.directory
@property
def mount(self) -> str:
return self.meta.mount
def path(self, *parts: str) -> Path:
return self.meta.directory.joinpath(*parts)
@dataclass
class LoadedPlugin:
meta: PluginMeta
app: Any = None
error: str = ""
@property
def ok(self) -> bool:
return self.app is not None and not self.error
# ------------------------------------------------------------------ Discovery
def discover(plugins_dir: Path) -> list[PluginMeta]:
metas: list[PluginMeta] = []
if not plugins_dir.is_dir():
return metas
for entry in sorted(plugins_dir.iterdir()):
manifest = entry / "plugin.json"
if not entry.is_dir() or not manifest.is_file():
continue
try:
meta = PluginMeta.from_file(manifest)
except Exception as exc:
print(f"[plugins] {entry.name}: plugin.json unlesbar {exc}", file=sys.stderr)
continue
if meta.enabled:
metas.append(meta)
metas.sort(key=lambda m: (m.order, m.name.casefold()))
return metas
def _import_module(meta: PluginMeta, module_name: str):
"""Importiert plugins/<id>/<module>.py isoliert unter eindeutigem Namen."""
file = meta.directory / f"{module_name}.py"
if not file.is_file():
raise FileNotFoundError(f"{file} fehlt")
qualified = f"boehmitools_plugin_{meta.id.replace('-', '_')}_{module_name}"
spec = importlib.util.spec_from_file_location(qualified, file)
if spec is None or spec.loader is None:
raise ImportError(f"{file} kann nicht geladen werden")
module = importlib.util.module_from_spec(spec)
sys.modules[qualified] = module
# Das Plugin-Verzeichnis muss importierbar sein, damit die Original-Tools
# ihre eigenen Module weiterhin schlicht per "import x" finden.
directory = str(meta.directory)
if directory not in sys.path:
sys.path.insert(0, directory)
spec.loader.exec_module(module)
return module
def load(meta: PluginMeta, ctx: PluginContext) -> LoadedPlugin:
try:
module_name, _, func_name = meta.entrypoint.partition(":")
module = _import_module(meta, module_name or "backend")
factory = getattr(module, func_name or "create_app", None)
if factory is None:
raise AttributeError(f"{meta.entrypoint} nicht gefunden")
app = factory(ctx)
if app is None:
raise ValueError("create_app() hat keine App zurückgegeben")
return LoadedPlugin(meta=meta, app=app)
except Exception as exc:
detail = traceback.format_exc(limit=6)
print(f"[plugins] {meta.id}: Laden fehlgeschlagen\n{detail}", file=sys.stderr)
return LoadedPlugin(meta=meta, error=f"{type(exc).__name__}: {exc}")
def unload(meta: PluginMeta) -> None:
"""
Entfernt alle Module, die aus dem Ordner dieses Plugins geladen wurden, aus
``sys.modules`` — sowohl das qualifizierte Backend als auch Untermodule, die
das Plugin per ``import x`` aus seinem eigenen Ordner gezogen hat.
Nötig, damit ein erneutes ``load`` geänderten Code wirklich neu einliest und
nicht die zwischengespeicherte Fassung wiederverwendet. Kernmodule (``core``)
werden bewusst NICHT angefasst — die laufen ja gerade.
"""
prefix = str(meta.directory.resolve())
for name, module in list(sys.modules.items()):
datei = getattr(module, "__file__", None)
if not datei:
continue
try:
unter_plugin = str(Path(datei).resolve()).startswith(prefix)
except (OSError, ValueError):
continue
if unter_plugin:
del sys.modules[name]
+51
View File
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""
Optionaler HTTP-Basic-Schutz für die gesamte Suite.
Ist APP_PASSWORD gesetzt, gilt der Schutz für Dashboard und alle Plugins.
Die Zugangsdaten sind identisch zu denen, die der AI-Webimport bisher schon
genutzt hat bestehende .env-Dateien funktionieren unverändert weiter.
"""
from __future__ import annotations
import base64
import hmac
import os
from fastapi import Request
from fastapi.responses import JSONResponse, Response
OPEN_PATHS = ("/api/health",)
def _unauthorized() -> Response:
return JSONResponse(
status_code=401,
content={"detail": "Anmeldung erforderlich."},
headers={"WWW-Authenticate": 'Basic realm="boehmitools"'},
)
async def basic_auth_middleware(request: Request, call_next):
expected_password = os.environ.get("APP_PASSWORD", "")
if not expected_password or request.url.path in OPEN_PATHS:
return await call_next(request)
expected_username = os.environ.get("APP_USERNAME", "admin")
header = request.headers.get("Authorization", "")
if not header.startswith("Basic "):
return _unauthorized()
try:
decoded = base64.b64decode(header[6:]).decode("utf-8")
username, password = decoded.split(":", 1)
except Exception:
return _unauthorized()
if not (
hmac.compare_digest(username, expected_username)
and hmac.compare_digest(password, expected_password)
):
return _unauthorized()
return await call_next(request)
+173
View File
@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
"""
Zentrale Einstellungen der Suite.
Alle Tools brauchen dieselben Zugangsdaten (Tandoor, OpenAI). Statt sie in
jedem Plugin einzeln zu pflegen, liegen sie einmal in data/settings.json und
werden beim Laden in os.environ gespiegelt. Damit funktionieren die
Original-Tools unverändert weiter: sie lesen wie bisher ihre Umgebungsvariablen.
Vorrang:
1. data/settings.json (über die Oberfläche gepflegt)
2. Umgebung / .env (Fallback, wenn in settings.json leer)
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
MASK = "••••••••"
@dataclass(frozen=True)
class Field:
key: str
label: str
group: str
kind: str = "text" # text | secret | bool | number | select
default: str = ""
help: str = ""
options: tuple[str, ...] = ()
placeholder: str = ""
@property
def secret(self) -> bool:
return self.kind == "secret"
FIELDS: tuple[Field, ...] = (
Field("TANDOOR_URL", "Tandoor-URL", "Tandoor", "text",
placeholder="https://kitchen.example.de",
help="Basis-URL deiner Tandoor-Instanz, ohne /api."),
Field("TANDOOR_TOKEN", "Tandoor-Token", "Tandoor", "secret",
help="Token mit Schreibrechten. Verlässt das lokale Backend nicht."),
Field("TANDOOR_AUTH_SCHEME", "Auth-Schema", "Tandoor", "select",
default="Bearer", options=("Bearer", "Token"),
help="Bei älteren DRF-Tokens „Token“ wählen."),
Field("TANDOOR_VERIFY_TLS", "TLS prüfen", "Tandoor", "bool", default="true",
help="Nur für interne Instanzen mit selbstsigniertem Zertifikat abschalten."),
Field("TANDOOR_TIMEOUT", "Timeout (s)", "Tandoor", "number", default="45"),
Field("OPENAI_API_KEY", "OpenAI API-Key", "OpenAI", "secret",
help="Wird nur für den AI-Webimport verwendet."),
Field("OPENAI_MODEL", "Modell", "OpenAI", "text", default="gpt-5.5",
placeholder="gpt-5.5"),
Field("SOURCE_VERIFY_TLS", "TLS der Quelle prüfen", "Rezeptquellen", "bool", default="true"),
Field("SOURCE_TIMEOUT", "Timeout (s)", "Rezeptquellen", "number", default="20"),
Field("FETCH_MAX_BYTES", "Max. Downloadgröße (Byte)", "Rezeptquellen", "number",
default="3000000"),
Field("ALLOW_PRIVATE_SOURCE_URLS", "Private Quell-IPs erlauben", "Rezeptquellen", "bool",
default="false",
help="Standardmäßig aus: schützt vor SSRF über localhost und private Netze."),
)
BY_KEY = {f.key: f for f in FIELDS}
GROUPS = tuple(dict.fromkeys(f.group for f in FIELDS))
@dataclass
class Settings:
path: Path
values: dict[str, str] = field(default_factory=dict)
# ------------------------------------------------------------- Laden
@classmethod
def load(cls, path: Path) -> "Settings":
values: dict[str, str] = {}
if path.exists():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
values = {k: str(v) for k, v in raw.items() if k in BY_KEY}
except Exception:
values = {}
instance = cls(path=path, values=values)
instance.apply_env()
return instance
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self.values, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
tmp.replace(self.path)
try:
self.path.chmod(0o600)
except Exception:
pass
self.apply_env()
# -------------------------------------------------------------- Werte
def get(self, key: str) -> str:
value = self.values.get(key, "").strip()
if value:
return value
return os.environ.get(key, "").strip() or BY_KEY[key].default
def apply_env(self) -> None:
"""Spiegelt die Einstellungen in die Prozess-Umgebung."""
for f in FIELDS:
value = self.values.get(f.key, "").strip()
if value:
os.environ[f.key] = value
elif f.key not in os.environ and f.default:
os.environ[f.key] = f.default
def tool_env(self) -> dict[str, str]:
"""Umgebung für Subprozess-Tools (CLI-Plugins)."""
env = dict(os.environ)
for f in FIELDS:
value = self.get(f.key)
if value:
env[f.key] = value
return env
# ----------------------------------------------------------- Web-API
def public(self) -> dict[str, Any]:
"""Für die Oberfläche Geheimnisse werden maskiert."""
out: dict[str, Any] = {}
for f in FIELDS:
value = self.get(f.key)
out[f.key] = MASK if (f.secret and value) else ("" if f.secret else value)
return out
def status(self) -> dict[str, bool]:
return {
"tandoor": bool(self.get("TANDOOR_URL") and self.get("TANDOOR_TOKEN")),
"openai": bool(self.get("OPENAI_API_KEY") and self.get("OPENAI_MODEL")),
}
def update(self, payload: dict[str, Any]) -> None:
for key, value in payload.items():
f = BY_KEY.get(key)
if not f:
continue
text = "" if value is None else str(value).strip()
if f.secret and text == MASK:
continue # unverändert lassen
if f.kind == "bool":
text = "true" if str(value).lower() in {"1", "true", "on", "yes"} else "false"
self.values[key] = text
self.save()
def schema() -> list[dict[str, Any]]:
"""Beschreibung der Felder für die Oberfläche."""
return [
{
"group": group,
"fields": [
{
"key": f.key, "label": f.label, "kind": f.kind,
"help": f.help, "options": list(f.options),
"placeholder": f.placeholder, "default": f.default,
}
for f in FIELDS if f.group == group
],
}
for group in GROUPS
]
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · nicht gefunden</title>
<link rel="stylesheet" href="/shared/boehmi.css">
</head>
<body data-bt-title="Nicht gefunden" data-bt-icon="∅">
<main class="bt-main narrow">
<div class="bt-card" style="text-align:center;padding:48px 20px">
<h1 style="margin:0 0 6px;font-size:44px;letter-spacing:-.04em">404</h1>
<p class="bt-muted" style="margin:0 0 18px">Diese Seite gehört zu keinem Tool der Suite.</p>
<a class="bt-btn primary" href="/">Zum Dashboard</a>
</div>
</main>
<script src="/shared/boehmi.js"></script>
</body>
</html>
+258
View File
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · Dashboard</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<style>
.hero {
display: flex; align-items: flex-end; gap: 16px;
flex-wrap: wrap; margin: 6px 0 22px;
}
.hero h1 { margin: 0; font-size: 27px; font-weight: 850; letter-spacing: -.03em; }
.hero p { margin: 6px 0 0; color: var(--bt-muted); max-width: 62ch; }
.hero .bt-spacer { flex: 1; min-width: 0; }
.health { display: flex; gap: 8px; flex-wrap: wrap; }
.hero-side { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
.hero-side .health { justify-content: flex-end; }
#reloadPlugins { white-space: nowrap; }
.side-btns { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
#importPlugin { white-space: nowrap; }
.cat { margin: 26px 0 10px; display: flex; align-items: center; gap: 10px; }
.cat h2 {
margin: 0; font-size: 11.5px; font-weight: 800; letter-spacing: .1em;
text-transform: uppercase; color: var(--bt-muted);
}
.cat .line { flex: 1; height: 1px; background: var(--bt-line); }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 14px; }
.tool {
display: flex; flex-direction: column; gap: 10px;
background: var(--bt-surface); border: 1px solid var(--bt-line);
border-radius: var(--bt-r-lg); padding: 16px; box-shadow: var(--bt-shadow);
color: inherit; transition: border-color .15s, transform .1s, box-shadow .15s;
position: relative; overflow: hidden;
}
.tool::before {
content: ""; position: absolute; inset: 0 auto 0 0; width: 3px;
background: var(--bt-accent); opacity: 0; transition: opacity .15s;
}
.tool:hover { text-decoration: none; border-color: var(--bt-accent); transform: translateY(-2px); box-shadow: var(--bt-shadow-lg); }
.tool:hover::before { opacity: 1; }
.tool.broken { border-style: dashed; opacity: .85; }
.tool.broken:hover { transform: none; border-color: var(--bt-err); }
.tool-head { display: flex; align-items: flex-start; gap: 11px; }
.tool-ico {
width: 40px; height: 40px; flex: none; border-radius: 11px;
display: grid; place-items: center; font-size: 20px;
background: var(--bt-accent-soft); border: 1px solid var(--bt-line-soft);
}
.tool-head h3 { margin: 0; font-size: 15.5px; font-weight: 750; letter-spacing: -.01em; }
.tool-head .ver { font-size: 11px; color: var(--bt-muted); font-weight: 600; }
.tool p.desc { margin: 0; font-size: 13px; color: var(--bt-ink-soft); line-height: 1.5; }
.tool ul {
margin: 0; padding: 0; list-style: none;
display: flex; flex-direction: column; gap: 4px;
}
.tool ul li {
font-size: 12px; color: var(--bt-muted); padding-left: 15px; position: relative;
}
.tool ul li::before {
content: ""; position: absolute; left: 3px; color: var(--bt-accent); font-weight: 800;
}
.tool-foot {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-top: auto; padding-top: 4px;
}
.tool-foot .go { margin-left: auto; font-size: 12.5px; font-weight: 700; color: var(--bt-accent); }
</style>
</head>
<body data-bt-title="Dashboard" data-bt-icon="▦">
<main class="bt-main">
<div class="hero">
<div>
<h1>Werkzeugkasten</h1>
<p>Alle Tools laufen unter einem Dach und teilen sich Zugänge, Design und
Protokolle. Jedes Tool bleibt dabei eigenständig — es lässt sich einzeln
starten, aktualisieren oder entfernen.</p>
</div>
<span class="bt-spacer"></span>
<div class="hero-side">
<div class="side-btns">
<button class="bt-btn" id="importPlugin" title="Plugin als ZIP hochladen">
⤓ Plugin importieren</button>
<button class="bt-btn" id="reloadPlugins" title="plugins/ neu einlesen — ohne Neustart">
⟳ Plugins neu laden</button>
</div>
<input type="file" id="pluginFile" accept=".zip,application/zip" hidden>
<div class="health" id="health"></div>
</div>
</div>
<div id="cats"></div>
<div class="bt-notice info" style="margin-top:28px">
<b>Baukasten:</b> Ein neues Tool wird zum Plugin, indem sein Ordner unter
<code>plugins/&lt;name&gt;/</code> abgelegt wird — mit einer <code>plugin.json</code>
und einer <code>backend.py</code>, die <code>create_app(ctx)</code> zurückgibt.
Mit <b>„Plugins neu laden“</b> erscheint es sofort hier — ohne Neustart.
Details stehen in <code>plugins/README.md</code>.
</div>
</main>
<script src="/shared/boehmi.js"></script>
<script>
const catsEl = document.getElementById("cats");
const healthEl = document.getElementById("health");
function chip(label, ok, hint) {
return `<span class="bt-badge ${ok ? "ok" : "warn"}" title="${BT.escape(hint || "")}">
<span class="bt-dot"></span>${BT.escape(label)}</span>`;
}
function card(p) {
const broken = !p.ok;
const href = broken ? "#" : p.mount + "/";
return `
<a class="tool ${broken ? "broken" : ""}" href="${href}" ${broken ? 'onclick="return false"' : ""}>
<div class="tool-head">
<span class="tool-ico">${BT.escape(p.icon)}</span>
<div>
<h3>${BT.escape(p.name)}</h3>
<span class="ver">v${BT.escape(p.version)} · ${BT.escape(p.mount)}</span>
</div>
</div>
<p class="desc">${BT.escape(p.description || p.summary)}</p>
${p.features && p.features.length
? `<ul>${p.features.slice(0, 4).map((f) => `<li>${BT.escape(f)}</li>`).join("")}</ul>` : ""}
<div class="tool-foot">
${(p.requires || []).map((r) => `<span class="bt-badge">${BT.escape(r)}</span>`).join("")}
${broken
? `<span class="bt-badge err">Ladefehler</span>`
: `<span class="go">Öffnen →</span>`}
</div>
${broken ? `<div class="bt-notice err" style="margin:0">${BT.escape(p.error)}</div>` : ""}
</a>`;
}
async function render() {
const [{ plugins }, health] = await Promise.all([
fetch("/api/plugins").then((r) => r.json()),
fetch("/api/health").then((r) => r.json()).catch(() => ({})),
]);
healthEl.innerHTML = [
chip("Tandoor", health.tandoor_configured, "URL und Token in den Einstellungen"),
chip("OpenAI", health.openai_configured, "API-Key und Modell in den Einstellungen"),
health.authentication_enabled
? chip("Zugangsschutz", true, "APP_PASSWORD ist gesetzt")
: chip("Kein Zugangsschutz", false, "APP_PASSWORD setzen, wenn erreichbar"),
].join("");
const cats = [...new Set(plugins.map((p) => p.category))];
catsEl.innerHTML = cats.map((c) => `
<div class="cat"><h2>${BT.escape(c)}</h2><span class="line"></span></div>
<div class="cards">${plugins.filter((p) => p.category === c).map(card).join("")}</div>
`).join("");
if (!plugins.length) {
catsEl.innerHTML = `<div class="bt-empty">Noch keine Plugins unter <code>plugins/</code>.</div>`;
}
}
const reloadBtn = document.getElementById("reloadPlugins");
reloadBtn.addEventListener("click", async () => {
const alt = reloadBtn.textContent;
reloadBtn.disabled = true;
reloadBtn.textContent = "⟳ lädt …";
try {
const r = await fetch("/api/plugins/reload", { method: "POST" });
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
await render();
const fehler = (data.fehler || []).length;
BT.toast(
`${data.geladen}/${data.gesamt} Plugins geladen`
+ (fehler ? `, ${fehler} mit Fehler` : ""),
fehler ? "err" : "ok",
);
} catch (err) {
BT.toast("Neu laden fehlgeschlagen: " + err.message, "err");
} finally {
reloadBtn.disabled = false;
reloadBtn.textContent = alt;
}
});
const importBtn = document.getElementById("importPlugin");
const fileInput = document.getElementById("pluginFile");
importBtn.addEventListener("click", () => fileInput.click());
async function sendePlugin(datei, { nurPruefen = false, force = false } = {}) {
const form = new FormData();
form.append("file", datei);
if (nurPruefen) form.append("pruefen_only", "true");
if (force) form.append("force", "true");
const r = await fetch("/api/plugins/import", { method: "POST", body: form });
let daten = {};
try { daten = await r.json(); } catch (_) {}
if (!r.ok) throw new Error(daten.detail || `HTTP ${r.status}`);
return daten;
}
fileInput.addEventListener("change", async () => {
const datei = fileInput.files && fileInput.files[0];
fileInput.value = ""; // erneutes Wählen derselben Datei
if (!datei) return;
const alt = importBtn.textContent;
importBtn.disabled = true;
importBtn.textContent = "⤓ prüft …";
try {
// Erst schauen, was passieren würde — geschrieben wird noch nichts.
const { vorschau: v } = await sendePlugin(datei, { nurPruefen: true });
let force = false;
if (v.bestaetigung_noetig) {
const frage = `${v.warnung}\n\n` +
`Plugin: ${v.name} (${v.id})\n` +
`Installiert: ${v.version_alt} → Archiv: ${v.version}\n\n` +
`Fortfahren? Dateien werden ergänzt und überschrieben, nichts ` +
`gelöscht. Der Datenordner bleibt unberührt.`;
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
force = true;
} else if (v.art === "aktualisierung") {
const frage = `${v.name} (${v.id})\n` +
`Aktualisierung ${v.version_alt}${v.version}\n\n` +
`Der bisherige Stand wird gesichert, Daten bleiben erhalten.`;
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
}
importBtn.textContent = "⤓ lädt hoch …";
const b = await sendePlugin(datei, { force });
await render();
if (b.aktiv) {
const wort = b.art === "neu" ? "installiert"
: b.art === "aelter" ? "zurückgesetzt" : "aktualisiert";
BT.toast(`${b.name} ${b.version} ${wort} · ${b.geschrieben} Dateien`, "ok");
} else {
BT.toast(`${b.name} geschrieben, lädt aber nicht: ${b.ladefehler || "?"}`, "err");
}
} catch (err) {
BT.toast("Import fehlgeschlagen: " + err.message, "err");
} finally {
importBtn.disabled = false;
importBtn.textContent = alt;
}
});
render();
</script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · Läufe</title>
<link rel="stylesheet" href="/shared/boehmi.css">
</head>
<body data-bt-title="Läufe" data-bt-icon="↻">
<main class="bt-main">
<div class="bt-pagehead">
<h1>Läufe</h1>
<p>Alle Ausführungen der Kommandozeilen-Tools dieser Sitzung.</p>
</div>
<div class="bt-card bt-card-flush">
<table class="bt-table">
<thead><tr><th>Tool</th><th>Aufgabe</th><th>Status</th><th>Dauer</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div id="empty" class="bt-empty bt-hidden">Noch keine Läufe in dieser Sitzung.</div>
</main>
<script src="/shared/boehmi.js"></script>
<script>
const badge = (s) => ({
running: '<span class="bt-badge info"><span class="bt-dot"></span>läuft</span>',
done: '<span class="bt-badge ok">erfolgreich</span>',
failed: '<span class="bt-badge err">fehlgeschlagen</span>',
cancelled: '<span class="bt-badge warn">abgebrochen</span>',
}[s] || s);
async function load() {
const { jobs } = await fetch("/api/jobs").then((r) => r.json());
document.getElementById("empty").classList.toggle("bt-hidden", jobs.length > 0);
document.getElementById("rows").innerHTML = jobs.map((j) => `
<tr>
<td><b>${BT.escape(j.plugin_name)}</b></td>
<td>${BT.escape(j.label)}<div class="bt-muted bt-mono" style="font-size:11px">${BT.escape(j.command)}</div></td>
<td>${badge(j.status)}</td>
<td class="bt-muted">${j.duration}s</td>
<td><a class="bt-btn mini" href="/plugins/${BT.escape(j.plugin)}/?job=${BT.escape(j.id)}">Protokoll</a></td>
</tr>`).join("");
}
load();
setInterval(load, 4000);
</script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · Einstellungen</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<style>
.field { margin-bottom: 12px; }
.field .help { font-size: 11.5px; color: var(--bt-muted); margin-top: 4px; }
.sticky-actions {
position: sticky; bottom: 0; margin-top: 18px; padding: 12px 0;
background: linear-gradient(transparent, var(--bt-bg) 40%);
display: flex; gap: 8px; align-items: center;
}
</style>
</head>
<body data-bt-title="Einstellungen" data-bt-icon="⚙">
<main class="bt-main narrow">
<div class="bt-pagehead">
<h1>Einstellungen</h1>
<p>Diese Zugänge gelten für alle Tools der Suite. Sie liegen in
<code>data/settings.json</code> und werden den Tools als Umgebungsvariablen
übergeben — genau so, wie sie es als Einzeltool erwarten.</p>
</div>
<div class="bt-notice info">
Tokens und Schlüssel bleiben im lokalen Backend. Bereits gespeicherte
Geheimnisse werden hier nur maskiert angezeigt; ein leeres Feld lässt den
bestehenden Wert unverändert.
</div>
<div id="groups"></div>
<div class="sticky-actions">
<button class="primary" id="save">Speichern</button>
<span id="state" class="bt-muted"></span>
</div>
</main>
<script src="/shared/boehmi.js"></script>
<script>
const groupsEl = document.getElementById("groups");
const stateEl = document.getElementById("state");
let schema = [];
function fieldHtml(f, value) {
const id = "f_" + f.key;
let control;
if (f.kind === "bool") {
control = `<label class="bt-check"><input type="checkbox" id="${id}"
${String(value).toLowerCase() === "true" ? "checked" : ""}> aktiv</label>`;
} else if (f.kind === "select") {
control = `<select id="${id}">${f.options.map((o) =>
`<option ${o === value ? "selected" : ""}>${BT.escape(o)}</option>`).join("")}</select>`;
} else if (f.kind === "secret") {
control = `<input type="password" id="${id}" value="${BT.escape(value)}"
placeholder="${value ? "unverändert" : "nicht gesetzt"}" autocomplete="new-password">`;
} else {
control = `<input type="${f.kind === "number" ? "number" : "text"}" id="${id}"
value="${BT.escape(value)}" placeholder="${BT.escape(f.placeholder || f.default || "")}">`;
}
return `<div class="field">
<label for="${id}">${BT.escape(f.label)}</label>
${control}
${f.help ? `<div class="help">${BT.escape(f.help)}</div>` : ""}
</div>`;
}
async function load() {
const data = await fetch("/api/settings").then((r) => r.json());
schema = data.schema;
groupsEl.innerHTML = schema.map((g) => `
<section class="bt-card">
<h2>${BT.escape(g.group)}</h2>
<p class="hint">${g.group === "Tandoor"
? "Wird vom AI-Webimport, vom Rezeptimport und vom Vereinheitlichen genutzt."
: g.group === "OpenAI" ? "Nur für den AI-Webimport nötig."
: "Schutz beim Abruf externer Rezeptseiten."}</p>
${g.fields.map((f) => fieldHtml(f, data.values[f.key] ?? "")).join("")}
</section>`).join("");
}
document.getElementById("save").addEventListener("click", async () => {
const payload = {};
schema.forEach((g) => g.fields.forEach((f) => {
const el = document.getElementById("f_" + f.key);
if (!el) return;
payload[f.key] = f.kind === "bool" ? String(el.checked) : el.value;
}));
try {
stateEl.textContent = "Speichern …";
await BT.api("/api/settings", { method: "POST", body: JSON.stringify(payload) });
stateEl.textContent = "";
BT.toast("Einstellungen gespeichert.", "ok");
load();
} catch (e) {
stateEl.textContent = "";
BT.toast(e.message, "err");
}
});
load();
</script>
</body>
</html>
+237
View File
@@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
"""
Gemeinsamer Tandoor-Zugang für die Plugins der Suite.
Die drei mitgebrachten Original-Tools bringen ihren eigenen Client mit und
bleiben unangetastet. Alles, was danach in dieser Suite entsteht, benutzt
diesen hier — damit Paginierung, Wiederholversuche und Fehlermeldungen nur an
einer Stelle gepflegt werden müssen.
Belegt am Tandoor-Quelltext (cookbook/serializer.py, cookbook/urls.py):
Food id, name, plural_name, description, url, fdc_id,
properties, properties_food_amount, properties_food_unit,
supermarket_category, numrecipe (nur lesbar), full_name
Property id, property_amount, property_type
PropertyType id, name, unit, description, order, open_data_slug, fdc_id
Endpunkte /api/food/ /api/unit/ /api/keyword/ /api/property-type/
/api/property/ /api/recipe/
Zusammenführen PUT /api/food/{id}/merge/{target}/
"""
from __future__ import annotations
import os
import time
from typing import Any, Iterator
import requests
class TandoorError(RuntimeError):
"""Fehler beim Reden mit Tandoor — mit lesbarer Meldung."""
class TandoorClient:
def __init__(
self,
base_url: str,
token: str,
auth_scheme: str = "Bearer",
timeout: float = 45.0,
verify: bool = True,
retries: int = 2,
) -> None:
if not base_url or not token:
raise TandoorError("Tandoor-URL oder Token fehlt.")
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.retries = max(0, retries)
self.session = requests.Session()
self.session.verify = verify
self.session.headers.update({
"Authorization": f"{auth_scheme} {token}".strip(),
"Accept": "application/json",
"User-Agent": "boehmitools/1.0",
})
self._cache: dict[str, list[dict[str, Any]]] = {}
# ------------------------------------------------------------------ HTTP
def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self.base_url}/{path.lstrip('/')}"
last: Exception | None = None
for attempt in range(self.retries + 1):
try:
response = self.session.request(
method, url, timeout=self.timeout, **kwargs
)
except requests.RequestException as exc:
last = exc
if attempt < self.retries:
time.sleep(0.6 * (attempt + 1))
continue
raise TandoorError(f"{method} {url} nicht erreichbar: {exc}") from exc
# 5xx sind oft vorübergehend, 4xx nicht.
if response.status_code >= 500 and attempt < self.retries:
time.sleep(0.6 * (attempt + 1))
continue
if response.status_code >= 400:
raise TandoorError(
f"{method} {url} → HTTP {response.status_code}: "
f"{response.text[:400]}"
)
return response
raise TandoorError(f"{method} {url} fehlgeschlagen: {last}")
def get_json(self, path: str, **kwargs: Any) -> Any:
return self.request("GET", path, **kwargs).json()
def patch_json(self, path: str, payload: dict[str, Any]) -> Any:
return self.request("PATCH", path, json=payload).json()
def put_json(self, path: str, payload: dict[str, Any] | None = None) -> Any:
response = self.request("PUT", path, json=payload or {})
if not response.content:
return None
try:
return response.json()
except ValueError:
return None
def post_json(self, path: str, payload: dict[str, Any]) -> Any:
return self.request("POST", path, json=payload).json()
def delete(self, path: str) -> None:
self.request("DELETE", path)
# ----------------------------------------------------------- Sammlungen
def iter_objects(self, endpoint: str, params: dict[str, Any] | None = None) -> Iterator[dict[str, Any]]:
"""Läuft eine paginierte Liste vollständig ab."""
query = {"page_size": 200}
query.update(params or {})
path: str | None = f"api/{endpoint.strip('/')}/"
first = True
while path:
payload = self.get_json(path, params=query if first else None)
first = False
if isinstance(payload, list):
yield from (item for item in payload if isinstance(item, dict))
return
if not isinstance(payload, dict):
raise TandoorError(f"Unerwartete Antwort von {endpoint}.")
yield from (
item for item in payload.get("results") or [] if isinstance(item, dict)
)
nxt = payload.get("next")
if not nxt:
return
# "next" ist eine absolute URL; für die nächste Runde relativ machen.
path = nxt[len(self.base_url):] if nxt.startswith(self.base_url) else nxt
def list_objects(self, endpoint: str, refresh: bool = False) -> list[dict[str, Any]]:
"""Wie iter_objects, aber gepuffert — ein Lauf fragt jede Liste einmal ab."""
if refresh or endpoint not in self._cache:
self._cache[endpoint] = list(self.iter_objects(endpoint))
return self._cache[endpoint]
def forget(self, endpoint: str | None = None) -> None:
if endpoint is None:
self._cache.clear()
else:
self._cache.pop(endpoint, None)
# -------------------------------------------------------------- Bequem
def ping(self) -> dict[str, Any]:
"""Kurzer Verbindungstest; wirft TandoorError, wenn etwas klemmt."""
payload = self.get_json("api/food/", params={"page_size": 1})
count = payload.get("count") if isinstance(payload, dict) else None
return {"ok": True, "foods": count, "base_url": self.base_url}
def merge(self, endpoint: str, source_id: int, target_id: int) -> None:
"""
PUT /api/{endpoint}/{source}/merge/{target}/
Achtung: Tandoor hängt alle Verweise der Quelle auf das Ziel um und
löscht die Quelle. Die *Properties der Quelle* gehen dabei verloren
(so gewollt im Tandoor-Code, um Dubletten zu vermeiden) — es muss also
immer der schlechtere Eintrag auf den besseren zeigen.
"""
if source_id == target_id:
raise TandoorError("Quelle und Ziel sind identisch.")
self.put_json(f"api/{endpoint}/{source_id}/merge/{target_id}/")
@classmethod
def from_env(cls, **overrides: Any) -> "TandoorClient":
def flag(name: str, default: str) -> bool:
return os.environ.get(name, default).strip().casefold() not in {
"0", "false", "no", "nein", "off",
}
settings: dict[str, Any] = {
"base_url": os.environ.get("TANDOOR_URL", "").strip(),
"token": os.environ.get("TANDOOR_TOKEN", "").strip(),
"auth_scheme": os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer").strip() or "Bearer",
"timeout": float(os.environ.get("TANDOOR_TIMEOUT", "45") or 45),
"verify": flag("TANDOOR_VERIFY_TLS", "true"),
}
settings.update({k: v for k, v in overrides.items() if v is not None})
return cls(**settings)
# --------------------------------------------------------------- Properties
def food_property_map(food: dict[str, Any]) -> dict[int, float | None]:
"""{property_type_id: Wert} für ein Food-Objekt."""
result: dict[int, float | None] = {}
for prop in food.get("properties") or []:
if not isinstance(prop, dict):
continue
ptype = prop.get("property_type") or {}
type_id = ptype.get("id") if isinstance(ptype, dict) else None
if isinstance(type_id, int):
amount = prop.get("property_amount")
result[type_id] = None if amount is None else float(amount)
return result
def merged_properties_payload(
food: dict[str, Any],
updates: dict[int, float],
) -> list[dict[str, Any]]:
"""
Baut die vollständige properties-Liste für einen PATCH.
Tandoor ersetzt die Liste komplett (drf-writable-nested). Wer nur den neuen
Wert schickt, verliert die vorhandenen. Deshalb wird hier immer der Bestand
übernommen und nur ergänzt oder überschrieben.
"""
payload: list[dict[str, Any]] = []
seen: set[int] = set()
for prop in food.get("properties") or []:
if not isinstance(prop, dict):
continue
ptype = prop.get("property_type") or {}
type_id = ptype.get("id")
if not isinstance(type_id, int):
continue
seen.add(type_id)
amount = updates.get(type_id, prop.get("property_amount"))
entry: dict[str, Any] = {
"property_amount": None if amount is None else float(amount),
"property_type": {"id": type_id},
}
if isinstance(prop.get("id"), int):
entry["id"] = prop["id"]
payload.append(entry)
for type_id, amount in updates.items():
if type_id not in seen:
payload.append({
"property_amount": float(amount),
"property_type": {"id": type_id},
})
return payload
+15
View File
@@ -0,0 +1,15 @@
# Host / Suite
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
a2wsgi>=1.10,<2
pydantic>=2.9,<3
python-multipart>=0.0.9
# Plugin: trainingsplan
flask>=3.0
reportlab>=4.0
# Plugin: tandoor-ai-import
openai>=2,<3
requests>=2.32,<3
beautifulsoup4>=4.12,<5
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Startet die boehmitools-Suite.
python run.py # Standard-Port 8080
PORT=9090 python run.py # anderer Port
Für die Entwicklung mit Neuladen:
uvicorn core.app:app --reload
"""
import os
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BASE_DIR))
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("PORT", "8080"))
host = os.environ.get("HOST", "0.0.0.0")
print("=" * 64)
print(" boehmitools")
print(f" Im Browser öffnen: http://<server-ip>:{port}")
print("=" * 64)
uvicorn.run("core.app:app", host=host, port=port, log_level="info")
+160
View File
@@ -0,0 +1,160 @@
/*
* Gemeinsamer Baustein „Sicherungen“.
*
* Jedes schreibende Plugin bekommt damit dieselbe Ansicht: welche Läufe gab
* es, was steckt drin, wie weit lässt sich das zurückdrehen. Der Text zur
* Rückspielbarkeit kommt aus dem Manifest — die Oberfläche behauptet also
* nichts, was das Werkzeug nicht auch kann.
*/
(function () {
"use strict";
const LEVEL = {
voll: { css: "ok", text: "vollständig rückspielbar" },
neue_id: { css: "info", text: "rückspielbar, neue ID" },
teilweise: { css: "warn", text: "teilweise rückspielbar" },
nein: { css: "err", text: "nicht rückspielbar" },
};
function renderRuns(runs, selected) {
if (!runs.length) {
return `<div class="bt-empty" style="border:none">
Noch keine Sicherungen. Es entsteht bei jedem Lauf eine, der wirklich
etwas schreibt — Trockenübungen zählen nicht.</div>`;
}
return runs.map((r) => {
const lv = LEVEL[r.restore_level] || LEVEL.nein;
const when = r.created_at ? new Date(r.created_at) : null;
return `
<label class="bt-run ${r.id === selected ? "sel" : ""}">
<input type="radio" name="btrun" value="${BT.escape(r.id)}"
${r.id === selected ? "checked" : ""}>
<span class="bt-run-main">
<b>${BT.escape(r.label || r.id)}</b>
<span class="bt-run-sub">${when && !isNaN(when) ? when.toLocaleString("de-DE") : BT.escape(r.id)}
· ${r.done} von ${r.steps} Schritten geschrieben</span>
</span>
<span class="bt-run-tags">
<span class="bt-badge ${lv.css}">${lv.text}</span>
${r.restored ? `<span class="bt-badge">bereits zurückgespielt</span>` : ""}
</span>
</label>`;
}).join("");
}
function renderDetail(detail) {
const steps = (detail.manifest?.steps || []).filter((s) => s.status === "done");
if (!steps.length) return `<p class="hint">In diesem Lauf wurde nichts geschrieben.</p>`;
return `<div class="bt-steps">${steps.map((s) => {
const lv = LEVEL[s.restore_level] || LEVEL.nein;
let was = "";
if (s.action === "properties") {
was = `${Object.keys(s.written || {}).length} Werte gesetzt`;
} else if (s.action === "merge") {
was = `zusammengeführt auf ${BT.escape(s.target_name)}`
+ (s.references?.length ? ` · ${s.references.length} Verweise aufgezeichnet` : "")
+ (s.rescued?.length ? ` · ${s.rescued.length} Werte gerettet` : "");
} else if (s.action === "delete") {
was = "gelöscht";
} else if (s.action === "recipe") {
was = `${s.step_count} Schritte gesichert`;
}
return `<div class="bt-step">
<span class="bt-badge ${lv.css}">${lv.text}</span>
<span><b>${BT.escape(s.food_name || s.source_name || "?")}</b>
<span class="bt-muted"> ${was}</span></span>
</div>`;
}).join("")}</div>`;
}
/**
* Baut den Reiter. `mount` ist das Zielelement, `runner` ein BT.Runner,
* der auf /api/run/restore zeigt.
*/
BT.Backups = function (options) {
const mount = options.mount;
const runner = options.runner;
let runs = [];
let selected = null;
async function load() {
const data = await BT.api("/api/backups");
runs = data.runs || [];
if (!runs.some((r) => r.id === selected)) selected = runs[0]?.id || null;
draw(data.dir);
if (selected) await detail();
}
async function detail() {
if (!selected || !options.detail) { paintDetail(""); return; }
try {
paintDetail(renderDetail(await BT.api(`/api/backups/${encodeURIComponent(selected)}`)));
} catch { paintDetail(""); }
}
function paintDetail(html) {
const box = mount.querySelector("#btDetail");
if (box) box.innerHTML = html;
}
function draw(dir) {
const current = runs.find((r) => r.id === selected);
const lv = LEVEL[current?.restore_level] || LEVEL.nein;
mount.innerHTML = `
<div class="bt-card">
<div class="bt-row" style="align-items:center;margin-bottom:8px">
<h2 style="margin:0;flex:1">Sicherungen</h2>
<button class="mini ghost" id="btReload">Neu laden</button>
</div>
<p class="hint">Jeder Lauf, der wirklich geschrieben hat, liegt unter
<code>${BT.escape(dir || "")}</code>. Der Zustand vorher ist dort
vollständig festgehalten.</p>
<div class="bt-runs">${renderRuns(runs, selected)}</div>
</div>
${runs.length ? `
<div class="bt-card">
<h2>Was passierte in diesem Lauf</h2>
<div id="btDetail"><p class="hint">wird geladen …</p></div>
<div class="bt-notice ${lv.css === "ok" ? "" : "warn"}" style="margin-top:12px">
${options.note || ""}
</div>
<div class="bt-row" style="margin-top:12px;align-items:center">
<button id="btDry">Trockenübung</button>
<button class="danger" id="btApply">Zurückspielen</button>
<span class="bt-spacer"></span>
<label class="bt-check">
<input type="checkbox" id="btForce">
auch wenn seither von Hand geändert wurde
</label>
</div>
<div id="btStatus" class="bt-status">Bereit.</div>
<div id="btLog" class="bt-log"></div>
</div>` : ""}`;
mount.querySelectorAll('input[name="btrun"]').forEach((el) =>
el.addEventListener("change", () => { selected = el.value; draw(dir); detail(); }));
const byId = (id) => mount.querySelector("#" + id);
byId("btReload")?.addEventListener("click", load);
byId("btDry")?.addEventListener("click", () => go(false));
byId("btApply")?.addEventListener("click", () => go(true));
if (runner) runner.rebind({ log: byId("btLog"), status: byId("btStatus") });
}
function go(apply) {
const current = runs.find((r) => r.id === selected);
if (!current) return;
if (apply && !confirm(
`Lauf „${current.label || current.id}“ zurückspielen?\n\n` +
`${current.done} Schritte · ${(LEVEL[current.restore_level] || LEVEL.nein).text}\n\n` +
(current.restored ? "Achtung: Dieser Lauf wurde bereits zurückgespielt.\n\n" : "") +
`Erst die Trockenübung ansehen, wenn du unsicher bist.`)) return;
runner.start({ run: selected, apply, force: mount.querySelector("#btForce")?.checked || false });
}
return { load, reload: load };
};
})();
+128
View File
@@ -0,0 +1,128 @@
/* ==========================================================================
boehmitools · Runner
Gemeinsame Oberflächenlogik für Plugins, die ein Kommandozeilen-Tool
kapseln: Lauf starten, Ausgabe live streamen, Status anzeigen, abbrechen.
const runner = BT.Runner({ log: el, status: el, onFinish: fn });
runner.start({ apply: false }); // POST <mount>/api/run
runner.attach(jobId); // an laufenden Lauf andocken
Setzt boehmi.js voraus.
========================================================================== */
(function () {
"use strict";
const LABEL = {
running: ["busy", "läuft …"],
done: ["ok", "erfolgreich abgeschlossen"],
failed: ["error", "fehlgeschlagen"],
cancelled: ["warn", "abgebrochen"],
};
function classify(entry) {
if (entry.stream === "meta") return "l-meta";
if (entry.stream === "stderr") return "l-err";
const t = entry.text;
if (/^(FEHLER|Abbruch|Fehlgeschlagen)|✗|Traceback/i.test(t)) return "l-err";
if (/^(Warnung|Hinweis)|⚠/i.test(t)) return "l-warn";
if (/(erfolgreich|✓|OK\b|angelegt|aktualisiert)/i.test(t)) return "l-ok";
return "";
}
function Runner(cfg) {
// Nicht const: Oberflächen, die ihre Karten neu zeichnen, hängen den
// Runner per rebind() wieder an die frischen Elemente.
let logEl = cfg.log;
let statusEl = cfg.status;
// Plugins mit mehreren Läufen (prüfen / vorschlagen / übernehmen) geben
// ihren eigenen Endpunkt an. Vorgabe bleibt der bisherige.
const endpoint = cfg.endpoint || "/api/run";
let source = null;
let job = null;
function setStatus(kind, text, extra) {
if (!statusEl) return;
statusEl.className = "bt-status " + (kind || "");
statusEl.innerHTML = `<span class="bt-dot"></span><b>${BT.escape(text)}</b>` +
(extra ? ` <span class="bt-muted">${BT.escape(extra)}</span>` : "");
}
function clear() {
if (logEl) logEl.textContent = "";
}
function append(entry) {
if (!logEl) return;
const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
const span = document.createElement("span");
const cls = classify(entry);
if (cls) span.className = cls;
span.textContent = entry.text + "\n";
logEl.appendChild(span);
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
}
function close() {
if (source) { source.close(); source = null; }
}
function attach(id) {
close();
source = new EventSource(BT.url(`/api/jobs/${id}/events`));
setStatus("busy", "läuft …");
source.addEventListener("line", (e) => append(JSON.parse(e.data)));
source.addEventListener("end", (e) => {
const info = JSON.parse(e.data);
job = info;
const [kind, text] = LABEL[info.status] || ["", info.status];
setStatus(kind, text, `${info.duration}s · Exit-Code ${info.returncode}`);
close();
if (cfg.onFinish) cfg.onFinish(info);
});
source.onerror = () => {
if (source && source.readyState === EventSource.CLOSED) {
close();
setStatus("warn", "Verbindung zum Protokoll unterbrochen");
}
};
}
async function start(body) {
clear();
setStatus("busy", "wird gestartet …");
try {
job = await BT.api(endpoint, { method: "POST", body: JSON.stringify(body || {}) });
attach(job.id);
if (cfg.onStart) cfg.onStart(job);
return job;
} catch (err) {
setStatus("error", "Start fehlgeschlagen", err.message);
BT.toast(err.message, "err");
throw err;
}
}
async function cancel() {
if (!job) return;
try {
await BT.api(`/api/jobs/${job.id}/cancel`, { method: "POST" });
BT.toast("Abbruch angefordert.");
} catch (err) {
BT.toast(err.message, "err");
}
}
function rebind(next) {
if (next.log) logEl = next.log;
if (next.status) statusEl = next.status;
}
return {
start, attach, cancel, clear, setStatus, rebind,
get job() { return job; },
get busy() { return Boolean(source); },
};
}
window.BT.Runner = Runner;
})();
+458
View File
@@ -0,0 +1,458 @@
/* ==========================================================================
boehmitools · gemeinsames Design-System
Wird von der Shell UND von jedem Plugin eingebunden.
Plugins definieren darüber hinaus nur noch Layout-Spezifisches.
========================================================================== */
:root {
/* Farben hell */
--bt-bg: #f7f4ef;
--bt-bg-2: #efeae1;
--bt-surface: #ffffff;
--bt-surface-2: #fbf9f6;
--bt-ink: #1c1a17;
--bt-ink-soft: #4a453d;
--bt-muted: #7a7267;
--bt-line: #ddd6ca;
--bt-line-soft: #ebe5da;
--bt-accent: #c2410c;
--bt-accent-hi: #9a330a;
--bt-accent-soft: #fdf0e8;
--bt-accent-ink: #ffffff;
--bt-teal: #0f766e;
--bt-violet: #6d28d9;
--bt-ok: #15803d;
--bt-ok-soft: #eaf6ee;
--bt-warn: #a16207;
--bt-warn-soft: #fdf5e3;
--bt-err: #b3261e;
--bt-err-soft: #fbecea;
--bt-info: #1d4ed8;
--bt-info-soft: #eef2ff;
/* Form */
--bt-r-sm: 6px;
--bt-r: 9px;
--bt-r-lg: 14px;
--bt-shadow: 0 1px 2px rgba(28,26,23,.05), 0 4px 14px rgba(28,26,23,.05);
--bt-shadow-lg: 0 12px 40px rgba(28,26,23,.13);
--bt-ring: 0 0 0 3px rgba(194,65,12,.18);
/* Typo */
--bt-font: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--bt-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--bt-fs: 14px;
--bt-appbar-h: 54px;
color-scheme: light;
}
[data-bt-theme="dark"] {
--bt-bg: #14161a;
--bt-bg-2: #1a1d22;
--bt-surface: #1e2228;
--bt-surface-2: #252a31;
--bt-ink: #eceff3;
--bt-ink-soft: #c3cad3;
--bt-muted: #8d97a3;
--bt-line: #333a44;
--bt-line-soft: #2a3038;
--bt-accent: #f2792f;
--bt-accent-hi: #ff8f4d;
--bt-accent-soft: #2c211a;
--bt-accent-ink: #1a1206;
--bt-teal: #3fc4b4;
--bt-violet: #a98bf0;
--bt-ok: #57cc82;
--bt-ok-soft: #17281d;
--bt-warn: #e8b654;
--bt-warn-soft: #2b2417;
--bt-err: #ff8a80;
--bt-err-soft: #2d1b1a;
--bt-info: #8ec5ff;
--bt-info-soft: #17202e;
--bt-shadow: 0 1px 2px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.28);
--bt-shadow-lg: 0 16px 46px rgba(0,0,0,.45);
--bt-ring: 0 0 0 3px rgba(242,121,47,.28);
color-scheme: dark;
}
/* ------------------------------------------------------------------ Basis */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--bt-font);
font-size: var(--bt-fs);
line-height: 1.45;
color: var(--bt-ink);
background: var(--bt-bg);
-webkit-font-smoothing: antialiased;
}
a { color: var(--bt-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, kbd { font-family: var(--bt-mono); font-size: .92em; }
::selection { background: var(--bt-accent); color: var(--bt-accent-ink); }
/* ----------------------------------------------------------- App-Bar/Shell */
.bt-appbar {
position: sticky; top: 0; z-index: 60;
height: var(--bt-appbar-h);
display: flex; align-items: center; gap: 12px;
padding: 0 14px;
background: var(--bt-surface);
border-bottom: 1px solid var(--bt-line);
box-shadow: 0 1px 0 rgba(0,0,0,.02);
}
.bt-brand {
display: flex; align-items: center; gap: 9px;
font-weight: 800; letter-spacing: -.02em;
color: var(--bt-ink); white-space: nowrap;
}
.bt-brand:hover { text-decoration: none; }
.bt-brand-mark {
width: 26px; height: 26px; border-radius: 8px; flex: none;
display: grid; place-items: center;
background: var(--bt-accent); color: var(--bt-accent-ink);
font-size: 14px; font-weight: 800;
}
.bt-brand small {
font-weight: 600; color: var(--bt-muted); font-size: 11px;
letter-spacing: .06em; text-transform: uppercase;
}
.bt-appbar-sep {
width: 1px; height: 22px; background: var(--bt-line); flex: none;
}
.bt-crumb {
display: flex; align-items: center; gap: 8px;
font-weight: 700; color: var(--bt-ink);
min-width: 0;
}
.bt-crumb .bt-crumb-ico { font-size: 15px; }
.bt-crumb span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bt-appbar-spacer { flex: 1 1 auto; }
.bt-appbar-actions { display: flex; align-items: center; gap: 8px; }
/* Tool-Umschalter */
.bt-switch { position: relative; }
.bt-switch-menu {
position: absolute; right: 0; top: calc(100% + 8px);
width: 320px; max-width: 90vw; padding: 6px;
background: var(--bt-surface);
border: 1px solid var(--bt-line);
border-radius: var(--bt-r-lg);
box-shadow: var(--bt-shadow-lg);
display: none; z-index: 70;
}
.bt-switch-menu.open { display: block; }
.bt-switch-menu .bt-switch-head {
font-size: 11px; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--bt-muted);
padding: 8px 10px 4px;
}
.bt-switch-item {
display: flex; gap: 10px; align-items: flex-start;
padding: 9px 10px; border-radius: var(--bt-r);
color: var(--bt-ink);
}
.bt-switch-item:hover { background: var(--bt-bg-2); text-decoration: none; }
.bt-switch-item.active { background: var(--bt-accent-soft); }
.bt-switch-item .ico { font-size: 17px; line-height: 1.2; flex: none; }
.bt-switch-item b { display: block; font-size: 13px; }
.bt-switch-item i {
display: block; font-style: normal; font-size: 11.5px;
color: var(--bt-muted); line-height: 1.35;
}
/* -------------------------------------------------------------- Container */
.bt-main { padding: 18px 16px 60px; max-width: 1140px; margin: 0 auto; }
.bt-main.wide { max-width: 1520px; }
.bt-main.narrow { max-width: 820px; }
.bt-pagehead { margin: 0 0 16px; }
.bt-pagehead h1 {
margin: 0; font-size: 22px; letter-spacing: -.02em; font-weight: 800;
}
.bt-pagehead p { margin: 5px 0 0; color: var(--bt-muted); }
/* ------------------------------------------------------------------ Karten */
.bt-card {
background: var(--bt-surface);
border: 1px solid var(--bt-line);
border-radius: var(--bt-r-lg);
padding: 16px;
box-shadow: var(--bt-shadow);
margin-bottom: 14px;
}
.bt-card > h2 {
margin: 0 0 3px; font-size: 15px; font-weight: 750;
letter-spacing: -.01em; color: var(--bt-ink);
}
.bt-card > h3 {
margin: 16px 0 7px; font-size: 11.5px; font-weight: 800;
letter-spacing: .09em; text-transform: uppercase; color: var(--bt-teal);
}
.bt-card > .hint, .bt-hint {
margin: 0 0 12px; color: var(--bt-muted); font-size: 12.5px;
}
.bt-card-flush { padding: 0; overflow: hidden; }
/* ----------------------------------------------------------------- Buttons */
button, .bt-btn {
font: inherit; font-weight: 600; cursor: pointer;
border: 1px solid var(--bt-line);
background: var(--bt-surface);
color: var(--bt-ink);
border-radius: var(--bt-r);
padding: 8px 13px;
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
transition: background .13s, border-color .13s, color .13s, transform .06s;
white-space: nowrap;
}
button:hover, .bt-btn:hover { background: var(--bt-bg-2); text-decoration: none; }
button:active, .bt-btn:active { transform: translateY(1px); }
button:focus-visible, .bt-btn:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: none; box-shadow: var(--bt-ring); border-color: var(--bt-accent);
}
button:disabled, .bt-btn:disabled, button[disabled] {
opacity: .5; cursor: not-allowed; transform: none;
}
button.primary, .bt-btn.primary {
background: var(--bt-accent); border-color: var(--bt-accent);
color: var(--bt-accent-ink); font-weight: 700;
}
button.primary:hover, .bt-btn.primary:hover {
background: var(--bt-accent-hi); border-color: var(--bt-accent-hi);
}
button.ghost, .bt-btn.ghost {
background: transparent; border-color: var(--bt-line); color: var(--bt-muted);
}
button.ghost:hover, .bt-btn.ghost:hover { color: var(--bt-ink); background: var(--bt-bg-2); }
button.danger, .bt-btn.danger { color: var(--bt-err); border-color: var(--bt-err); background: transparent; }
button.danger:hover, .bt-btn.danger:hover { background: var(--bt-err-soft); }
button.mini, .bt-btn.mini { padding: 4px 9px; font-size: 12px; border-radius: var(--bt-r-sm); }
button.icon, .bt-btn.icon { padding: 7px 9px; }
.bt-btnrow { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* ---------------------------------------------------------------- Formulare */
label, .bt-label {
display: block; font-size: 11.5px; font-weight: 700;
color: var(--bt-muted); margin: 10px 0 4px;
letter-spacing: .02em;
}
label.bt-inline, .bt-check {
display: inline-flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 600; color: var(--bt-ink-soft);
margin: 0; text-transform: none; letter-spacing: 0;
}
.bt-check input { width: auto; }
input[type=text], input[type=url], input[type=number], input[type=password],
input[type=search], textarea, select {
width: 100%; font: inherit;
padding: 8px 10px;
color: var(--bt-ink);
background: var(--bt-surface);
border: 1px solid var(--bt-line);
border-radius: var(--bt-r);
transition: border-color .13s, box-shadow .13s;
}
input::placeholder, textarea::placeholder { color: var(--bt-muted); opacity: .75; }
textarea { resize: vertical; min-height: 54px; line-height: 1.5; }
select { cursor: pointer; }
input[type=color] {
width: 36px; height: 34px; padding: 2px; cursor: pointer;
border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface);
}
input[type=checkbox], input[type=radio] { accent-color: var(--bt-accent); }
.bt-row { display: flex; gap: 10px; flex-wrap: wrap; }
.bt-row > * { flex: 1; min-width: 130px; }
.bt-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.bt-grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.bt-grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
@media (max-width: 760px) {
.bt-grid2, .bt-grid3, .bt-grid4 { grid-template-columns: 1fr; }
}
/* -------------------------------------------------------------------- Tabs */
.bt-tabs {
display: flex; gap: 3px; flex-wrap: wrap;
border-bottom: 1px solid var(--bt-line);
padding: 0 2px;
}
.bt-tab {
padding: 8px 13px; font-size: 13px; font-weight: 650;
color: var(--bt-muted); background: transparent;
border: none; border-bottom: 2px solid transparent;
border-radius: var(--bt-r-sm) var(--bt-r-sm) 0 0;
margin-bottom: -1px; cursor: pointer;
}
.bt-tab:hover { color: var(--bt-ink); background: var(--bt-bg-2); }
.bt-tab.active { color: var(--bt-accent); border-bottom-color: var(--bt-accent); background: transparent; }
/* ------------------------------------------------------------------ Badges */
.bt-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 700; letter-spacing: .02em;
background: var(--bt-bg-2); color: var(--bt-ink-soft);
border: 1px solid var(--bt-line-soft);
}
.bt-badge.ok { background: var(--bt-ok-soft); color: var(--bt-ok); border-color: transparent; }
.bt-badge.warn { background: var(--bt-warn-soft); color: var(--bt-warn); border-color: transparent; }
.bt-badge.err { background: var(--bt-err-soft); color: var(--bt-err); border-color: transparent; }
.bt-badge.info { background: var(--bt-info-soft); color: var(--bt-info); border-color: transparent; }
.bt-badge.accent { background: var(--bt-accent-soft); color: var(--bt-accent); border-color: transparent; }
.bt-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
/* ------------------------------------------------------------------ Notice */
.bt-notice {
border: 1px solid var(--bt-line);
border-left: 3px solid var(--bt-muted);
background: var(--bt-surface-2);
border-radius: 0 var(--bt-r) var(--bt-r) 0;
padding: 10px 13px; font-size: 12.5px; color: var(--bt-ink-soft);
margin-bottom: 12px;
}
.bt-notice.ok { border-left-color: var(--bt-ok); background: var(--bt-ok-soft); }
.bt-notice.warn { border-left-color: var(--bt-warn); background: var(--bt-warn-soft); }
.bt-notice.err { border-left-color: var(--bt-err); background: var(--bt-err-soft); }
.bt-notice.info { border-left-color: var(--bt-info); background: var(--bt-info-soft); }
.bt-notice code { background: rgba(128,128,128,.16); padding: 1px 5px; border-radius: 4px; }
.bt-notice b { color: var(--bt-ink); }
/* ------------------------------------------------------------------ Status */
.bt-status {
display: flex; align-items: center; gap: 8px;
padding: 9px 13px; border-radius: var(--bt-r);
background: var(--bt-surface); border: 1px solid var(--bt-line);
font-size: 13px; color: var(--bt-ink-soft); margin: 12px 0;
box-shadow: var(--bt-shadow);
}
.bt-status.ok { border-color: var(--bt-ok); color: var(--bt-ok); }
.bt-status.error{ border-color: var(--bt-err); color: var(--bt-err); }
.bt-status.warn { border-color: var(--bt-warn); color: var(--bt-warn); }
.bt-status.busy { border-color: var(--bt-accent); color: var(--bt-accent); }
/* -------------------------------------------------------------------- Item */
.bt-item {
border: 1px solid var(--bt-line);
border-radius: var(--bt-r);
background: var(--bt-surface-2);
padding: 11px 13px; margin: 10px 0;
}
.bt-item-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 7px;
}
.bt-item-head .n {
font-weight: 800; font-size: 11px; color: var(--bt-teal);
letter-spacing: .05em;
}
.bt-item-head .spacer { flex: 1; }
/* ------------------------------------------------------------------ Tabelle */
.bt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.bt-table th {
text-align: left; font-size: 11px; font-weight: 800;
letter-spacing: .07em; text-transform: uppercase; color: var(--bt-muted);
padding: 9px 10px; border-bottom: 1px solid var(--bt-line);
background: var(--bt-surface-2);
}
.bt-table td { padding: 9px 10px; border-bottom: 1px solid var(--bt-line-soft); vertical-align: top; }
.bt-table tr:last-child td { border-bottom: none; }
.bt-table tbody tr:hover { background: var(--bt-surface-2); }
/* --------------------------------------------------------------------- Log */
.bt-log {
font-family: var(--bt-mono); font-size: 12px; line-height: 1.55;
background: #0f1115; color: #d7dde6;
border: 1px solid var(--bt-line); border-radius: var(--bt-r);
padding: 12px 13px; overflow: auto; white-space: pre-wrap;
word-break: break-word; min-height: 180px; max-height: 60vh;
}
.bt-log .l-err { color: #ff9a90; }
.bt-log .l-ok { color: #7fdca2; }
.bt-log .l-warn { color: #ffd28a; }
.bt-log .l-meta { color: #7d8896; }
/* ------------------------------------------------------------------- Toast */
#bt-toaster {
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
z-index: 100; display: flex; flex-direction: column; gap: 8px;
align-items: center; pointer-events: none;
}
.bt-toast {
background: var(--bt-ink); color: var(--bt-bg);
padding: 10px 18px; border-radius: var(--bt-r);
font-size: 13px; font-weight: 600; max-width: 92vw;
box-shadow: var(--bt-shadow-lg);
opacity: 0; transform: translateY(8px);
transition: opacity .2s, transform .2s;
}
.bt-toast.show { opacity: .97; transform: none; }
.bt-toast.err { background: var(--bt-err); color: #fff; }
.bt-toast.ok { background: var(--bt-ok); color: #fff; }
/* ------------------------------------------------------------------ Helpers */
.bt-muted { color: var(--bt-muted); font-size: 12.5px; }
.bt-spacer { flex: 1; }
.bt-mono { font-family: var(--bt-mono); }
.bt-empty {
color: var(--bt-muted); font-size: 13px; text-align: center;
padding: 26px 14px; border: 1px dashed var(--bt-line);
border-radius: var(--bt-r); background: var(--bt-surface-2);
}
.bt-scroll { overflow: auto; }
.bt-hidden { display: none !important; }
/* Scrollbars */
* { scrollbar-color: var(--bt-line) transparent; scrollbar-width: thin; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bt-line); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-track { background: transparent; }
/* ---------------------------------------------------------- Sicherungen */
.bt-runs{ display: grid; gap: 6px; }
.bt-run{
display: grid; grid-template-columns: 20px 1fr auto; gap: 10px; align-items: center;
padding: 10px 12px; border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); cursor: pointer;
}
.bt-run:hover{ background: var(--bt-surface-2); }
.bt-run.sel{ border-color: var(--bt-accent); background: var(--bt-accent-soft); }
.bt-run-main b{ font-size: 13px; }
.bt-run-sub{ display: block; font-size: 11.5px; color: var(--bt-muted); margin-top: 1px; }
.bt-run-tags{ display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
.bt-steps{ display: grid; gap: 5px; max-height: 34vh; overflow: auto; }
.bt-step{
display: grid; grid-template-columns: auto 1fr; gap: 9px; align-items: baseline;
padding: 6px 9px; border-bottom: 1px solid var(--bt-line-soft); font-size: 12.5px;
}
.bt-step:last-child{ border-bottom: none; }
@media (max-width: 700px){
.bt-run{ grid-template-columns: 20px 1fr; }
.bt-run-tags{ grid-column: 2; justify-content: flex-start; }
}
+179
View File
@@ -0,0 +1,179 @@
/* ==========================================================================
boehmitools · gemeinsame Shell
Stellt App-Bar, Tool-Umschalter, Theme und kleine Helfer für alle Plugins.
Einbindung in einem Plugin (siehe plugins/README.md):
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js" defer></script>
<body data-bt-title="Mein Tool" data-bt-icon="🔧"> ... </body>
Der Mount-Pfad wird automatisch aus der URL abgeleitet, ein Plugin muss
ihn nicht kennen. API-Aufrufe laufen über BT.api("/api/...").
========================================================================== */
(function () {
"use strict";
const ROOT_META = document.querySelector('meta[name="bt-root"]');
const pathMatch = location.pathname.match(/^(.*?)(\/plugins\/[^/]+)/);
const BT = {
/** Präfix der gesamten Suite (nur bei Reverse-Proxy-Unterpfad gesetzt). */
root: ROOT_META ? ROOT_META.content.replace(/\/$/, "") : (pathMatch ? pathMatch[1] : ""),
/** Mount-Präfix des aktuellen Plugins, z. B. "/plugins/trainingsplan". */
base: pathMatch ? pathMatch[1] + pathMatch[2] : "",
/** ID des aktuellen Plugins oder null (Dashboard). */
plugin: pathMatch ? pathMatch[2].split("/").pop() : null,
plugins: [],
};
window.BT = BT;
/* ------------------------------------------------------------- Helfer */
/** Plugin-eigener Pfad → absolute URL. */
BT.url = (path) => BT.base + path;
/** Suite-weiter Pfad → absolute URL. */
BT.rootUrl = (path) => BT.root + path;
/** fetch mit JSON-Handling, relativ zum Plugin-Mount. */
BT.api = async function (path, options = {}) {
const opts = { ...options };
if (opts.body !== undefined && !(opts.body instanceof FormData)) {
opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
}
const res = await fetch(BT.url(path), opts);
const text = await res.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
return data;
};
BT.toast = function (message, kind = "") {
let host = document.getElementById("bt-toaster");
if (!host) {
host = document.createElement("div");
host.id = "bt-toaster";
document.body.appendChild(host);
}
const el = document.createElement("div");
el.className = "bt-toast " + kind;
el.textContent = message;
host.appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, kind === "err" ? 5200 : 2600);
};
BT.escape = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
/* -------------------------------------------------------------- Theme */
const THEME_KEY = "boehmitools.theme";
BT.setTheme = function (mode) {
const resolved = mode === "auto"
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: mode;
document.documentElement.setAttribute("data-bt-theme", resolved);
try { localStorage.setItem(THEME_KEY, mode); } catch (_) {}
document.querySelectorAll("[data-bt-theme-toggle]").forEach((b) => {
b.textContent = resolved === "dark" ? "☀︎" : "☾";
b.title = resolved === "dark" ? "Helles Design" : "Dunkles Design";
});
};
BT.currentTheme = function () {
try { return localStorage.getItem(THEME_KEY) || "auto"; } catch (_) { return "auto"; }
};
// so früh wie möglich anwenden (verhindert Aufblitzen)
BT.setTheme(BT.currentTheme());
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (BT.currentTheme() === "auto") BT.setTheme("auto");
});
/* ------------------------------------------------------------ App-Bar */
function onBody(fn) {
if (document.body) fn();
else document.addEventListener("DOMContentLoaded", fn, { once: true });
}
function buildAppbar(plugins) {
const me = plugins.find((p) => p.id === BT.plugin);
const title = document.body.dataset.btTitle || (me ? me.name : "Dashboard");
const icon = document.body.dataset.btIcon || (me ? me.icon : "▦");
const bar = document.createElement("header");
bar.className = "bt-appbar";
bar.innerHTML = `
<a class="bt-brand" href="${BT.rootUrl("/")}" title="Zum Dashboard">
<span class="bt-brand-mark">B</span>
<span>boehmitools</span>
</a>
${BT.plugin ? `
<span class="bt-appbar-sep"></span>
<span class="bt-crumb"><span class="bt-crumb-ico">${BT.escape(icon)}</span>
<span>${BT.escape(title)}</span></span>` : ""}
<span class="bt-appbar-spacer"></span>
<span class="bt-appbar-actions">
<span class="bt-switch">
<button class="mini ghost" id="bt-switch-btn" aria-haspopup="true" aria-expanded="false">
Tools ▾
</button>
<div class="bt-switch-menu" id="bt-switch-menu" role="menu">
<div class="bt-switch-head">Tools</div>
${plugins.map((p) => `
<a class="bt-switch-item ${p.id === BT.plugin ? "active" : ""}" href="${BT.rootUrl(p.mount)}/">
<span class="ico">${BT.escape(p.icon)}</span>
<span><b>${BT.escape(p.name)}</b><i>${BT.escape(p.summary || "")}</i></span>
</a>`).join("")}
<div class="bt-switch-head">Suite</div>
<a class="bt-switch-item" href="${BT.rootUrl("/")}"><span class="ico">▦</span>
<span><b>Dashboard</b><i>Übersicht aller Tools</i></span></a>
<a class="bt-switch-item" href="${BT.rootUrl("/jobs")}"><span class="ico">↻</span>
<span><b>Läufe</b><i>Protokolle der Kommandozeilen-Tools</i></span></a>
<a class="bt-switch-item" href="${BT.rootUrl("/settings")}"><span class="ico">⚙</span>
<span><b>Einstellungen</b><i>Tandoor- und OpenAI-Zugänge</i></span></a>
</div>
</span>
<button class="mini ghost icon" data-bt-theme-toggle title="Design wechseln">☾</button>
</span>`;
const anchor = document.getElementById("bt-appbar") || document.body.firstChild;
if (anchor && anchor.id === "bt-appbar") anchor.replaceWith(bar);
else document.body.insertBefore(bar, document.body.firstChild);
const btn = document.getElementById("bt-switch-btn");
const menu = document.getElementById("bt-switch-menu");
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = menu.classList.toggle("open");
btn.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", () => menu.classList.remove("open"));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") menu.classList.remove("open"); });
bar.querySelector("[data-bt-theme-toggle]").addEventListener("click", () => {
const now = document.documentElement.getAttribute("data-bt-theme");
BT.setTheme(now === "dark" ? "light" : "dark");
});
BT.setTheme(BT.currentTheme());
}
BT.ready = fetch(BT.rootUrl("/api/plugins"))
.then((r) => (r.ok ? r.json() : { plugins: [] }))
.catch(() => ({ plugins: [] }))
.then((d) => new Promise((resolve) => {
BT.plugins = d.plugins || [];
onBody(() => {
if (document.body.dataset.btShell !== "off") buildAppbar(BT.plugins);
document.dispatchEvent(new CustomEvent("bt:ready", { detail: BT }));
resolve(BT);
});
}));
})();