From 9b5425851aae3d15c07f2c43d4bbb4635274ff51 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 24 Jul 2026 21:37:03 +0200 Subject: [PATCH] chore: initial import --- .gitignore | 23 ++ MANIFEST.json | 156 +++++++++++++ MIGRATION.md | 63 +++++ README.md | 216 +++++++++++++++++ RELEASE-NOTES.md | 41 ++++ REPOSITORY.md | 12 + core/__init__.py | 1 + core/ai.py | 112 +++++++++ core/app.py | 233 +++++++++++++++++++ core/backups.py | 219 ++++++++++++++++++ core/foodmatch.py | 297 ++++++++++++++++++++++++ core/importer.py | 275 ++++++++++++++++++++++ core/jobs.py | 226 ++++++++++++++++++ core/loader.py | 70 ++++++ core/registry.py | 215 +++++++++++++++++ core/security.py | 51 +++++ core/settings.py | 173 ++++++++++++++ core/static/404.html | 19 ++ core/static/dashboard.html | 258 +++++++++++++++++++++ core/static/jobs.html | 48 ++++ core/static/settings.html | 107 +++++++++ core/tandoor.py | 237 +++++++++++++++++++ requirements.txt | 15 ++ run.py | 28 +++ shared/boehmi-backups.js | 160 +++++++++++++ shared/boehmi-runner.js | 128 +++++++++++ shared/boehmi.css | 458 +++++++++++++++++++++++++++++++++++++ shared/boehmi.js | 179 +++++++++++++++ 28 files changed, 4020 insertions(+) create mode 100644 .gitignore create mode 100644 MANIFEST.json create mode 100644 MIGRATION.md create mode 100644 README.md create mode 100644 RELEASE-NOTES.md create mode 100644 REPOSITORY.md create mode 100644 core/__init__.py create mode 100644 core/ai.py create mode 100644 core/app.py create mode 100644 core/backups.py create mode 100644 core/foodmatch.py create mode 100644 core/importer.py create mode 100644 core/jobs.py create mode 100644 core/loader.py create mode 100644 core/registry.py create mode 100644 core/security.py create mode 100644 core/settings.py create mode 100644 core/static/404.html create mode 100644 core/static/dashboard.html create mode 100644 core/static/jobs.html create mode 100644 core/static/settings.html create mode 100644 core/tandoor.py create mode 100644 requirements.txt create mode 100755 run.py create mode 100644 shared/boehmi-backups.js create mode 100644 shared/boehmi-runner.js create mode 100644 shared/boehmi.css create mode 100644 shared/boehmi.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb38416 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/MANIFEST.json b/MANIFEST.json new file mode 100644 index 0000000..1a219f1 --- /dev/null +++ b/MANIFEST.json @@ -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" + } + ] +} diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..8d43a1d --- /dev/null +++ b/MIGRATION.md @@ -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//week-01.json +analyses//week-02.json +analyses//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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea0498b --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# boehmitools + +Ein Dashboard, unter dem alle Tools zusammenlaufen. Jedes Tool bleibt dabei +eigenständig — es liegt als Plugin in `plugins//`, 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://: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//` 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//`. + +**Direkt ablegen.** Ordner unter `plugins//` 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/ +│ ├── 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 `` 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//laeufe//` 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. diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md new file mode 100644 index 0000000..e7e718f --- /dev/null +++ b/RELEASE-NOTES.md @@ -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 diff --git a/REPOSITORY.md b/REPOSITORY.md new file mode 100644 index 0000000..1dabbe5 --- /dev/null +++ b/REPOSITORY.md @@ -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 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..63ba567 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +"""boehmitools – gemeinsame Host-Anwendung für alle Tool-Plugins.""" diff --git a/core/ai.py b/core/ai.py new file mode 100644 index 0000000..005314d --- /dev/null +++ b/core/ai.py @@ -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}" diff --git a/core/app.py b/core/app.py new file mode 100644 index 0000000..015535b --- /dev/null +++ b/core/app.py @@ -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//``) + 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() diff --git a/core/backups.py b/core/backups.py new file mode 100644 index 0000000..fe3144a --- /dev/null +++ b/core/backups.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" +Gemeinsame Sicherungs-Verwaltung für die schreibenden Plugins. + +Jeder verändernde Lauf legt einen Ordner unter `data//laeufe//` +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 diff --git a/core/foodmatch.py b/core/foodmatch.py new file mode 100644 index 0000000..a129b2a --- /dev/null +++ b/core/foodmatch.py @@ -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: (0–100, 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 diff --git a/core/importer.py b/core/importer.py new file mode 100644 index 0000000..0603e62 --- /dev/null +++ b/core/importer.py @@ -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//``), Adresse +(``/plugins/``), 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, + } diff --git a/core/jobs.py b/core/jobs.py new file mode 100644 index 0000000..63ce7a7 --- /dev/null +++ b/core/jobs.py @@ -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 diff --git a/core/loader.py b/core/loader.py new file mode 100644 index 0000000..d646420 --- /dev/null +++ b/core/loader.py @@ -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 /.py als Teil des Pakets .""" + load_package(directory, package_alias) + return importlib.import_module(f"{package_alias}.{submodule}") diff --git a/core/registry.py b/core/registry.py new file mode 100644 index 0000000..74ed75a --- /dev/null +++ b/core/registry.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +""" +Plugin-Registry – das Baukasten-Prinzip. + +Ein neues Tool wird angelegt, indem ein Ordner unter plugins// 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/ ein. Das Dashboard listet das Tool automatisch auf. + +plugins//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/ + "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//.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] diff --git a/core/security.py b/core/security.py new file mode 100644 index 0000000..83de3cd --- /dev/null +++ b/core/security.py @@ -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) diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..0b7bf2c --- /dev/null +++ b/core/settings.py @@ -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 + ] diff --git a/core/static/404.html b/core/static/404.html new file mode 100644 index 0000000..b47c896 --- /dev/null +++ b/core/static/404.html @@ -0,0 +1,19 @@ + + + + + +boehmitools · nicht gefunden + + + +
+
+

404

+

Diese Seite gehört zu keinem Tool der Suite.

+ Zum Dashboard +
+
+ + + diff --git a/core/static/dashboard.html b/core/static/dashboard.html new file mode 100644 index 0000000..f2cca0c --- /dev/null +++ b/core/static/dashboard.html @@ -0,0 +1,258 @@ + + + + + +boehmitools · Dashboard + + + + + +
+
+
+

Werkzeugkasten

+

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.

+
+ +
+
+ + +
+ +
+
+
+ +
+ +
+ Baukasten: Ein neues Tool wird zum Plugin, indem sein Ordner unter + plugins/<name>/ abgelegt wird — mit einer plugin.json + und einer backend.py, die create_app(ctx) zurückgibt. + Mit „Plugins neu laden“ erscheint es sofort hier — ohne Neustart. + Details stehen in plugins/README.md. +
+
+ + + + + diff --git a/core/static/jobs.html b/core/static/jobs.html new file mode 100644 index 0000000..88fe49a --- /dev/null +++ b/core/static/jobs.html @@ -0,0 +1,48 @@ + + + + + +boehmitools · Läufe + + + +
+
+

Läufe

+

Alle Ausführungen der Kommandozeilen-Tools dieser Sitzung.

+
+
+ + + +
ToolAufgabeStatusDauer
+
+
Noch keine Läufe in dieser Sitzung.
+
+ + + + diff --git a/core/static/settings.html b/core/static/settings.html new file mode 100644 index 0000000..df037a2 --- /dev/null +++ b/core/static/settings.html @@ -0,0 +1,107 @@ + + + + + +boehmitools · Einstellungen + + + + + +
+
+

Einstellungen

+

Diese Zugänge gelten für alle Tools der Suite. Sie liegen in + data/settings.json und werden den Tools als Umgebungsvariablen + übergeben — genau so, wie sie es als Einzeltool erwarten.

+
+ +
+ 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. +
+ +
+ +
+ + +
+
+ + + + + diff --git a/core/tandoor.py b/core/tandoor.py new file mode 100644 index 0000000..e2000a3 --- /dev/null +++ b/core/tandoor.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..85f45a4 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100755 index 0000000..353d42e --- /dev/null +++ b/run.py @@ -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://:{port}") + print("=" * 64) + uvicorn.run("core.app:app", host=host, port=port, log_level="info") diff --git a/shared/boehmi-backups.js b/shared/boehmi-backups.js new file mode 100644 index 0000000..4a735c7 --- /dev/null +++ b/shared/boehmi-backups.js @@ -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 `
+ Noch keine Sicherungen. Es entsteht bei jedem Lauf eine, der wirklich + etwas schreibt — Trockenübungen zählen nicht.
`; + } + return runs.map((r) => { + const lv = LEVEL[r.restore_level] || LEVEL.nein; + const when = r.created_at ? new Date(r.created_at) : null; + return ` + `; + }).join(""); + } + + function renderDetail(detail) { + const steps = (detail.manifest?.steps || []).filter((s) => s.status === "done"); + if (!steps.length) return `

In diesem Lauf wurde nichts geschrieben.

`; + return `
${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 `
+ ${lv.text} + ${BT.escape(s.food_name || s.source_name || "?")} + — ${was} +
`; + }).join("")}
`; + } + + /** + * 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 = ` +
+
+

Sicherungen

+ +
+

Jeder Lauf, der wirklich geschrieben hat, liegt unter + ${BT.escape(dir || "")}. Der Zustand vorher ist dort + vollständig festgehalten.

+
${renderRuns(runs, selected)}
+
+ + ${runs.length ? ` +
+

Was passierte in diesem Lauf

+

wird geladen …

+ +
+ ${options.note || ""} +
+ +
+ + + + +
+
Bereit.
+
+
` : ""}`; + + 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 }; + }; +})(); diff --git a/shared/boehmi-runner.js b/shared/boehmi-runner.js new file mode 100644 index 0000000..c67dced --- /dev/null +++ b/shared/boehmi-runner.js @@ -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 /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 = `${BT.escape(text)}` + + (extra ? ` ${BT.escape(extra)}` : ""); + } + + 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; +})(); diff --git a/shared/boehmi.css b/shared/boehmi.css new file mode 100644 index 0000000..0cd479d --- /dev/null +++ b/shared/boehmi.css @@ -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; } +} diff --git a/shared/boehmi.js b/shared/boehmi.js new file mode 100644 index 0000000..582fbf3 --- /dev/null +++ b/shared/boehmi.js @@ -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): + + + ... + + 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) => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 = ` + + B + boehmitools + + ${BT.plugin ? ` + + ${BT.escape(icon)} + ${BT.escape(title)}` : ""} + + + + + + + + `; + + 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); + }); + })); +})();