chore: initial import
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
logs/
|
||||||
|
backups/
|
||||||
|
data/
|
||||||
|
plugins/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
+156
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Migration auf Trainingsplan / Trainingstracker 2.0
|
||||||
|
|
||||||
|
## Enthaltene Komponenten
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/trainingsplan/
|
||||||
|
plugins/trainingstracker/
|
||||||
|
data/trainingsplan/plans/phase-1.json
|
||||||
|
data/trainingsplan/plans/calihoss-newbie-8-wochen.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Es werden keine anderen boehmitools-Komponenten verändert.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. boehmitools beziehungsweise den betroffenen Container stoppen.
|
||||||
|
2. Die beiden bestehenden Plugin-Ordner sichern.
|
||||||
|
3. `plugins/trainingsplan/` und `plugins/trainingstracker/` durch die Ordner aus dem Paket ersetzen.
|
||||||
|
4. Die beiden mitgelieferten Plan-JSONs nach `data/trainingsplan/plans/` kopieren.
|
||||||
|
5. boehmitools wieder starten.
|
||||||
|
|
||||||
|
## Bestehende Sessiondateien
|
||||||
|
|
||||||
|
Die Dateien unter `data/trainingstracker/sessions/` müssen nicht manuell geändert werden.
|
||||||
|
|
||||||
|
- Alte Positionsschlüssel wie `d1-r0-e0` bleiben lesbar.
|
||||||
|
- Die neuen Pläne speichern den ursprünglichen Positionsschlüssel dauerhaft als `legacy_id`.
|
||||||
|
- Beim Öffnen und nächsten Speichern einer Session wird der Eintrag auf die stabile Übungs-ID migriert.
|
||||||
|
- Bereits dokumentierte Sessiondaten erhalten beim nächsten Speichern die aktuelle `plan_id` und `plan_revision`, ohne Ergebnisse oder Notizen zu verwerfen.
|
||||||
|
|
||||||
|
Vorhandene Sessiondateien sollten trotzdem einmal separat gesichert werden.
|
||||||
|
|
||||||
|
## Bestehende Analysen
|
||||||
|
|
||||||
|
Der Tracker führt künftig genau eine aktuelle Analyse je Woche und eine Gesamtanalyse:
|
||||||
|
|
||||||
|
```text
|
||||||
|
analyses/<Plan-Dateiname>/week-01.json
|
||||||
|
analyses/<Plan-Dateiname>/week-02.json
|
||||||
|
analyses/<Plan-Dateiname>/overall.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Lesen eines alten Analyseordners wird die im bisherigen `index.json` als aktuell markierte Analyse übernommen. Ältere Zeitstempeldateien und das alte History-Feld werden entfernt. Eine neue Analyse überschreibt anschließend immer dieselbe Datei.
|
||||||
|
|
||||||
|
## Planrevisionen
|
||||||
|
|
||||||
|
Jede Plandatei enthält jetzt:
|
||||||
|
|
||||||
|
- `config`: die veröffentlichte, vom Tracker gelesene Fassung
|
||||||
|
- `draft`: den bearbeitbaren Entwurf
|
||||||
|
- `revision`: Bearbeitungsrevision für Konfliktschutz
|
||||||
|
- `published_revision`: veröffentlichte Planrevision
|
||||||
|
|
||||||
|
Das Speichern eines Entwurfs verändert laufende Sessions nicht. Erst **Entwurf veröffentlichen** stellt die neue Fassung dem Tracker bereit.
|
||||||
|
|
||||||
|
## Bewusste Nicht-Funktionen
|
||||||
|
|
||||||
|
Nicht eingebaut wurden:
|
||||||
|
|
||||||
|
- strukturierte Technik-, RIR- oder RPE-Werte
|
||||||
|
- unterschiedliches Gewicht pro Satz
|
||||||
|
- Trainings- oder Tabata-Timer
|
||||||
|
- mehrere Versionen oder Historien von KI-Analysen
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# boehmitools
|
||||||
|
|
||||||
|
Ein Dashboard, unter dem alle Tools zusammenlaufen. Jedes Tool bleibt dabei
|
||||||
|
eigenständig — es liegt als Plugin in `plugins/<name>/`, hat sein eigenes
|
||||||
|
Backend und seinen eigenen Datenordner. Gemeinsam sind das Design, die
|
||||||
|
Zugangsdaten und das Dach.
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard /
|
||||||
|
├── Trainingsplan- & Rezept-Editor /plugins/trainingsplan
|
||||||
|
├── Tandoor AI Import /plugins/tandoor-ai-import
|
||||||
|
├── Rezept-Inventur /plugins/tandoor-lint
|
||||||
|
├── Stammdaten aufräumen /plugins/tandoor-cleanup
|
||||||
|
├── Nährwerte vervollständigen /plugins/tandoor-nutrition
|
||||||
|
├── Zutaten-Kategorien /plugins/tandoor-categories
|
||||||
|
└── Einheiten-Umrechnungen /plugins/tandoor-conversions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
### Docker (empfohlen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd boehmitools
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # mindestens APP_PASSWORD setzen
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann im Browser: `http://<server-ip>:8080`
|
||||||
|
|
||||||
|
Port ändern: in der `.env` `APP_PORT=9090` setzen.
|
||||||
|
Logs: `docker compose logs -f` · Stoppen: `docker compose down`
|
||||||
|
|
||||||
|
### Direkt mit Python
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python run.py # Standard-Port 8080
|
||||||
|
PORT=9090 python run.py # anderer Port
|
||||||
|
```
|
||||||
|
|
||||||
|
Für die Entwicklung mit automatischem Neuladen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn core.app:app --reload --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Einrichten
|
||||||
|
|
||||||
|
Nach dem ersten Start einmal auf **Einstellungen** (⚙ oben rechts):
|
||||||
|
|
||||||
|
| Gruppe | wird gebraucht von |
|
||||||
|
|---|---|
|
||||||
|
| **Tandoor** — URL, Token, Auth-Schema | AI Import, Rezept-Inventur, Stammdaten, Nährwerte |
|
||||||
|
| **OpenAI** — API-Key, Modell | AI Import |
|
||||||
|
| **Rezeptquellen** — TLS, Timeouts, private IPs | AI Import |
|
||||||
|
|
||||||
|
Die Werte landen in `data/settings.json` und werden den Tools als
|
||||||
|
Umgebungsvariablen übergeben — genau so, wie die Tools sie als Einzelprogramm
|
||||||
|
erwarten. Wer lieber alles in der `.env` pflegt, kann das weiterhin tun: Diese
|
||||||
|
Werte gelten als Vorgabe, solange in der Oberfläche nichts eingetragen ist.
|
||||||
|
|
||||||
|
> Ohne `APP_PASSWORD` ist die Oberfläche ungeschützt. Der Schutz gilt dann für
|
||||||
|
> das Dashboard und alle Tools gemeinsam.
|
||||||
|
|
||||||
|
## Die Tools
|
||||||
|
|
||||||
|
### 🏋 Trainingsplan- & Rezept-Editor
|
||||||
|
Der 8-Wochen-Calisthenics-Plan und die vier Rezeptsammlungen, im Browser
|
||||||
|
bearbeitbar und per Klick als PDF. Phasen anlegen, duplizieren, exportieren.
|
||||||
|
Der ReportLab-Renderer ist unverändert der aus dem Einzeltool — das Layout
|
||||||
|
bleibt identisch.
|
||||||
|
|
||||||
|
### 🥕 Tandoor AI Import (v1.1)
|
||||||
|
URL einfügen → Seite sicher auslesen → OpenAI strukturiert das Rezept →
|
||||||
|
Vorschau, Warnungen und Zuordnungen prüfen → bewusst importieren → Verifikation,
|
||||||
|
bei Fehlern Rollback. Jeder Durchlauf wird unter
|
||||||
|
`data/tandoor-ai-import/runs/<RUN-ID>/` protokolliert.
|
||||||
|
|
||||||
|
Jede Zutat hat ein **Dropdown** mit den passenden Tandoor-Einträgen, der beste
|
||||||
|
Treffer ist vorausgewählt. Gesucht wird über Singular, Plural und Teilwörter —
|
||||||
|
`Kartoffeln` findet `Kartoffel`. Passt nichts, lässt sich über das Textfeld mit
|
||||||
|
einem anderen Namen erneut suchen oder der Eintrag per `➕ Neu anlegen` beim
|
||||||
|
Import erzeugen. Scheitert der Import, werden Rezept **und** die in diesem Lauf
|
||||||
|
neu angelegten Stammdaten zurückgerollt.
|
||||||
|
|
||||||
|
### 🔍 Rezept-Inventur
|
||||||
|
Geht die Sammlung durch und meldet, was fehlt: Rezepte ohne Quelle, Bild,
|
||||||
|
Portionsangabe oder Arbeitszeit, Zutaten ohne Einheit oder Menge, Schritte ohne
|
||||||
|
Text, doppelte Rezeptnamen, verwaiste Zutaten/Einheiten/Schlagworte. **Liest
|
||||||
|
ausschließlich** — es gibt keinen schreibenden Endpunkt. Befunde filterbar,
|
||||||
|
Export als CSV.
|
||||||
|
|
||||||
|
### 🧹 Stammdaten aufräumen
|
||||||
|
Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet
|
||||||
|
Karteileichen. Vorgeschlagen wird nur, was praktisch sicher dasselbe ist
|
||||||
|
(`Zwiebel`/`Zwiebeln`, `Vegan`/`vegan`); Grenzfälle wie `Sahne`/`Schlagsahne`
|
||||||
|
stehen nur zur Ansicht. Tandoor verwirft beim Zusammenführen die Nährwerte des
|
||||||
|
Quelleintrags — dieses Tool rettet sie vorher ans Ziel. Trockenübung,
|
||||||
|
Einzelfreigabe und Sicherung je Schritt. Löschen ist nie vorausgewählt.
|
||||||
|
|
||||||
|
### 🥑 Nährwerte vervollständigen
|
||||||
|
Zeigt, bei welchen Zutaten Eigenschaften fehlen (Abdeckung je Eigenschaft,
|
||||||
|
Lücken nach Rezeptzahl sortiert), lässt sie je 100 g schätzen und schreibt sie
|
||||||
|
nach Prüfung zurück. Jeder Wert ist vor dem Schreiben editierbar. Vorhandene
|
||||||
|
Werte bleiben unangetastet, fehlende Bezugsmengen werden mitgesetzt.
|
||||||
|
Die Zahlen sind Schätzungen eines Sprachmodells, keine Laboranalysen.
|
||||||
|
|
||||||
|
|
||||||
|
### 🏷 Zutaten-Kategorien
|
||||||
|
Ordnet Zutaten ohne Supermarkt-Kategorie einer der vorhandenen
|
||||||
|
Kategorien zu — auf Wunsch von ChatGPT vorgeschlagen. Es werden nur
|
||||||
|
bestehende Kategorien vergeben, jede Zuweisung ist freizugeben und voll
|
||||||
|
rückspielbar.
|
||||||
|
|
||||||
|
### 🔁 Einheiten-Umrechnungen
|
||||||
|
Findet Zutaten, die in Rezepten mit mehreren, nicht ohne Weiteres
|
||||||
|
umrechenbaren Einheiten vorkommen (z. B. Stück und g) und wofür noch
|
||||||
|
keine Umrechnung hinterlegt ist. ChatGPT schlägt sinnvolle Werte vor —
|
||||||
|
diese sind Schätzungen und vor dem Eintragen zu prüfen. Angelegte
|
||||||
|
Umrechnungen sind über die Sicherungen wieder löschbar.
|
||||||
|
## Ein neues Tool hinzufügen
|
||||||
|
|
||||||
|
Es gibt zwei Wege:
|
||||||
|
|
||||||
|
**Hochladen.** Das Plugin als ZIP packen und im Dashboard auf **„Plugin
|
||||||
|
importieren“** — fertig. Erkannt wird es an der `id` aus seiner `plugin.json`;
|
||||||
|
ist die schon vorhanden, wird jene Fassung aktualisiert. Dabei wird nur ergänzt
|
||||||
|
und überschrieben, **nie gelöscht**, und der Datenordner bleibt unberührt. Der
|
||||||
|
bisherige Stand wandert vorher in eine Sicherung unter
|
||||||
|
`data/_plugin-sicherungen/<id>/`.
|
||||||
|
|
||||||
|
**Direkt ablegen.** Ordner unter `plugins/<name>/` anlegen, `plugin.json` und
|
||||||
|
`backend.py` dazulegen und im Dashboard **„Plugins neu laden“** klicken — die
|
||||||
|
Karte erscheint sofort, ohne die Suite neu zu starten. Der Knopf liest `plugins/` neu
|
||||||
|
ein, hängt neue oder geänderte Tools frisch ein und entfernt gelöschte. (Nur
|
||||||
|
Änderungen unter `core/` selbst brauchen weiterhin einen echten Neustart.)
|
||||||
|
Die vollständige Bauanleitung samt Design-Bausteinen steht in
|
||||||
|
**[`plugins/README.md`](plugins/README.md)**.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
boehmitools/
|
||||||
|
├── run.py Start
|
||||||
|
├── core/ Host: Dashboard, Plugin-Lader, Einstellungen, Jobs
|
||||||
|
│ ├── app.py mountet jedes Plugin unter /plugins/<id>
|
||||||
|
│ ├── registry.py liest plugin.json, lädt backend.py
|
||||||
|
│ ├── settings.py data/settings.json ⇄ Umgebungsvariablen
|
||||||
|
│ ├── jobs.py Subprozess-Läufe mit Live-Protokoll (SSE)
|
||||||
|
│ ├── loader.py kollisionsfreier Import der Tool-Module
|
||||||
|
│ └── static/ Dashboard, Einstellungen, Läufe
|
||||||
|
├── shared/ das gemeinsame Aussehen
|
||||||
|
│ ├── boehmi.css Design-System (hell/dunkel)
|
||||||
|
│ ├── boehmi.js App-Bar, Tool-Umschalter, BT.api/BT.toast
|
||||||
|
│ └── boehmi-runner.js Protokoll-Ansicht für Kommandozeilen-Tools
|
||||||
|
├── plugins/ ein Ordner je Tool ← hier wächst der Baukasten
|
||||||
|
└── data/ alles Veränderliche (Volume)
|
||||||
|
├── settings.json
|
||||||
|
├── trainingsplan/plans/
|
||||||
|
├── tandoor-ai-import/runs/
|
||||||
|
├── tandoor-cleanup/laeufe/
|
||||||
|
└── tandoor-nutrition/laeufe/
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Backup ist das Sichern des Ordners `data/`.
|
||||||
|
|
||||||
|
## Tools weiterhin einzeln starten
|
||||||
|
|
||||||
|
Jedes Plugin ist für sich lauffähig geblieben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/trainingsplan && python app.py
|
||||||
|
cd plugins/tandoor-ai-import && uvicorn app.main:app --port 8091
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Original-Anleitungen der Tools liegen als `TOOL-README.md` bzw.
|
||||||
|
`TOOL-README.txt` in den jeweiligen Plugin-Ordnern; beim AI-Import steht in
|
||||||
|
`TOOL-UPGRADE.md` zusätzlich, was v1.1 geändert hat.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Gedacht für das **lokale Netzwerk**. Wenn erreichbar: `APP_PASSWORD` setzen
|
||||||
|
oder einen Reverse Proxy davorsetzen.
|
||||||
|
- Die Tokens verlassen das Backend nicht; OpenAI bekommt ausschließlich die
|
||||||
|
extrahierten Rezeptdaten und die Quell-URL.
|
||||||
|
- Läuft die Suite hinter einem Reverse Proxy in einem Unterpfad, in den
|
||||||
|
HTML-Seiten `<meta name="bt-root" content="/unterpfad">` ergänzen.
|
||||||
|
|
||||||
|
|
||||||
|
## Sicherungen und Zurückspielen
|
||||||
|
|
||||||
|
Die beiden Plugins, die etwas in Tandoor verändern, schreiben vor jeder Änderung
|
||||||
|
eine Sicherung und lassen sich über einen Reiter „Sicherungen“ wieder
|
||||||
|
zurückspielen. Jeder Lauf, der wirklich schreibt, landet unter
|
||||||
|
`data/<plugin>/laeufe/<zeit>/` samt einer `manifest.json`, in der Schritt für
|
||||||
|
Schritt steht, was geändert wurde und wie der Zustand vorher war.
|
||||||
|
|
||||||
|
Wie weit ein Lauf zurückgeholt werden kann, hängt vom Eingriff ab und steht
|
||||||
|
ehrlich an jedem Lauf:
|
||||||
|
|
||||||
|
* **🥑 Nährwerte** – *vollständig rückspielbar.* Die alten Werte werden exakt
|
||||||
|
zurückgeschrieben. Wurde eine Zutat seither von Hand geändert, wird sie
|
||||||
|
erkannt und übersprungen.
|
||||||
|
* **🧹 Stammdaten** – *teilweise rückspielbar.* Zusammengeführte und gelöschte
|
||||||
|
Einträge werden neu angelegt und die aufgezeichneten Rezeptverweise
|
||||||
|
zurückgehängt. Die alte ID ist in Tandoor vergeben und kommt nicht zurück;
|
||||||
|
Einkaufslisten, Umrechnungen und Automatisierungen bleiben beim Ziel. Das
|
||||||
|
Werkzeug zeichnet dafür vor jedem Zusammenführen alle Rezeptverweise auf.
|
||||||
|
|
||||||
|
Zurückspielen gibt es immer als Trockenübung zuerst. Jeder Lauf, der schon
|
||||||
|
einmal zurückgespielt wurde, ist markiert und muss ausdrücklich bestätigt
|
||||||
|
werden.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""boehmitools – gemeinsame Host-Anwendung für alle Tool-Plugins."""
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Gemeinsamer, robuster OpenAI-Zugang für die Plugins.
|
||||||
|
|
||||||
|
Kapselt zwei wiederkehrende Sorgen:
|
||||||
|
|
||||||
|
* **Parameter-Verträglichkeit.** Neuere Modelle (o-Reihe, GPT-5-Reihe) lehnen
|
||||||
|
`temperature` ungleich dem Standard und teils `response_format` ab. Statt
|
||||||
|
daran zu scheitern, wird von der genauesten zur schlichtesten Variante
|
||||||
|
durchprobiert — aber nur bei Parameter-Fehlern. Echte Fehler (falsches
|
||||||
|
Modell, Auth, Netz) werden sofort durchgereicht.
|
||||||
|
* **Antwort lesen.** Modelle schludern manchmal mit Markdown-Zäunen oder packen
|
||||||
|
das Ergebnis in einen Wrapper. `parse_json` gleicht das aus.
|
||||||
|
|
||||||
|
Der Schlüssel kommt aus der Umgebung (OPENAI_API_KEY). Das Modell aus dem
|
||||||
|
Aufrufer oder OPENAI_MODEL, Vorgabe „gpt-5.5“.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.5")
|
||||||
|
|
||||||
|
# Merkt sich prozessweit, welche Aufruf-Variante das Modell akzeptiert, damit
|
||||||
|
# nicht jede Anfrage erneut durchprobiert wird.
|
||||||
|
_CHAT_VARIANT: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json(text: str) -> Any:
|
||||||
|
"""Antworttext zu Daten machen, auch bei Markdown-Zaun oder Wrapper."""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.strip("`")
|
||||||
|
if text[:4].lower() == "json":
|
||||||
|
text = text[4:]
|
||||||
|
text = text.strip()
|
||||||
|
data = json.loads(text)
|
||||||
|
# Einzeln verschachtelten Wrapper auspacken, aber nur wenn die Werte selbst
|
||||||
|
# Tabellen sind (sonst würde ein echtes einelementiges Ergebnis zerstört).
|
||||||
|
if isinstance(data, dict) and len(data) == 1:
|
||||||
|
(only,) = data.values()
|
||||||
|
if isinstance(only, dict) and only and all(isinstance(v, (dict, list)) for v in only.values()):
|
||||||
|
return only
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_completion(client, model: str, messages: list[dict[str, str]]):
|
||||||
|
global _CHAT_VARIANT
|
||||||
|
varianten = [
|
||||||
|
{"response_format": {"type": "json_object"}, "temperature": 0},
|
||||||
|
{"response_format": {"type": "json_object"}},
|
||||||
|
{"temperature": 0},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
if _CHAT_VARIANT is not None:
|
||||||
|
varianten = [_CHAT_VARIANT]
|
||||||
|
|
||||||
|
letzter = None
|
||||||
|
for extra in varianten:
|
||||||
|
try:
|
||||||
|
antwort = client.chat.completions.create(model=model, messages=messages, **extra)
|
||||||
|
_CHAT_VARIANT = extra
|
||||||
|
return antwort
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
letzter = exc
|
||||||
|
text = str(exc).lower()
|
||||||
|
parameterfehler = any(w in text for w in (
|
||||||
|
"temperature", "response_format", "unsupported", "not supported",
|
||||||
|
"unknown_parameter", "invalid_request", "unexpected keyword",
|
||||||
|
))
|
||||||
|
if not parameterfehler:
|
||||||
|
raise
|
||||||
|
raise letzter
|
||||||
|
|
||||||
|
|
||||||
|
def chat_json(messages: list[dict[str, str]], model: str | None = None) -> Any:
|
||||||
|
"""Eine Chat-Abfrage, deren Antwort als JSON gelesen zurückkommt."""
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI()
|
||||||
|
response = _chat_completion(client, model or DEFAULT_MODEL, messages)
|
||||||
|
text = response.choices[0].message.content or "{}"
|
||||||
|
return parse_json(text)
|
||||||
|
|
||||||
|
|
||||||
|
def probe(model: str | None = None) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Eine einzelne Testabfrage. Rückgabe: (erfolg, Klartext-Meldung).
|
||||||
|
|
||||||
|
Braucht kein Tandoor — nur OPENAI_API_KEY.
|
||||||
|
"""
|
||||||
|
model = model or DEFAULT_MODEL
|
||||||
|
try:
|
||||||
|
ergebnis = chat_json(
|
||||||
|
[{"role": "system", "content": "Antworte ausschließlich mit JSON."},
|
||||||
|
{"role": "user", "content": 'Gib genau zurück: {"ok": true}'}],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
low = str(exc).lower()
|
||||||
|
hinweis = ""
|
||||||
|
if "model" in low and ("not" in low or "exist" in low or "unknown" in low):
|
||||||
|
hinweis = " → Modell nicht vorhanden/freigeschaltet. In den Einstellungen ändern."
|
||||||
|
elif "api key" in low or "authentication" in low or "401" in low:
|
||||||
|
hinweis = " → API-Schlüssel wird nicht akzeptiert. OPENAI_API_KEY prüfen."
|
||||||
|
elif "quota" in low or "insufficient" in low or "429" in low:
|
||||||
|
hinweis = " → Kontingent/Guthaben erschöpft."
|
||||||
|
return False, f"OpenAI nicht erreichbar: {exc}{hinweis}\n\n{traceback.format_exc().strip()}"
|
||||||
|
return True, f"OpenAI antwortet. Verwendete Aufruf-Variante: {_CHAT_VARIANT}. Antwort: {ergebnis}"
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
boehmitools – Host-Anwendung.
|
||||||
|
|
||||||
|
Startet das Dashboard, lädt alle Plugins aus plugins/ und hängt sie unter
|
||||||
|
ihrem Mount-Pfad ein. Danach verhält sich jedes Tool so, als liefe es allein:
|
||||||
|
eigene Routen, eigenes Backend, eigener Datenordner – nur eben unter einem
|
||||||
|
gemeinsamen Dach und mit einheitlicher Oberfläche.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.routing import Mount
|
||||||
|
|
||||||
|
from . import importer, registry, settings as settings_module
|
||||||
|
from .jobs import JobManager
|
||||||
|
from .registry import PluginContext
|
||||||
|
from .security import basic_auth_middleware
|
||||||
|
from .settings import Settings
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
PLUGINS_DIR = Path(
|
||||||
|
os.environ.get("BOEHMITOOLS_PLUGINS", BASE_DIR / "plugins")
|
||||||
|
).resolve()
|
||||||
|
SHARED_DIR = BASE_DIR / "shared"
|
||||||
|
STATIC_DIR = BASE_DIR / "core" / "static"
|
||||||
|
DATA_DIR = Path(os.environ.get("BOEHMITOOLS_DATA", BASE_DIR / "data")).resolve()
|
||||||
|
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
app_settings = Settings.load(DATA_DIR / "settings.json")
|
||||||
|
jobs = JobManager()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="boehmitools",
|
||||||
|
version=VERSION,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url=None,
|
||||||
|
)
|
||||||
|
app.middleware("http")(basic_auth_middleware)
|
||||||
|
|
||||||
|
app.state.settings = app_settings
|
||||||
|
app.state.jobs = jobs
|
||||||
|
app.state.plugins = []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Plugins
|
||||||
|
loaded: list[registry.LoadedPlugin] = []
|
||||||
|
|
||||||
|
def mount_plugins(*, reload: bool = False) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Lädt alle Plugins aus plugins/ und hängt sie ein. Bei ``reload=True``
|
||||||
|
werden vorher die bisherigen Plugin-Mounts entfernt und die zugehörigen
|
||||||
|
Module aus dem Cache geworfen, damit geänderter oder neuer Code (und neu
|
||||||
|
hinzugekommene bzw. entfernte Plugins) ohne Neustart wirksam werden.
|
||||||
|
|
||||||
|
Statik (HTML/CSS/JS) wird ohnehin bei jedem Aufruf frisch von der Platte
|
||||||
|
gelesen; dafür genügt ein Neuladen im Browser. Kernänderungen unter
|
||||||
|
core/ erfordern weiterhin einen echten Neustart.
|
||||||
|
"""
|
||||||
|
if reload:
|
||||||
|
# Bisherige Plugin-Mounts aus dem Router nehmen (in place, damit der
|
||||||
|
# Router dieselbe Liste weiterbenutzt).
|
||||||
|
app.router.routes[:] = [
|
||||||
|
r for r in app.router.routes
|
||||||
|
if not (isinstance(r, Mount) and (r.name or "").startswith("plugin_"))
|
||||||
|
]
|
||||||
|
for old in loaded:
|
||||||
|
registry.unload(old.meta)
|
||||||
|
|
||||||
|
neu: list[registry.LoadedPlugin] = []
|
||||||
|
for meta in registry.discover(PLUGINS_DIR):
|
||||||
|
plugin_data = DATA_DIR / meta.id
|
||||||
|
plugin_data.mkdir(parents=True, exist_ok=True)
|
||||||
|
ctx = PluginContext(
|
||||||
|
meta=meta,
|
||||||
|
settings=app_settings,
|
||||||
|
jobs=jobs,
|
||||||
|
data_dir=plugin_data,
|
||||||
|
shared_dir=SHARED_DIR,
|
||||||
|
)
|
||||||
|
result = registry.load(meta, ctx)
|
||||||
|
neu.append(result)
|
||||||
|
if result.ok:
|
||||||
|
app.mount(meta.mount, result.app, name=f"plugin_{meta.id}")
|
||||||
|
print(f"[plugins] {meta.id:<24} → {meta.mount}")
|
||||||
|
else:
|
||||||
|
print(f"[plugins] {meta.id:<24} → FEHLER: {result.error}")
|
||||||
|
|
||||||
|
loaded[:] = neu
|
||||||
|
app.state.plugins = loaded
|
||||||
|
return {
|
||||||
|
"gesamt": len(loaded),
|
||||||
|
"geladen": sum(1 for p in loaded if p.ok),
|
||||||
|
"fehler": [
|
||||||
|
{"id": p.meta.id, "error": p.error} for p in loaded if not p.ok
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mount_plugins()
|
||||||
|
|
||||||
|
def plugin_payload() -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{**p.meta.public(), "ok": p.ok, "error": p.error}
|
||||||
|
for p in loaded
|
||||||
|
]
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Statik
|
||||||
|
app.mount("/shared", StaticFiles(directory=SHARED_DIR), name="shared")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Seiten
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
def dashboard():
|
||||||
|
return FileResponse(STATIC_DIR / "dashboard.html")
|
||||||
|
|
||||||
|
@app.get("/settings", include_in_schema=False)
|
||||||
|
def settings_page():
|
||||||
|
return FileResponse(STATIC_DIR / "settings.html")
|
||||||
|
|
||||||
|
@app.get("/jobs", include_in_schema=False)
|
||||||
|
def jobs_page():
|
||||||
|
return FileResponse(STATIC_DIR / "jobs.html")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- API
|
||||||
|
@app.get("/api/plugins")
|
||||||
|
def api_plugins() -> dict[str, Any]:
|
||||||
|
return {"plugins": plugin_payload(), "version": VERSION}
|
||||||
|
|
||||||
|
@app.post("/api/plugins/reload")
|
||||||
|
def api_plugins_reload() -> dict[str, Any]:
|
||||||
|
"""Plugins ohne Neustart neu einlesen und einhängen."""
|
||||||
|
bericht = mount_plugins(reload=True)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"plugins": plugin_payload(),
|
||||||
|
"version": VERSION,
|
||||||
|
**bericht,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/api/plugins/import")
|
||||||
|
async def api_plugins_import(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
force: bool = Form(False),
|
||||||
|
pruefen_only: bool = Form(False),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Ein Plugin als ZIP hochladen.
|
||||||
|
|
||||||
|
Erkannt wird das Plugin an der ``id`` aus seiner ``plugin.json``. Ist
|
||||||
|
diese id bereits vorhanden, wird genau jener Ordner ergänzt und
|
||||||
|
überschrieben — niemals gelöscht. Der Datenordner (``data/<id>/``)
|
||||||
|
liegt außerhalb und bleibt in jedem Fall unberührt.
|
||||||
|
"""
|
||||||
|
daten = await file.read()
|
||||||
|
try:
|
||||||
|
if pruefen_only:
|
||||||
|
return {"ok": True, "vorschau": importer.pruefe(daten, PLUGINS_DIR)}
|
||||||
|
bericht = importer.importiere(
|
||||||
|
daten, PLUGINS_DIR, DATA_DIR / "_plugin-sicherungen", force=force)
|
||||||
|
except importer.ImportFehler as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
|
||||||
|
# Frisch eingelesen, damit das Plugin sofort nutzbar ist.
|
||||||
|
neu_geladen = mount_plugins(reload=True)
|
||||||
|
eigen = next((p for p in loaded if p.meta.id == bericht["id"]), None)
|
||||||
|
# Achtung: der Reload-Bericht führt selbst ein Feld „geladen“ (Anzahl).
|
||||||
|
# Der Zustand DIESES Plugins heißt deshalb „aktiv“.
|
||||||
|
bericht["aktiv"] = bool(eigen and eigen.ok)
|
||||||
|
bericht["ladefehler"] = None if not eigen or eigen.ok else eigen.error
|
||||||
|
return {**neu_geladen, **bericht, "plugins": plugin_payload()}
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def api_health() -> dict[str, Any]:
|
||||||
|
status = app_settings.status()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"version": VERSION,
|
||||||
|
"plugins": {p.meta.id: ("ok" if p.ok else "error") for p in loaded},
|
||||||
|
"tandoor_configured": status["tandoor"],
|
||||||
|
"openai_configured": status["openai"],
|
||||||
|
"authentication_enabled": bool(os.environ.get("APP_PASSWORD")),
|
||||||
|
"jobs_running": len(jobs.running()),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/settings")
|
||||||
|
def api_settings_get() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema": settings_module.schema(),
|
||||||
|
"values": app_settings.public(),
|
||||||
|
"status": app_settings.status(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/api/settings")
|
||||||
|
async def api_settings_post(request: Request) -> dict[str, Any]:
|
||||||
|
payload = await request.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(400, "Ungültige Daten.")
|
||||||
|
app_settings.update(payload)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"values": app_settings.public(),
|
||||||
|
"status": app_settings.status(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/jobs")
|
||||||
|
def api_jobs_all() -> dict[str, Any]:
|
||||||
|
names = {p.meta.id: p.meta.name for p in loaded}
|
||||||
|
return {
|
||||||
|
"jobs": [
|
||||||
|
{**job.info(), "plugin_name": names.get(job.plugin, job.plugin)}
|
||||||
|
for job in jobs.list()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.exception_handler(404)
|
||||||
|
async def not_found(request: Request, exc):
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
return JSONResponse({"detail": "Nicht gefunden."}, status_code=404)
|
||||||
|
return FileResponse(STATIC_DIR / "404.html", status_code=404)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Gemeinsame Sicherungs-Verwaltung für die schreibenden Plugins.
|
||||||
|
|
||||||
|
Jeder verändernde Lauf legt einen Ordner unter `data/<plugin>/laeufe/<zeit>/`
|
||||||
|
an und schreibt dort eine `manifest.json`. Darin steht Schritt für Schritt, was
|
||||||
|
geändert wurde und wie der Zustand vorher aussah — genug, um es zurückzudrehen.
|
||||||
|
|
||||||
|
Was ein Rückspielen leisten kann, unterscheidet sich je nach Eingriff. Das steht
|
||||||
|
ehrlich im Manifest, damit die Oberfläche es anzeigen kann statt zu behaupten,
|
||||||
|
alles ließe sich rückstandsfrei rückgängig machen:
|
||||||
|
|
||||||
|
"voll" Der vorherige Zustand lässt sich exakt wiederherstellen.
|
||||||
|
Beispiel: geänderte Nährwerte — der alte Wert wird
|
||||||
|
zurückgeschrieben.
|
||||||
|
|
||||||
|
"neue_id" Der Eintrag kommt zurück, bekommt aber eine neue ID. Bei
|
||||||
|
Einträgen, auf die nichts zeigte (unbenutzt gelöscht), ist das
|
||||||
|
folgenlos.
|
||||||
|
|
||||||
|
"teilweise" Der Eintrag kommt mit neuer ID zurück und die aufgezeichneten
|
||||||
|
Verweise werden zurückgehängt. Was nicht aufgezeichnet wurde
|
||||||
|
(Einkaufslisten, Automatisierungen, Umrechnungen), bleibt beim
|
||||||
|
Ziel.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
run: str
|
||||||
|
apply: bool = False
|
||||||
|
force: bool = False
|
||||||
|
|
||||||
|
MANIFEST = "manifest.json"
|
||||||
|
RESTORE_MARK = "zurueckgespielt.json"
|
||||||
|
|
||||||
|
RESTORE_LEVELS = {
|
||||||
|
"voll": "vollständig rückspielbar",
|
||||||
|
"neue_id": "rückspielbar, Eintrag bekommt neue ID",
|
||||||
|
"teilweise": "nur teilweise rückspielbar",
|
||||||
|
"nein": "nicht rückspielbar",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stamp() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, payload: Any) -> Path:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(run_dir: Path, payload: dict[str, Any]) -> Path:
|
||||||
|
return write_json(run_dir / MANIFEST, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def read_manifest(run_dir: Path) -> dict[str, Any] | None:
|
||||||
|
file = run_dir / MANIFEST
|
||||||
|
if not file.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(file.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_restored(run_dir: Path, info: dict[str, Any]) -> None:
|
||||||
|
"""Vermerkt, dass dieser Lauf zurückgespielt wurde."""
|
||||||
|
existing = []
|
||||||
|
file = run_dir / RESTORE_MARK
|
||||||
|
if file.is_file():
|
||||||
|
try:
|
||||||
|
existing = json.loads(file.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
existing = []
|
||||||
|
if not isinstance(existing, list):
|
||||||
|
existing = [existing]
|
||||||
|
existing.append({"at": datetime.now(timezone.utc).isoformat(), **info})
|
||||||
|
write_json(file, existing)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_history(run_dir: Path) -> list[dict[str, Any]]:
|
||||||
|
file = run_dir / RESTORE_MARK
|
||||||
|
if not file.is_file():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(file.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, list) else [data]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(manifest: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Kurzfassung eines Laufs für die Übersicht."""
|
||||||
|
steps = manifest.get("steps") or []
|
||||||
|
done = [s for s in steps if s.get("status") == "done"]
|
||||||
|
levels = {s.get("restore_level", "nein") for s in done}
|
||||||
|
worst = ("nein" if "nein" in levels
|
||||||
|
else "teilweise" if "teilweise" in levels
|
||||||
|
else "neue_id" if "neue_id" in levels
|
||||||
|
else "voll" if levels else "nein")
|
||||||
|
return {
|
||||||
|
"steps": len(steps),
|
||||||
|
"done": len(done),
|
||||||
|
"restore_level": worst,
|
||||||
|
"restore_label": RESTORE_LEVELS[worst],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_runs(runs_dir: Path) -> list[dict[str, Any]]:
|
||||||
|
"""Alle Läufe eines Plugins, neueste zuerst."""
|
||||||
|
if not runs_dir.is_dir():
|
||||||
|
return []
|
||||||
|
runs: list[dict[str, Any]] = []
|
||||||
|
for path in sorted(runs_dir.iterdir(), reverse=True):
|
||||||
|
if not path.is_dir():
|
||||||
|
continue
|
||||||
|
manifest = read_manifest(path)
|
||||||
|
if manifest is None:
|
||||||
|
continue
|
||||||
|
history = restore_history(path)
|
||||||
|
runs.append({
|
||||||
|
"id": path.name,
|
||||||
|
"created_at": manifest.get("created_at"),
|
||||||
|
"tandoor": manifest.get("tandoor"),
|
||||||
|
"label": manifest.get("label") or "",
|
||||||
|
**summarize(manifest),
|
||||||
|
"restored": bool(history),
|
||||||
|
"restored_at": history[-1]["at"] if history else None,
|
||||||
|
})
|
||||||
|
return runs
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_run(runs_dir: Path, name: str) -> Path:
|
||||||
|
"""Lauf-Ordner sicher auflösen — kein Ausbrechen aus dem Datenordner."""
|
||||||
|
candidate = runs_dir / Path(name).name
|
||||||
|
if not candidate.is_dir() or not (candidate / MANIFEST).is_file():
|
||||||
|
raise FileNotFoundError(f"Lauf „{name}“ gibt es nicht.")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Router fürs Plugin
|
||||||
|
|
||||||
|
def backup_router(ctx, runs_subdir: str = "laeufe", restore_argv=None):
|
||||||
|
"""
|
||||||
|
Fertiger Satz Endpunkte für die Sicherungen eines Plugins.
|
||||||
|
|
||||||
|
Damit sieht der Reiter „Sicherungen“ in jedem schreibenden Plugin gleich
|
||||||
|
aus und verhält sich gleich:
|
||||||
|
|
||||||
|
GET /api/backups Läufe auflisten
|
||||||
|
GET /api/backups/{id} ein Lauf im Detail
|
||||||
|
POST /api/run/restore zurückspielen (Trockenübung oder echt)
|
||||||
|
|
||||||
|
`restore_argv(run_id, apply, force)` liefert die Kommandozeile für den
|
||||||
|
Job-Runner. Das Zurückspielen läuft damit über dasselbe Skript und
|
||||||
|
denselben Weg wie das Ändern selbst — nicht über einen zweiten,
|
||||||
|
ungetesteten Pfad.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
runs_dir = ctx.data_dir / runs_subdir
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/api/backups")
|
||||||
|
def backups_list() -> dict[str, Any]:
|
||||||
|
return {"runs": list_runs(runs_dir), "dir": str(runs_dir)}
|
||||||
|
|
||||||
|
@router.get("/api/backups/{name}")
|
||||||
|
def backup_detail(name: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
run = resolve_run(runs_dir, name)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
manifest = read_manifest(run) or {}
|
||||||
|
return {
|
||||||
|
"id": run.name,
|
||||||
|
"manifest": manifest,
|
||||||
|
"summary": summarize(manifest),
|
||||||
|
"history": restore_history(run),
|
||||||
|
"files": sorted(p.name for p in run.glob("*.json")),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/api/run/restore")
|
||||||
|
async def run_restore(request: RestoreRequest) -> dict[str, Any]:
|
||||||
|
if ctx.jobs.running(ctx.id):
|
||||||
|
raise HTTPException(409, "Es läuft bereits etwas.")
|
||||||
|
if not ctx.settings.status()["tandoor"]:
|
||||||
|
raise HTTPException(400, "Tandoor-URL und Token fehlen.")
|
||||||
|
try:
|
||||||
|
run = resolve_run(runs_dir, request.run)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
|
||||||
|
env = ctx.settings.tool_env()
|
||||||
|
env["DATA_DIR"] = str(ctx.data_dir)
|
||||||
|
job = await ctx.jobs.start(
|
||||||
|
plugin=ctx.id,
|
||||||
|
label=("Zurückspielen" if request.apply else "Trockenübung Zurückspielen")
|
||||||
|
+ f": {run.name}",
|
||||||
|
argv=restore_argv(run.name, request.apply, request.force),
|
||||||
|
cwd=ctx.data_dir, env=env,
|
||||||
|
)
|
||||||
|
return job.info()
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Plugins als ZIP hochladen.
|
||||||
|
|
||||||
|
Identität eines Plugins ist die ``id`` aus ``plugin.json`` — nicht der
|
||||||
|
Ordnername. Daran hängen Datenordner (``data/<id>/``), Adresse
|
||||||
|
(``/plugins/<id>``), Modul-Namensraum und Job-Zuordnung. Wird ein Plugin mit
|
||||||
|
bekannter ``id`` hochgeladen, ersetzt es genau dessen Ordner, egal wie der
|
||||||
|
hochgeladene Ordner heißt.
|
||||||
|
|
||||||
|
Grundregel beim Ersetzen: **nur ergänzen und überschreiben, nie löschen.**
|
||||||
|
Dateien, die im Archiv nicht vorkommen, bleiben unangetastet. Der Datenordner
|
||||||
|
liegt ohnehin außerhalb des Plugin-Ordners und wird nie berührt.
|
||||||
|
|
||||||
|
Sicherheit: Ein Plugin ist ausführbarer Code, den der Host importiert. Dieser
|
||||||
|
Import ist damit bewusst eine Administrator-Funktion. Geprüft wird trotzdem
|
||||||
|
alles, was sich prüfen lässt — Pfadausbrüche, Symlinks, Größen, Bomben.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Was eine gültige Plugin-Kennung sein darf. Bewusst eng: sie wird zum
|
||||||
|
# Ordnernamen, zum URL-Bestandteil und zum Modulnamen.
|
||||||
|
ID_MUSTER = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||||
|
|
||||||
|
MAX_ARCHIV = 25 * 1024 * 1024 # 25 MB gepackt
|
||||||
|
MAX_ENTPACKT = 120 * 1024 * 1024 # 120 MB entpackt (gegen ZIP-Bomben)
|
||||||
|
MAX_EINTRAEGE = 3000
|
||||||
|
|
||||||
|
# Diese Namen dürfen nie aus einem Archiv geschrieben werden.
|
||||||
|
VERBOTEN = {".git", ".ssh", "__pycache__"}
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFehler(ValueError):
|
||||||
|
"""Fachlicher Fehler mit einer Meldung, die man dem Menschen zeigen kann."""
|
||||||
|
|
||||||
|
|
||||||
|
def version_tupel(text: str) -> tuple:
|
||||||
|
"""„1.10.2“ → (1, 10, 2). Nicht-Zahlen landen hinten und zählen als 0."""
|
||||||
|
teile = re.split(r"[.\-+]", str(text or "0"))
|
||||||
|
zahlen = []
|
||||||
|
for t in teile:
|
||||||
|
m = re.match(r"^(\d+)", t)
|
||||||
|
zahlen.append(int(m.group(1)) if m else 0)
|
||||||
|
while len(zahlen) < 3:
|
||||||
|
zahlen.append(0)
|
||||||
|
return tuple(zahlen[:4])
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad_ok(name: str) -> bool:
|
||||||
|
"""Verhindert Pfadausbrüche („zip slip“) und absolute Pfade."""
|
||||||
|
if not name or name.startswith("/") or name.startswith("\\"):
|
||||||
|
return False
|
||||||
|
if ":" in name.split("/")[0] and len(name.split("/")[0]) == 2:
|
||||||
|
return False # Laufwerksbuchstabe C:
|
||||||
|
teile = name.replace("\\", "/").split("/")
|
||||||
|
for t in teile:
|
||||||
|
if t in ("", ".", ".."):
|
||||||
|
return False
|
||||||
|
if t in VERBOTEN:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_symlink(info: zipfile.ZipInfo) -> bool:
|
||||||
|
return stat.S_ISLNK(info.external_attr >> 16)
|
||||||
|
|
||||||
|
|
||||||
|
def lies_archiv(daten: bytes) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Prüft das Archiv und liest die ``plugin.json``, ohne irgendetwas zu
|
||||||
|
schreiben. Liefert die Beschreibung des Plugins und die Liste der Dateien.
|
||||||
|
"""
|
||||||
|
if len(daten) > MAX_ARCHIV:
|
||||||
|
raise ImportFehler(
|
||||||
|
f"Archiv ist zu groß ({len(daten)//1024//1024} MB, erlaubt sind "
|
||||||
|
f"{MAX_ARCHIV//1024//1024} MB).")
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(daten))
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise ImportFehler("Das ist keine lesbare ZIP-Datei.")
|
||||||
|
|
||||||
|
infos = [i for i in zf.infolist() if not i.is_dir()]
|
||||||
|
if not infos:
|
||||||
|
raise ImportFehler("Das Archiv ist leer.")
|
||||||
|
if len(infos) > MAX_EINTRAEGE:
|
||||||
|
raise ImportFehler(f"Zu viele Dateien im Archiv ({len(infos)}).")
|
||||||
|
|
||||||
|
gesamt = 0
|
||||||
|
for i in infos:
|
||||||
|
if _ist_symlink(i):
|
||||||
|
raise ImportFehler(f"Symbolische Verknüpfungen sind nicht erlaubt: {i.filename}")
|
||||||
|
if not _pfad_ok(i.filename):
|
||||||
|
raise ImportFehler(f"Unerlaubter Pfad im Archiv: {i.filename}")
|
||||||
|
gesamt += i.file_size
|
||||||
|
if gesamt > MAX_ENTPACKT:
|
||||||
|
raise ImportFehler("Das Archiv wäre entpackt zu groß.")
|
||||||
|
|
||||||
|
# plugin.json mit dem kürzesten Pfad bestimmt den Wurzelordner.
|
||||||
|
kandidaten = [i.filename for i in infos
|
||||||
|
if i.filename.replace("\\", "/").split("/")[-1] == "plugin.json"]
|
||||||
|
if not kandidaten:
|
||||||
|
raise ImportFehler("Im Archiv fehlt eine plugin.json.")
|
||||||
|
kandidaten.sort(key=lambda n: (n.count("/"), len(n)))
|
||||||
|
manifest_pfad = kandidaten[0]
|
||||||
|
wurzel = manifest_pfad.rsplit("/", 1)[0] + "/" if "/" in manifest_pfad else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
roh = json.loads(zf.read(manifest_pfad).decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ImportFehler(f"plugin.json ist nicht lesbar: {exc}")
|
||||||
|
if not isinstance(roh, dict):
|
||||||
|
raise ImportFehler("plugin.json muss ein Objekt enthalten.")
|
||||||
|
|
||||||
|
pid = str(roh.get("id") or "").strip()
|
||||||
|
if not pid:
|
||||||
|
raise ImportFehler("In der plugin.json fehlt die „id“ — daran wird das "
|
||||||
|
"Plugin erkannt.")
|
||||||
|
if not ID_MUSTER.match(pid):
|
||||||
|
raise ImportFehler(
|
||||||
|
f"Die id „{pid}“ ist nicht zulässig. Erlaubt sind Kleinbuchstaben, "
|
||||||
|
"Ziffern, Punkt, Bindestrich und Unterstrich (Beginn mit Buchstabe "
|
||||||
|
"oder Ziffer).")
|
||||||
|
|
||||||
|
dateien = [i.filename for i in infos if i.filename.startswith(wurzel)]
|
||||||
|
if not any(f[len(wurzel):] == "backend.py" for f in dateien) \
|
||||||
|
and not roh.get("entrypoint"):
|
||||||
|
raise ImportFehler("Im Archiv fehlt die backend.py.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"zf": zf,
|
||||||
|
"wurzel": wurzel,
|
||||||
|
"dateien": dateien,
|
||||||
|
"manifest": roh,
|
||||||
|
"id": pid,
|
||||||
|
"name": str(roh.get("name") or pid),
|
||||||
|
"version": str(roh.get("version") or "0.0.0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def finde_vorhandenes(plugins_dir: Path, pid: str) -> Path | None:
|
||||||
|
"""
|
||||||
|
Sucht den Ordner eines bereits vorhandenen Plugins **anhand der id**, nicht
|
||||||
|
anhand des Ordnernamens. Nur so wird ein umbenanntes Plugin sauber ersetzt
|
||||||
|
statt doppelt angelegt.
|
||||||
|
"""
|
||||||
|
for manifest in sorted(plugins_dir.glob("*/plugin.json")):
|
||||||
|
try:
|
||||||
|
roh = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
vorhandene_id = str(roh.get("id") or manifest.parent.name).strip()
|
||||||
|
if vorhandene_id == pid:
|
||||||
|
return manifest.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe(daten: bytes, plugins_dir: Path) -> dict[str, Any]:
|
||||||
|
"""Vorschau: Was würde der Import tun? Schreibt nichts."""
|
||||||
|
a = lies_archiv(daten)
|
||||||
|
vorhanden = finde_vorhandenes(plugins_dir, a["id"])
|
||||||
|
alt_version = None
|
||||||
|
if vorhanden:
|
||||||
|
try:
|
||||||
|
alt = json.loads((vorhanden / "plugin.json").read_text(encoding="utf-8"))
|
||||||
|
alt_version = str(alt.get("version") or "0.0.0")
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
alt_version = "?"
|
||||||
|
|
||||||
|
neu = version_tupel(a["version"])
|
||||||
|
alt_t = version_tupel(alt_version) if alt_version else None
|
||||||
|
if not vorhanden:
|
||||||
|
art, warnung = "neu", None
|
||||||
|
elif alt_t is None or neu > alt_t:
|
||||||
|
art, warnung = "aktualisierung", None
|
||||||
|
elif neu == alt_t:
|
||||||
|
art = "gleiche_version"
|
||||||
|
warnung = (f"Version {a['version']} ist bereits installiert. Beim "
|
||||||
|
"Fortfahren werden die Dateien überschrieben.")
|
||||||
|
else:
|
||||||
|
art = "aelter"
|
||||||
|
warnung = (f"Die hochgeladene Version {a['version']} ist ÄLTER als die "
|
||||||
|
f"installierte {alt_version}.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": a["id"],
|
||||||
|
"name": a["name"],
|
||||||
|
"version": a["version"],
|
||||||
|
"version_alt": alt_version,
|
||||||
|
"art": art,
|
||||||
|
"warnung": warnung,
|
||||||
|
"ordner": vorhanden.name if vorhanden else a["id"],
|
||||||
|
"dateien": len(a["dateien"]),
|
||||||
|
"bestaetigung_noetig": art in ("gleiche_version", "aelter"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def importiere(daten: bytes, plugins_dir: Path, sicherungen: Path,
|
||||||
|
force: bool = False) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Schreibt das Plugin nach ``plugins/``.
|
||||||
|
|
||||||
|
* Vorhandenes wird **anhand der id** gefunden und im selben Ordner ergänzt.
|
||||||
|
* Es wird nur geschrieben und überschrieben — **nichts gelöscht**. Dateien,
|
||||||
|
die das Archiv nicht enthält, bleiben liegen.
|
||||||
|
* Vorher wandert der bisherige Stand vollständig in eine Sicherung.
|
||||||
|
"""
|
||||||
|
vorschau = pruefe(daten, plugins_dir)
|
||||||
|
if vorschau["bestaetigung_noetig"] and not force:
|
||||||
|
raise ImportFehler(vorschau["warnung"] or "Bestätigung nötig.")
|
||||||
|
|
||||||
|
a = lies_archiv(daten)
|
||||||
|
zf, wurzel = a["zf"], a["wurzel"]
|
||||||
|
pid = a["id"]
|
||||||
|
|
||||||
|
vorhanden = finde_vorhandenes(plugins_dir, pid)
|
||||||
|
ziel = vorhanden if vorhanden else (plugins_dir / pid)
|
||||||
|
ziel_aufgeloest = ziel.resolve()
|
||||||
|
plugins_aufgeloest = plugins_dir.resolve()
|
||||||
|
if plugins_aufgeloest not in ziel_aufgeloest.parents:
|
||||||
|
raise ImportFehler("Zielordner liegt außerhalb von plugins/.")
|
||||||
|
|
||||||
|
# Sicherung des bisherigen Standes (macht den Import umkehrbar).
|
||||||
|
sicherung = None
|
||||||
|
if ziel.is_dir():
|
||||||
|
stempel = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
basis = sicherungen / pid
|
||||||
|
basis.mkdir(parents=True, exist_ok=True)
|
||||||
|
sicherung = basis / stempel
|
||||||
|
# Mehrere Importe in derselben Sekunde dürfen sich nicht überschreiben.
|
||||||
|
zaehler = 2
|
||||||
|
while sicherung.exists():
|
||||||
|
sicherung = basis / f"{stempel}-{zaehler}"
|
||||||
|
zaehler += 1
|
||||||
|
shutil.copytree(ziel, sicherung)
|
||||||
|
|
||||||
|
ziel.mkdir(parents=True, exist_ok=True)
|
||||||
|
geschrieben, uebersprungen = 0, 0
|
||||||
|
for name in a["dateien"]:
|
||||||
|
rel = name[len(wurzel):]
|
||||||
|
if not rel or rel.endswith("/"):
|
||||||
|
continue
|
||||||
|
if not _pfad_ok(rel):
|
||||||
|
uebersprungen += 1
|
||||||
|
continue
|
||||||
|
pfad = (ziel / rel).resolve()
|
||||||
|
if ziel_aufgeloest != pfad and ziel_aufgeloest not in pfad.parents:
|
||||||
|
uebersprungen += 1 # Ausbruchsversuch — überspringen
|
||||||
|
continue
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(name) as quelle, open(pfad, "wb") as ablage:
|
||||||
|
shutil.copyfileobj(quelle, ablage, length=64 * 1024)
|
||||||
|
geschrieben += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": pid,
|
||||||
|
"name": a["name"],
|
||||||
|
"version": a["version"],
|
||||||
|
"version_alt": vorschau["version_alt"],
|
||||||
|
"art": vorschau["art"],
|
||||||
|
"ordner": ziel.name,
|
||||||
|
"geschrieben": geschrieben,
|
||||||
|
"uebersprungen": uebersprungen,
|
||||||
|
"sicherung": str(sicherung) if sicherung else None,
|
||||||
|
}
|
||||||
+226
@@ -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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Import-Helfer für Plugins.
|
||||||
|
|
||||||
|
Die eingebundenen Tools sind eigenständig gewachsen und benutzen deshalb
|
||||||
|
naheliegende Modulnamen (app.py, storage.py, models.py …). Würden sie normal
|
||||||
|
importiert, käme sich das zweite Tool mit dem ersten ins Gehege.
|
||||||
|
|
||||||
|
Diese Helfer laden ein Modul bzw. ein Paket unter einem eindeutigen Namen
|
||||||
|
direkt aus dem Plugin-Ordner. Die Tools selbst müssen dafür nichts ändern:
|
||||||
|
paketinterne Importe (from .auth import ...) funktionieren unverändert.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(file: Path, alias: str) -> ModuleType:
|
||||||
|
"""Lädt eine einzelne .py-Datei unter eindeutigem Namen."""
|
||||||
|
file = Path(file)
|
||||||
|
if not file.is_file():
|
||||||
|
raise FileNotFoundError(f"{file} fehlt")
|
||||||
|
if alias in sys.modules:
|
||||||
|
return sys.modules[alias]
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(alias, file)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"{file} kann nicht geladen werden")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[alias] = module
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
except Exception:
|
||||||
|
sys.modules.pop(alias, None)
|
||||||
|
raise
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def load_package(directory: Path, alias: str) -> ModuleType:
|
||||||
|
"""Lädt ein Paket-Verzeichnis (mit __init__.py) unter eindeutigem Namen."""
|
||||||
|
directory = Path(directory)
|
||||||
|
init = directory / "__init__.py"
|
||||||
|
if not init.is_file():
|
||||||
|
raise FileNotFoundError(f"{init} fehlt")
|
||||||
|
if alias in sys.modules:
|
||||||
|
return sys.modules[alias]
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
alias, init, submodule_search_locations=[str(directory)]
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"{directory} kann nicht geladen werden")
|
||||||
|
package = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[alias] = package
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(package)
|
||||||
|
except Exception:
|
||||||
|
sys.modules.pop(alias, None)
|
||||||
|
raise
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def load_submodule(directory: Path, package_alias: str, submodule: str) -> ModuleType:
|
||||||
|
"""Lädt <directory>/<submodule>.py als Teil des Pakets <package_alias>."""
|
||||||
|
load_package(directory, package_alias)
|
||||||
|
return importlib.import_module(f"{package_alias}.{submodule}")
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Plugin-Registry – das Baukasten-Prinzip.
|
||||||
|
|
||||||
|
Ein neues Tool wird angelegt, indem ein Ordner unter plugins/<id>/ abgelegt
|
||||||
|
wird. Mehr ist nicht nötig: Beim Start liest die Suite jede plugin.json ein,
|
||||||
|
importiert das darin genannte Backend und hängt dessen ASGI-App unter
|
||||||
|
/plugins/<id> ein. Das Dashboard listet das Tool automatisch auf.
|
||||||
|
|
||||||
|
plugins/<id>/plugin.json
|
||||||
|
-------------------------
|
||||||
|
{
|
||||||
|
"id": "mein-tool", Pflicht, identisch zum Ordnernamen
|
||||||
|
"name": "Mein Tool", Pflicht, Anzeigename
|
||||||
|
"summary": "Kurz in einer Zeile",
|
||||||
|
"description": "Längerer Text für die Dashboard-Karte",
|
||||||
|
"icon": "🔧",
|
||||||
|
"category": "Tandoor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entrypoint": "backend:create_app", Modul:Funktion, relativ zum Plugin-Ordner
|
||||||
|
"mount": "/plugins/mein-tool", optional, Standard ist /plugins/<id>
|
||||||
|
"requires": ["tandoor"], optional: tandoor | openai
|
||||||
|
"features": ["Stichpunkt", "..."], optional, für die Dashboard-Karte
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.py
|
||||||
|
----------
|
||||||
|
def create_app(ctx: PluginContext):
|
||||||
|
...
|
||||||
|
return app # FastAPI-, Starlette- oder (via a2wsgi) WSGI-App
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .jobs import JobManager
|
||||||
|
from .settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- Modell
|
||||||
|
@dataclass
|
||||||
|
class PluginMeta:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
directory: Path
|
||||||
|
summary: str = ""
|
||||||
|
description: str = ""
|
||||||
|
icon: str = "▪"
|
||||||
|
category: str = "Allgemein"
|
||||||
|
version: str = "1.0.0"
|
||||||
|
entrypoint: str = "backend:create_app"
|
||||||
|
mount: str = ""
|
||||||
|
requires: list[str] = field(default_factory=list)
|
||||||
|
features: list[str] = field(default_factory=list)
|
||||||
|
docs: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, manifest: Path) -> "PluginMeta":
|
||||||
|
raw: dict[str, Any] = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
directory = manifest.parent
|
||||||
|
pid = str(raw.get("id") or directory.name)
|
||||||
|
meta = cls(
|
||||||
|
id=pid,
|
||||||
|
name=str(raw.get("name") or pid),
|
||||||
|
directory=directory,
|
||||||
|
summary=str(raw.get("summary", "")),
|
||||||
|
description=str(raw.get("description", "")),
|
||||||
|
icon=str(raw.get("icon", "▪")),
|
||||||
|
category=str(raw.get("category", "Allgemein")),
|
||||||
|
version=str(raw.get("version", "1.0.0")),
|
||||||
|
entrypoint=str(raw.get("entrypoint", "backend:create_app")),
|
||||||
|
mount=str(raw.get("mount") or f"/plugins/{pid}"),
|
||||||
|
requires=list(raw.get("requires", []) or []),
|
||||||
|
features=list(raw.get("features", []) or []),
|
||||||
|
docs=str(raw.get("docs", "")),
|
||||||
|
enabled=bool(raw.get("enabled", True)),
|
||||||
|
order=int(raw.get("order", 100)),
|
||||||
|
)
|
||||||
|
if not meta.mount.startswith("/"):
|
||||||
|
meta.mount = "/" + meta.mount
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id, "name": self.name, "summary": self.summary,
|
||||||
|
"description": self.description, "icon": self.icon,
|
||||||
|
"category": self.category, "version": self.version,
|
||||||
|
"mount": self.mount, "requires": self.requires,
|
||||||
|
"features": self.features, "docs": self.docs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PluginContext:
|
||||||
|
"""Wird jedem Plugin beim Start übergeben."""
|
||||||
|
meta: PluginMeta
|
||||||
|
settings: Settings
|
||||||
|
jobs: JobManager
|
||||||
|
data_dir: Path
|
||||||
|
shared_dir: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return self.meta.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dir(self) -> Path:
|
||||||
|
return self.meta.directory
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mount(self) -> str:
|
||||||
|
return self.meta.mount
|
||||||
|
|
||||||
|
def path(self, *parts: str) -> Path:
|
||||||
|
return self.meta.directory.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LoadedPlugin:
|
||||||
|
meta: PluginMeta
|
||||||
|
app: Any = None
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.app is not None and not self.error
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Discovery
|
||||||
|
def discover(plugins_dir: Path) -> list[PluginMeta]:
|
||||||
|
metas: list[PluginMeta] = []
|
||||||
|
if not plugins_dir.is_dir():
|
||||||
|
return metas
|
||||||
|
for entry in sorted(plugins_dir.iterdir()):
|
||||||
|
manifest = entry / "plugin.json"
|
||||||
|
if not entry.is_dir() or not manifest.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta = PluginMeta.from_file(manifest)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[plugins] {entry.name}: plugin.json unlesbar – {exc}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
if meta.enabled:
|
||||||
|
metas.append(meta)
|
||||||
|
metas.sort(key=lambda m: (m.order, m.name.casefold()))
|
||||||
|
return metas
|
||||||
|
|
||||||
|
|
||||||
|
def _import_module(meta: PluginMeta, module_name: str):
|
||||||
|
"""Importiert plugins/<id>/<module>.py isoliert unter eindeutigem Namen."""
|
||||||
|
file = meta.directory / f"{module_name}.py"
|
||||||
|
if not file.is_file():
|
||||||
|
raise FileNotFoundError(f"{file} fehlt")
|
||||||
|
qualified = f"boehmitools_plugin_{meta.id.replace('-', '_')}_{module_name}"
|
||||||
|
spec = importlib.util.spec_from_file_location(qualified, file)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"{file} kann nicht geladen werden")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[qualified] = module
|
||||||
|
# Das Plugin-Verzeichnis muss importierbar sein, damit die Original-Tools
|
||||||
|
# ihre eigenen Module weiterhin schlicht per "import x" finden.
|
||||||
|
directory = str(meta.directory)
|
||||||
|
if directory not in sys.path:
|
||||||
|
sys.path.insert(0, directory)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def load(meta: PluginMeta, ctx: PluginContext) -> LoadedPlugin:
|
||||||
|
try:
|
||||||
|
module_name, _, func_name = meta.entrypoint.partition(":")
|
||||||
|
module = _import_module(meta, module_name or "backend")
|
||||||
|
factory = getattr(module, func_name or "create_app", None)
|
||||||
|
if factory is None:
|
||||||
|
raise AttributeError(f"{meta.entrypoint} nicht gefunden")
|
||||||
|
app = factory(ctx)
|
||||||
|
if app is None:
|
||||||
|
raise ValueError("create_app() hat keine App zurückgegeben")
|
||||||
|
return LoadedPlugin(meta=meta, app=app)
|
||||||
|
except Exception as exc:
|
||||||
|
detail = traceback.format_exc(limit=6)
|
||||||
|
print(f"[plugins] {meta.id}: Laden fehlgeschlagen\n{detail}", file=sys.stderr)
|
||||||
|
return LoadedPlugin(meta=meta, error=f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def unload(meta: PluginMeta) -> None:
|
||||||
|
"""
|
||||||
|
Entfernt alle Module, die aus dem Ordner dieses Plugins geladen wurden, aus
|
||||||
|
``sys.modules`` — sowohl das qualifizierte Backend als auch Untermodule, die
|
||||||
|
das Plugin per ``import x`` aus seinem eigenen Ordner gezogen hat.
|
||||||
|
|
||||||
|
Nötig, damit ein erneutes ``load`` geänderten Code wirklich neu einliest und
|
||||||
|
nicht die zwischengespeicherte Fassung wiederverwendet. Kernmodule (``core``)
|
||||||
|
werden bewusst NICHT angefasst — die laufen ja gerade.
|
||||||
|
"""
|
||||||
|
prefix = str(meta.directory.resolve())
|
||||||
|
for name, module in list(sys.modules.items()):
|
||||||
|
datei = getattr(module, "__file__", None)
|
||||||
|
if not datei:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
unter_plugin = str(Path(datei).resolve()).startswith(prefix)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
if unter_plugin:
|
||||||
|
del sys.modules[name]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>boehmitools · nicht gefunden</title>
|
||||||
|
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||||
|
</head>
|
||||||
|
<body data-bt-title="Nicht gefunden" data-bt-icon="∅">
|
||||||
|
<main class="bt-main narrow">
|
||||||
|
<div class="bt-card" style="text-align:center;padding:48px 20px">
|
||||||
|
<h1 style="margin:0 0 6px;font-size:44px;letter-spacing:-.04em">404</h1>
|
||||||
|
<p class="bt-muted" style="margin:0 0 18px">Diese Seite gehört zu keinem Tool der Suite.</p>
|
||||||
|
<a class="bt-btn primary" href="/">Zum Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="/shared/boehmi.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>boehmitools · Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
display: flex; align-items: flex-end; gap: 16px;
|
||||||
|
flex-wrap: wrap; margin: 6px 0 22px;
|
||||||
|
}
|
||||||
|
.hero h1 { margin: 0; font-size: 27px; font-weight: 850; letter-spacing: -.03em; }
|
||||||
|
.hero p { margin: 6px 0 0; color: var(--bt-muted); max-width: 62ch; }
|
||||||
|
.hero .bt-spacer { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.health { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.hero-side { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
|
||||||
|
.hero-side .health { justify-content: flex-end; }
|
||||||
|
#reloadPlugins { white-space: nowrap; }
|
||||||
|
.side-btns { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
#importPlugin { white-space: nowrap; }
|
||||||
|
|
||||||
|
.cat { margin: 26px 0 10px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.cat h2 {
|
||||||
|
margin: 0; font-size: 11.5px; font-weight: 800; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; color: var(--bt-muted);
|
||||||
|
}
|
||||||
|
.cat .line { flex: 1; height: 1px; background: var(--bt-line); }
|
||||||
|
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 14px; }
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
background: var(--bt-surface); border: 1px solid var(--bt-line);
|
||||||
|
border-radius: var(--bt-r-lg); padding: 16px; box-shadow: var(--bt-shadow);
|
||||||
|
color: inherit; transition: border-color .15s, transform .1s, box-shadow .15s;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.tool::before {
|
||||||
|
content: ""; position: absolute; inset: 0 auto 0 0; width: 3px;
|
||||||
|
background: var(--bt-accent); opacity: 0; transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.tool:hover { text-decoration: none; border-color: var(--bt-accent); transform: translateY(-2px); box-shadow: var(--bt-shadow-lg); }
|
||||||
|
.tool:hover::before { opacity: 1; }
|
||||||
|
.tool.broken { border-style: dashed; opacity: .85; }
|
||||||
|
.tool.broken:hover { transform: none; border-color: var(--bt-err); }
|
||||||
|
|
||||||
|
.tool-head { display: flex; align-items: flex-start; gap: 11px; }
|
||||||
|
.tool-ico {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: 11px;
|
||||||
|
display: grid; place-items: center; font-size: 20px;
|
||||||
|
background: var(--bt-accent-soft); border: 1px solid var(--bt-line-soft);
|
||||||
|
}
|
||||||
|
.tool-head h3 { margin: 0; font-size: 15.5px; font-weight: 750; letter-spacing: -.01em; }
|
||||||
|
.tool-head .ver { font-size: 11px; color: var(--bt-muted); font-weight: 600; }
|
||||||
|
.tool p.desc { margin: 0; font-size: 13px; color: var(--bt-ink-soft); line-height: 1.5; }
|
||||||
|
.tool ul {
|
||||||
|
margin: 0; padding: 0; list-style: none;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.tool ul li {
|
||||||
|
font-size: 12px; color: var(--bt-muted); padding-left: 15px; position: relative;
|
||||||
|
}
|
||||||
|
.tool ul li::before {
|
||||||
|
content: "›"; position: absolute; left: 3px; color: var(--bt-accent); font-weight: 800;
|
||||||
|
}
|
||||||
|
.tool-foot {
|
||||||
|
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||||
|
margin-top: auto; padding-top: 4px;
|
||||||
|
}
|
||||||
|
.tool-foot .go { margin-left: auto; font-size: 12.5px; font-weight: 700; color: var(--bt-accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-bt-title="Dashboard" data-bt-icon="▦">
|
||||||
|
|
||||||
|
<main class="bt-main">
|
||||||
|
<div class="hero">
|
||||||
|
<div>
|
||||||
|
<h1>Werkzeugkasten</h1>
|
||||||
|
<p>Alle Tools laufen unter einem Dach und teilen sich Zugänge, Design und
|
||||||
|
Protokolle. Jedes Tool bleibt dabei eigenständig — es lässt sich einzeln
|
||||||
|
starten, aktualisieren oder entfernen.</p>
|
||||||
|
</div>
|
||||||
|
<span class="bt-spacer"></span>
|
||||||
|
<div class="hero-side">
|
||||||
|
<div class="side-btns">
|
||||||
|
<button class="bt-btn" id="importPlugin" title="Plugin als ZIP hochladen">
|
||||||
|
⤓ Plugin importieren</button>
|
||||||
|
<button class="bt-btn" id="reloadPlugins" title="plugins/ neu einlesen — ohne Neustart">
|
||||||
|
⟳ Plugins neu laden</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="pluginFile" accept=".zip,application/zip" hidden>
|
||||||
|
<div class="health" id="health"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cats"></div>
|
||||||
|
|
||||||
|
<div class="bt-notice info" style="margin-top:28px">
|
||||||
|
<b>Baukasten:</b> Ein neues Tool wird zum Plugin, indem sein Ordner unter
|
||||||
|
<code>plugins/<name>/</code> abgelegt wird — mit einer <code>plugin.json</code>
|
||||||
|
und einer <code>backend.py</code>, die <code>create_app(ctx)</code> zurückgibt.
|
||||||
|
Mit <b>„Plugins neu laden“</b> erscheint es sofort hier — ohne Neustart.
|
||||||
|
Details stehen in <code>plugins/README.md</code>.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/shared/boehmi.js"></script>
|
||||||
|
<script>
|
||||||
|
const catsEl = document.getElementById("cats");
|
||||||
|
const healthEl = document.getElementById("health");
|
||||||
|
|
||||||
|
function chip(label, ok, hint) {
|
||||||
|
return `<span class="bt-badge ${ok ? "ok" : "warn"}" title="${BT.escape(hint || "")}">
|
||||||
|
<span class="bt-dot"></span>${BT.escape(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(p) {
|
||||||
|
const broken = !p.ok;
|
||||||
|
const href = broken ? "#" : p.mount + "/";
|
||||||
|
return `
|
||||||
|
<a class="tool ${broken ? "broken" : ""}" href="${href}" ${broken ? 'onclick="return false"' : ""}>
|
||||||
|
<div class="tool-head">
|
||||||
|
<span class="tool-ico">${BT.escape(p.icon)}</span>
|
||||||
|
<div>
|
||||||
|
<h3>${BT.escape(p.name)}</h3>
|
||||||
|
<span class="ver">v${BT.escape(p.version)} · ${BT.escape(p.mount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="desc">${BT.escape(p.description || p.summary)}</p>
|
||||||
|
${p.features && p.features.length
|
||||||
|
? `<ul>${p.features.slice(0, 4).map((f) => `<li>${BT.escape(f)}</li>`).join("")}</ul>` : ""}
|
||||||
|
<div class="tool-foot">
|
||||||
|
${(p.requires || []).map((r) => `<span class="bt-badge">${BT.escape(r)}</span>`).join("")}
|
||||||
|
${broken
|
||||||
|
? `<span class="bt-badge err">Ladefehler</span>`
|
||||||
|
: `<span class="go">Öffnen →</span>`}
|
||||||
|
</div>
|
||||||
|
${broken ? `<div class="bt-notice err" style="margin:0">${BT.escape(p.error)}</div>` : ""}
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const [{ plugins }, health] = await Promise.all([
|
||||||
|
fetch("/api/plugins").then((r) => r.json()),
|
||||||
|
fetch("/api/health").then((r) => r.json()).catch(() => ({})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
healthEl.innerHTML = [
|
||||||
|
chip("Tandoor", health.tandoor_configured, "URL und Token in den Einstellungen"),
|
||||||
|
chip("OpenAI", health.openai_configured, "API-Key und Modell in den Einstellungen"),
|
||||||
|
health.authentication_enabled
|
||||||
|
? chip("Zugangsschutz", true, "APP_PASSWORD ist gesetzt")
|
||||||
|
: chip("Kein Zugangsschutz", false, "APP_PASSWORD setzen, wenn erreichbar"),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const cats = [...new Set(plugins.map((p) => p.category))];
|
||||||
|
catsEl.innerHTML = cats.map((c) => `
|
||||||
|
<div class="cat"><h2>${BT.escape(c)}</h2><span class="line"></span></div>
|
||||||
|
<div class="cards">${plugins.filter((p) => p.category === c).map(card).join("")}</div>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
if (!plugins.length) {
|
||||||
|
catsEl.innerHTML = `<div class="bt-empty">Noch keine Plugins unter <code>plugins/</code>.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadBtn = document.getElementById("reloadPlugins");
|
||||||
|
reloadBtn.addEventListener("click", async () => {
|
||||||
|
const alt = reloadBtn.textContent;
|
||||||
|
reloadBtn.disabled = true;
|
||||||
|
reloadBtn.textContent = "⟳ lädt …";
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/plugins/reload", { method: "POST" });
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
await render();
|
||||||
|
const fehler = (data.fehler || []).length;
|
||||||
|
BT.toast(
|
||||||
|
`${data.geladen}/${data.gesamt} Plugins geladen`
|
||||||
|
+ (fehler ? `, ${fehler} mit Fehler` : ""),
|
||||||
|
fehler ? "err" : "ok",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
BT.toast("Neu laden fehlgeschlagen: " + err.message, "err");
|
||||||
|
} finally {
|
||||||
|
reloadBtn.disabled = false;
|
||||||
|
reloadBtn.textContent = alt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const importBtn = document.getElementById("importPlugin");
|
||||||
|
const fileInput = document.getElementById("pluginFile");
|
||||||
|
importBtn.addEventListener("click", () => fileInput.click());
|
||||||
|
|
||||||
|
async function sendePlugin(datei, { nurPruefen = false, force = false } = {}) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", datei);
|
||||||
|
if (nurPruefen) form.append("pruefen_only", "true");
|
||||||
|
if (force) form.append("force", "true");
|
||||||
|
const r = await fetch("/api/plugins/import", { method: "POST", body: form });
|
||||||
|
let daten = {};
|
||||||
|
try { daten = await r.json(); } catch (_) {}
|
||||||
|
if (!r.ok) throw new Error(daten.detail || `HTTP ${r.status}`);
|
||||||
|
return daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", async () => {
|
||||||
|
const datei = fileInput.files && fileInput.files[0];
|
||||||
|
fileInput.value = ""; // erneutes Wählen derselben Datei
|
||||||
|
if (!datei) return;
|
||||||
|
|
||||||
|
const alt = importBtn.textContent;
|
||||||
|
importBtn.disabled = true;
|
||||||
|
importBtn.textContent = "⤓ prüft …";
|
||||||
|
try {
|
||||||
|
// Erst schauen, was passieren würde — geschrieben wird noch nichts.
|
||||||
|
const { vorschau: v } = await sendePlugin(datei, { nurPruefen: true });
|
||||||
|
let force = false;
|
||||||
|
if (v.bestaetigung_noetig) {
|
||||||
|
const frage = `${v.warnung}\n\n` +
|
||||||
|
`Plugin: ${v.name} (${v.id})\n` +
|
||||||
|
`Installiert: ${v.version_alt} → Archiv: ${v.version}\n\n` +
|
||||||
|
`Fortfahren? Dateien werden ergänzt und überschrieben, nichts ` +
|
||||||
|
`gelöscht. Der Datenordner bleibt unberührt.`;
|
||||||
|
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
|
||||||
|
force = true;
|
||||||
|
} else if (v.art === "aktualisierung") {
|
||||||
|
const frage = `${v.name} (${v.id})\n` +
|
||||||
|
`Aktualisierung ${v.version_alt} → ${v.version}\n\n` +
|
||||||
|
`Der bisherige Stand wird gesichert, Daten bleiben erhalten.`;
|
||||||
|
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
importBtn.textContent = "⤓ lädt hoch …";
|
||||||
|
const b = await sendePlugin(datei, { force });
|
||||||
|
await render();
|
||||||
|
|
||||||
|
if (b.aktiv) {
|
||||||
|
const wort = b.art === "neu" ? "installiert"
|
||||||
|
: b.art === "aelter" ? "zurückgesetzt" : "aktualisiert";
|
||||||
|
BT.toast(`${b.name} ${b.version} ${wort} · ${b.geschrieben} Dateien`, "ok");
|
||||||
|
} else {
|
||||||
|
BT.toast(`${b.name} geschrieben, lädt aber nicht: ${b.ladefehler || "?"}`, "err");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
BT.toast("Import fehlgeschlagen: " + err.message, "err");
|
||||||
|
} finally {
|
||||||
|
importBtn.disabled = false;
|
||||||
|
importBtn.textContent = alt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>boehmitools · Läufe</title>
|
||||||
|
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||||
|
</head>
|
||||||
|
<body data-bt-title="Läufe" data-bt-icon="↻">
|
||||||
|
<main class="bt-main">
|
||||||
|
<div class="bt-pagehead">
|
||||||
|
<h1>Läufe</h1>
|
||||||
|
<p>Alle Ausführungen der Kommandozeilen-Tools dieser Sitzung.</p>
|
||||||
|
</div>
|
||||||
|
<div class="bt-card bt-card-flush">
|
||||||
|
<table class="bt-table">
|
||||||
|
<thead><tr><th>Tool</th><th>Aufgabe</th><th>Status</th><th>Dauer</th><th></th></tr></thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="empty" class="bt-empty bt-hidden">Noch keine Läufe in dieser Sitzung.</div>
|
||||||
|
</main>
|
||||||
|
<script src="/shared/boehmi.js"></script>
|
||||||
|
<script>
|
||||||
|
const badge = (s) => ({
|
||||||
|
running: '<span class="bt-badge info"><span class="bt-dot"></span>läuft</span>',
|
||||||
|
done: '<span class="bt-badge ok">erfolgreich</span>',
|
||||||
|
failed: '<span class="bt-badge err">fehlgeschlagen</span>',
|
||||||
|
cancelled: '<span class="bt-badge warn">abgebrochen</span>',
|
||||||
|
}[s] || s);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const { jobs } = await fetch("/api/jobs").then((r) => r.json());
|
||||||
|
document.getElementById("empty").classList.toggle("bt-hidden", jobs.length > 0);
|
||||||
|
document.getElementById("rows").innerHTML = jobs.map((j) => `
|
||||||
|
<tr>
|
||||||
|
<td><b>${BT.escape(j.plugin_name)}</b></td>
|
||||||
|
<td>${BT.escape(j.label)}<div class="bt-muted bt-mono" style="font-size:11px">${BT.escape(j.command)}</div></td>
|
||||||
|
<td>${badge(j.status)}</td>
|
||||||
|
<td class="bt-muted">${j.duration}s</td>
|
||||||
|
<td><a class="bt-btn mini" href="/plugins/${BT.escape(j.plugin)}/?job=${BT.escape(j.id)}">Protokoll</a></td>
|
||||||
|
</tr>`).join("");
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
setInterval(load, 4000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>boehmitools · Einstellungen</title>
|
||||||
|
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||||
|
<style>
|
||||||
|
.field { margin-bottom: 12px; }
|
||||||
|
.field .help { font-size: 11.5px; color: var(--bt-muted); margin-top: 4px; }
|
||||||
|
.sticky-actions {
|
||||||
|
position: sticky; bottom: 0; margin-top: 18px; padding: 12px 0;
|
||||||
|
background: linear-gradient(transparent, var(--bt-bg) 40%);
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-bt-title="Einstellungen" data-bt-icon="⚙">
|
||||||
|
|
||||||
|
<main class="bt-main narrow">
|
||||||
|
<div class="bt-pagehead">
|
||||||
|
<h1>Einstellungen</h1>
|
||||||
|
<p>Diese Zugänge gelten für alle Tools der Suite. Sie liegen in
|
||||||
|
<code>data/settings.json</code> und werden den Tools als Umgebungsvariablen
|
||||||
|
übergeben — genau so, wie sie es als Einzeltool erwarten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bt-notice info">
|
||||||
|
Tokens und Schlüssel bleiben im lokalen Backend. Bereits gespeicherte
|
||||||
|
Geheimnisse werden hier nur maskiert angezeigt; ein leeres Feld lässt den
|
||||||
|
bestehenden Wert unverändert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="groups"></div>
|
||||||
|
|
||||||
|
<div class="sticky-actions">
|
||||||
|
<button class="primary" id="save">Speichern</button>
|
||||||
|
<span id="state" class="bt-muted"></span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/shared/boehmi.js"></script>
|
||||||
|
<script>
|
||||||
|
const groupsEl = document.getElementById("groups");
|
||||||
|
const stateEl = document.getElementById("state");
|
||||||
|
let schema = [];
|
||||||
|
|
||||||
|
function fieldHtml(f, value) {
|
||||||
|
const id = "f_" + f.key;
|
||||||
|
let control;
|
||||||
|
if (f.kind === "bool") {
|
||||||
|
control = `<label class="bt-check"><input type="checkbox" id="${id}"
|
||||||
|
${String(value).toLowerCase() === "true" ? "checked" : ""}> aktiv</label>`;
|
||||||
|
} else if (f.kind === "select") {
|
||||||
|
control = `<select id="${id}">${f.options.map((o) =>
|
||||||
|
`<option ${o === value ? "selected" : ""}>${BT.escape(o)}</option>`).join("")}</select>`;
|
||||||
|
} else if (f.kind === "secret") {
|
||||||
|
control = `<input type="password" id="${id}" value="${BT.escape(value)}"
|
||||||
|
placeholder="${value ? "unverändert" : "nicht gesetzt"}" autocomplete="new-password">`;
|
||||||
|
} else {
|
||||||
|
control = `<input type="${f.kind === "number" ? "number" : "text"}" id="${id}"
|
||||||
|
value="${BT.escape(value)}" placeholder="${BT.escape(f.placeholder || f.default || "")}">`;
|
||||||
|
}
|
||||||
|
return `<div class="field">
|
||||||
|
<label for="${id}">${BT.escape(f.label)}</label>
|
||||||
|
${control}
|
||||||
|
${f.help ? `<div class="help">${BT.escape(f.help)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await fetch("/api/settings").then((r) => r.json());
|
||||||
|
schema = data.schema;
|
||||||
|
groupsEl.innerHTML = schema.map((g) => `
|
||||||
|
<section class="bt-card">
|
||||||
|
<h2>${BT.escape(g.group)}</h2>
|
||||||
|
<p class="hint">${g.group === "Tandoor"
|
||||||
|
? "Wird vom AI-Webimport, vom Rezeptimport und vom Vereinheitlichen genutzt."
|
||||||
|
: g.group === "OpenAI" ? "Nur für den AI-Webimport nötig."
|
||||||
|
: "Schutz beim Abruf externer Rezeptseiten."}</p>
|
||||||
|
${g.fields.map((f) => fieldHtml(f, data.values[f.key] ?? "")).join("")}
|
||||||
|
</section>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("save").addEventListener("click", async () => {
|
||||||
|
const payload = {};
|
||||||
|
schema.forEach((g) => g.fields.forEach((f) => {
|
||||||
|
const el = document.getElementById("f_" + f.key);
|
||||||
|
if (!el) return;
|
||||||
|
payload[f.key] = f.kind === "bool" ? String(el.checked) : el.value;
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
stateEl.textContent = "Speichern …";
|
||||||
|
await BT.api("/api/settings", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
stateEl.textContent = "";
|
||||||
|
BT.toast("Einstellungen gespeichert.", "ok");
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
stateEl.textContent = "";
|
||||||
|
BT.toast(e.message, "err");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+237
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Startet die boehmitools-Suite.
|
||||||
|
|
||||||
|
python run.py # Standard-Port 8080
|
||||||
|
PORT=9090 python run.py # anderer Port
|
||||||
|
|
||||||
|
Für die Entwicklung mit Neuladen:
|
||||||
|
uvicorn core.app:app --reload
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(BASE_DIR))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
port = int(os.environ.get("PORT", "8080"))
|
||||||
|
host = os.environ.get("HOST", "0.0.0.0")
|
||||||
|
print("=" * 64)
|
||||||
|
print(" boehmitools")
|
||||||
|
print(f" Im Browser öffnen: http://<server-ip>:{port}")
|
||||||
|
print("=" * 64)
|
||||||
|
uvicorn.run("core.app:app", host=host, port=port, log_level="info")
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* Gemeinsamer Baustein „Sicherungen“.
|
||||||
|
*
|
||||||
|
* Jedes schreibende Plugin bekommt damit dieselbe Ansicht: welche Läufe gab
|
||||||
|
* es, was steckt drin, wie weit lässt sich das zurückdrehen. Der Text zur
|
||||||
|
* Rückspielbarkeit kommt aus dem Manifest — die Oberfläche behauptet also
|
||||||
|
* nichts, was das Werkzeug nicht auch kann.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const LEVEL = {
|
||||||
|
voll: { css: "ok", text: "vollständig rückspielbar" },
|
||||||
|
neue_id: { css: "info", text: "rückspielbar, neue ID" },
|
||||||
|
teilweise: { css: "warn", text: "teilweise rückspielbar" },
|
||||||
|
nein: { css: "err", text: "nicht rückspielbar" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderRuns(runs, selected) {
|
||||||
|
if (!runs.length) {
|
||||||
|
return `<div class="bt-empty" style="border:none">
|
||||||
|
Noch keine Sicherungen. Es entsteht bei jedem Lauf eine, der wirklich
|
||||||
|
etwas schreibt — Trockenübungen zählen nicht.</div>`;
|
||||||
|
}
|
||||||
|
return runs.map((r) => {
|
||||||
|
const lv = LEVEL[r.restore_level] || LEVEL.nein;
|
||||||
|
const when = r.created_at ? new Date(r.created_at) : null;
|
||||||
|
return `
|
||||||
|
<label class="bt-run ${r.id === selected ? "sel" : ""}">
|
||||||
|
<input type="radio" name="btrun" value="${BT.escape(r.id)}"
|
||||||
|
${r.id === selected ? "checked" : ""}>
|
||||||
|
<span class="bt-run-main">
|
||||||
|
<b>${BT.escape(r.label || r.id)}</b>
|
||||||
|
<span class="bt-run-sub">${when && !isNaN(when) ? when.toLocaleString("de-DE") : BT.escape(r.id)}
|
||||||
|
· ${r.done} von ${r.steps} Schritten geschrieben</span>
|
||||||
|
</span>
|
||||||
|
<span class="bt-run-tags">
|
||||||
|
<span class="bt-badge ${lv.css}">${lv.text}</span>
|
||||||
|
${r.restored ? `<span class="bt-badge">bereits zurückgespielt</span>` : ""}
|
||||||
|
</span>
|
||||||
|
</label>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(detail) {
|
||||||
|
const steps = (detail.manifest?.steps || []).filter((s) => s.status === "done");
|
||||||
|
if (!steps.length) return `<p class="hint">In diesem Lauf wurde nichts geschrieben.</p>`;
|
||||||
|
return `<div class="bt-steps">${steps.map((s) => {
|
||||||
|
const lv = LEVEL[s.restore_level] || LEVEL.nein;
|
||||||
|
let was = "";
|
||||||
|
if (s.action === "properties") {
|
||||||
|
was = `${Object.keys(s.written || {}).length} Werte gesetzt`;
|
||||||
|
} else if (s.action === "merge") {
|
||||||
|
was = `zusammengeführt auf „${BT.escape(s.target_name)}“`
|
||||||
|
+ (s.references?.length ? ` · ${s.references.length} Verweise aufgezeichnet` : "")
|
||||||
|
+ (s.rescued?.length ? ` · ${s.rescued.length} Werte gerettet` : "");
|
||||||
|
} else if (s.action === "delete") {
|
||||||
|
was = "gelöscht";
|
||||||
|
} else if (s.action === "recipe") {
|
||||||
|
was = `${s.step_count} Schritte gesichert`;
|
||||||
|
}
|
||||||
|
return `<div class="bt-step">
|
||||||
|
<span class="bt-badge ${lv.css}">${lv.text}</span>
|
||||||
|
<span><b>${BT.escape(s.food_name || s.source_name || "?")}</b>
|
||||||
|
<span class="bt-muted"> — ${was}</span></span>
|
||||||
|
</div>`;
|
||||||
|
}).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut den Reiter. `mount` ist das Zielelement, `runner` ein BT.Runner,
|
||||||
|
* der auf /api/run/restore zeigt.
|
||||||
|
*/
|
||||||
|
BT.Backups = function (options) {
|
||||||
|
const mount = options.mount;
|
||||||
|
const runner = options.runner;
|
||||||
|
let runs = [];
|
||||||
|
let selected = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await BT.api("/api/backups");
|
||||||
|
runs = data.runs || [];
|
||||||
|
if (!runs.some((r) => r.id === selected)) selected = runs[0]?.id || null;
|
||||||
|
draw(data.dir);
|
||||||
|
if (selected) await detail();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail() {
|
||||||
|
if (!selected || !options.detail) { paintDetail(""); return; }
|
||||||
|
try {
|
||||||
|
paintDetail(renderDetail(await BT.api(`/api/backups/${encodeURIComponent(selected)}`)));
|
||||||
|
} catch { paintDetail(""); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintDetail(html) {
|
||||||
|
const box = mount.querySelector("#btDetail");
|
||||||
|
if (box) box.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(dir) {
|
||||||
|
const current = runs.find((r) => r.id === selected);
|
||||||
|
const lv = LEVEL[current?.restore_level] || LEVEL.nein;
|
||||||
|
mount.innerHTML = `
|
||||||
|
<div class="bt-card">
|
||||||
|
<div class="bt-row" style="align-items:center;margin-bottom:8px">
|
||||||
|
<h2 style="margin:0;flex:1">Sicherungen</h2>
|
||||||
|
<button class="mini ghost" id="btReload">Neu laden</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Jeder Lauf, der wirklich geschrieben hat, liegt unter
|
||||||
|
<code>${BT.escape(dir || "")}</code>. Der Zustand vorher ist dort
|
||||||
|
vollständig festgehalten.</p>
|
||||||
|
<div class="bt-runs">${renderRuns(runs, selected)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${runs.length ? `
|
||||||
|
<div class="bt-card">
|
||||||
|
<h2>Was passierte in diesem Lauf</h2>
|
||||||
|
<div id="btDetail"><p class="hint">wird geladen …</p></div>
|
||||||
|
|
||||||
|
<div class="bt-notice ${lv.css === "ok" ? "" : "warn"}" style="margin-top:12px">
|
||||||
|
${options.note || ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bt-row" style="margin-top:12px;align-items:center">
|
||||||
|
<button id="btDry">Trockenübung</button>
|
||||||
|
<button class="danger" id="btApply">Zurückspielen</button>
|
||||||
|
<span class="bt-spacer"></span>
|
||||||
|
<label class="bt-check">
|
||||||
|
<input type="checkbox" id="btForce">
|
||||||
|
auch wenn seither von Hand geändert wurde
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="btStatus" class="bt-status">Bereit.</div>
|
||||||
|
<div id="btLog" class="bt-log"></div>
|
||||||
|
</div>` : ""}`;
|
||||||
|
|
||||||
|
mount.querySelectorAll('input[name="btrun"]').forEach((el) =>
|
||||||
|
el.addEventListener("change", () => { selected = el.value; draw(dir); detail(); }));
|
||||||
|
const byId = (id) => mount.querySelector("#" + id);
|
||||||
|
byId("btReload")?.addEventListener("click", load);
|
||||||
|
byId("btDry")?.addEventListener("click", () => go(false));
|
||||||
|
byId("btApply")?.addEventListener("click", () => go(true));
|
||||||
|
|
||||||
|
if (runner) runner.rebind({ log: byId("btLog"), status: byId("btStatus") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(apply) {
|
||||||
|
const current = runs.find((r) => r.id === selected);
|
||||||
|
if (!current) return;
|
||||||
|
if (apply && !confirm(
|
||||||
|
`Lauf „${current.label || current.id}“ zurückspielen?\n\n` +
|
||||||
|
`${current.done} Schritte · ${(LEVEL[current.restore_level] || LEVEL.nein).text}\n\n` +
|
||||||
|
(current.restored ? "Achtung: Dieser Lauf wurde bereits zurückgespielt.\n\n" : "") +
|
||||||
|
`Erst die Trockenübung ansehen, wenn du unsicher bist.`)) return;
|
||||||
|
runner.start({ run: selected, apply, force: mount.querySelector("#btForce")?.checked || false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, reload: load };
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
boehmitools · Runner
|
||||||
|
Gemeinsame Oberflächenlogik für Plugins, die ein Kommandozeilen-Tool
|
||||||
|
kapseln: Lauf starten, Ausgabe live streamen, Status anzeigen, abbrechen.
|
||||||
|
|
||||||
|
const runner = BT.Runner({ log: el, status: el, onFinish: fn });
|
||||||
|
runner.start({ apply: false }); // POST <mount>/api/run
|
||||||
|
runner.attach(jobId); // an laufenden Lauf andocken
|
||||||
|
|
||||||
|
Setzt boehmi.js voraus.
|
||||||
|
========================================================================== */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const LABEL = {
|
||||||
|
running: ["busy", "läuft …"],
|
||||||
|
done: ["ok", "erfolgreich abgeschlossen"],
|
||||||
|
failed: ["error", "fehlgeschlagen"],
|
||||||
|
cancelled: ["warn", "abgebrochen"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function classify(entry) {
|
||||||
|
if (entry.stream === "meta") return "l-meta";
|
||||||
|
if (entry.stream === "stderr") return "l-err";
|
||||||
|
const t = entry.text;
|
||||||
|
if (/^(FEHLER|Abbruch|Fehlgeschlagen)|✗|Traceback/i.test(t)) return "l-err";
|
||||||
|
if (/^(Warnung|Hinweis)|⚠/i.test(t)) return "l-warn";
|
||||||
|
if (/(erfolgreich|✓|OK\b|angelegt|aktualisiert)/i.test(t)) return "l-ok";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Runner(cfg) {
|
||||||
|
// Nicht const: Oberflächen, die ihre Karten neu zeichnen, hängen den
|
||||||
|
// Runner per rebind() wieder an die frischen Elemente.
|
||||||
|
let logEl = cfg.log;
|
||||||
|
let statusEl = cfg.status;
|
||||||
|
// Plugins mit mehreren Läufen (prüfen / vorschlagen / übernehmen) geben
|
||||||
|
// ihren eigenen Endpunkt an. Vorgabe bleibt der bisherige.
|
||||||
|
const endpoint = cfg.endpoint || "/api/run";
|
||||||
|
let source = null;
|
||||||
|
let job = null;
|
||||||
|
|
||||||
|
function setStatus(kind, text, extra) {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.className = "bt-status " + (kind || "");
|
||||||
|
statusEl.innerHTML = `<span class="bt-dot"></span><b>${BT.escape(text)}</b>` +
|
||||||
|
(extra ? ` <span class="bt-muted">${BT.escape(extra)}</span>` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (logEl) logEl.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function append(entry) {
|
||||||
|
if (!logEl) return;
|
||||||
|
const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
|
||||||
|
const span = document.createElement("span");
|
||||||
|
const cls = classify(entry);
|
||||||
|
if (cls) span.className = cls;
|
||||||
|
span.textContent = entry.text + "\n";
|
||||||
|
logEl.appendChild(span);
|
||||||
|
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (source) { source.close(); source = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(id) {
|
||||||
|
close();
|
||||||
|
source = new EventSource(BT.url(`/api/jobs/${id}/events`));
|
||||||
|
setStatus("busy", "läuft …");
|
||||||
|
source.addEventListener("line", (e) => append(JSON.parse(e.data)));
|
||||||
|
source.addEventListener("end", (e) => {
|
||||||
|
const info = JSON.parse(e.data);
|
||||||
|
job = info;
|
||||||
|
const [kind, text] = LABEL[info.status] || ["", info.status];
|
||||||
|
setStatus(kind, text, `${info.duration}s · Exit-Code ${info.returncode}`);
|
||||||
|
close();
|
||||||
|
if (cfg.onFinish) cfg.onFinish(info);
|
||||||
|
});
|
||||||
|
source.onerror = () => {
|
||||||
|
if (source && source.readyState === EventSource.CLOSED) {
|
||||||
|
close();
|
||||||
|
setStatus("warn", "Verbindung zum Protokoll unterbrochen");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(body) {
|
||||||
|
clear();
|
||||||
|
setStatus("busy", "wird gestartet …");
|
||||||
|
try {
|
||||||
|
job = await BT.api(endpoint, { method: "POST", body: JSON.stringify(body || {}) });
|
||||||
|
attach(job.id);
|
||||||
|
if (cfg.onStart) cfg.onStart(job);
|
||||||
|
return job;
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("error", "Start fehlgeschlagen", err.message);
|
||||||
|
BT.toast(err.message, "err");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
if (!job) return;
|
||||||
|
try {
|
||||||
|
await BT.api(`/api/jobs/${job.id}/cancel`, { method: "POST" });
|
||||||
|
BT.toast("Abbruch angefordert.");
|
||||||
|
} catch (err) {
|
||||||
|
BT.toast(err.message, "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebind(next) {
|
||||||
|
if (next.log) logEl = next.log;
|
||||||
|
if (next.status) statusEl = next.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start, attach, cancel, clear, setStatus, rebind,
|
||||||
|
get job() { return job; },
|
||||||
|
get busy() { return Boolean(source); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.BT.Runner = Runner;
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
boehmitools · gemeinsame Shell
|
||||||
|
Stellt App-Bar, Tool-Umschalter, Theme und kleine Helfer für alle Plugins.
|
||||||
|
|
||||||
|
Einbindung in einem Plugin (siehe plugins/README.md):
|
||||||
|
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||||
|
<script src="/shared/boehmi.js" defer></script>
|
||||||
|
<body data-bt-title="Mein Tool" data-bt-icon="🔧"> ... </body>
|
||||||
|
|
||||||
|
Der Mount-Pfad wird automatisch aus der URL abgeleitet, ein Plugin muss
|
||||||
|
ihn nicht kennen. API-Aufrufe laufen über BT.api("/api/...").
|
||||||
|
========================================================================== */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const ROOT_META = document.querySelector('meta[name="bt-root"]');
|
||||||
|
const pathMatch = location.pathname.match(/^(.*?)(\/plugins\/[^/]+)/);
|
||||||
|
|
||||||
|
const BT = {
|
||||||
|
/** Präfix der gesamten Suite (nur bei Reverse-Proxy-Unterpfad gesetzt). */
|
||||||
|
root: ROOT_META ? ROOT_META.content.replace(/\/$/, "") : (pathMatch ? pathMatch[1] : ""),
|
||||||
|
/** Mount-Präfix des aktuellen Plugins, z. B. "/plugins/trainingsplan". */
|
||||||
|
base: pathMatch ? pathMatch[1] + pathMatch[2] : "",
|
||||||
|
/** ID des aktuellen Plugins oder null (Dashboard). */
|
||||||
|
plugin: pathMatch ? pathMatch[2].split("/").pop() : null,
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
window.BT = BT;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Helfer */
|
||||||
|
|
||||||
|
/** Plugin-eigener Pfad → absolute URL. */
|
||||||
|
BT.url = (path) => BT.base + path;
|
||||||
|
/** Suite-weiter Pfad → absolute URL. */
|
||||||
|
BT.rootUrl = (path) => BT.root + path;
|
||||||
|
|
||||||
|
/** fetch mit JSON-Handling, relativ zum Plugin-Mount. */
|
||||||
|
BT.api = async function (path, options = {}) {
|
||||||
|
const opts = { ...options };
|
||||||
|
if (opts.body !== undefined && !(opts.body instanceof FormData)) {
|
||||||
|
opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
|
||||||
|
}
|
||||||
|
const res = await fetch(BT.url(path), opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = {};
|
||||||
|
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
|
||||||
|
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
BT.toast = function (message, kind = "") {
|
||||||
|
let host = document.getElementById("bt-toaster");
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement("div");
|
||||||
|
host.id = "bt-toaster";
|
||||||
|
document.body.appendChild(host);
|
||||||
|
}
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "bt-toast " + kind;
|
||||||
|
el.textContent = message;
|
||||||
|
host.appendChild(el);
|
||||||
|
requestAnimationFrame(() => el.classList.add("show"));
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.remove("show");
|
||||||
|
setTimeout(() => el.remove(), 250);
|
||||||
|
}, kind === "err" ? 5200 : 2600);
|
||||||
|
};
|
||||||
|
|
||||||
|
BT.escape = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => (
|
||||||
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||||||
|
));
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Theme */
|
||||||
|
|
||||||
|
const THEME_KEY = "boehmitools.theme";
|
||||||
|
|
||||||
|
BT.setTheme = function (mode) {
|
||||||
|
const resolved = mode === "auto"
|
||||||
|
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||||
|
: mode;
|
||||||
|
document.documentElement.setAttribute("data-bt-theme", resolved);
|
||||||
|
try { localStorage.setItem(THEME_KEY, mode); } catch (_) {}
|
||||||
|
document.querySelectorAll("[data-bt-theme-toggle]").forEach((b) => {
|
||||||
|
b.textContent = resolved === "dark" ? "☀︎" : "☾";
|
||||||
|
b.title = resolved === "dark" ? "Helles Design" : "Dunkles Design";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
BT.currentTheme = function () {
|
||||||
|
try { return localStorage.getItem(THEME_KEY) || "auto"; } catch (_) { return "auto"; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// so früh wie möglich anwenden (verhindert Aufblitzen)
|
||||||
|
BT.setTheme(BT.currentTheme());
|
||||||
|
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||||
|
if (BT.currentTheme() === "auto") BT.setTheme("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ App-Bar */
|
||||||
|
|
||||||
|
function onBody(fn) {
|
||||||
|
if (document.body) fn();
|
||||||
|
else document.addEventListener("DOMContentLoaded", fn, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAppbar(plugins) {
|
||||||
|
const me = plugins.find((p) => p.id === BT.plugin);
|
||||||
|
const title = document.body.dataset.btTitle || (me ? me.name : "Dashboard");
|
||||||
|
const icon = document.body.dataset.btIcon || (me ? me.icon : "▦");
|
||||||
|
|
||||||
|
const bar = document.createElement("header");
|
||||||
|
bar.className = "bt-appbar";
|
||||||
|
bar.innerHTML = `
|
||||||
|
<a class="bt-brand" href="${BT.rootUrl("/")}" title="Zum Dashboard">
|
||||||
|
<span class="bt-brand-mark">B</span>
|
||||||
|
<span>boehmitools</span>
|
||||||
|
</a>
|
||||||
|
${BT.plugin ? `
|
||||||
|
<span class="bt-appbar-sep"></span>
|
||||||
|
<span class="bt-crumb"><span class="bt-crumb-ico">${BT.escape(icon)}</span>
|
||||||
|
<span>${BT.escape(title)}</span></span>` : ""}
|
||||||
|
<span class="bt-appbar-spacer"></span>
|
||||||
|
<span class="bt-appbar-actions">
|
||||||
|
<span class="bt-switch">
|
||||||
|
<button class="mini ghost" id="bt-switch-btn" aria-haspopup="true" aria-expanded="false">
|
||||||
|
Tools ▾
|
||||||
|
</button>
|
||||||
|
<div class="bt-switch-menu" id="bt-switch-menu" role="menu">
|
||||||
|
<div class="bt-switch-head">Tools</div>
|
||||||
|
${plugins.map((p) => `
|
||||||
|
<a class="bt-switch-item ${p.id === BT.plugin ? "active" : ""}" href="${BT.rootUrl(p.mount)}/">
|
||||||
|
<span class="ico">${BT.escape(p.icon)}</span>
|
||||||
|
<span><b>${BT.escape(p.name)}</b><i>${BT.escape(p.summary || "")}</i></span>
|
||||||
|
</a>`).join("")}
|
||||||
|
<div class="bt-switch-head">Suite</div>
|
||||||
|
<a class="bt-switch-item" href="${BT.rootUrl("/")}"><span class="ico">▦</span>
|
||||||
|
<span><b>Dashboard</b><i>Übersicht aller Tools</i></span></a>
|
||||||
|
<a class="bt-switch-item" href="${BT.rootUrl("/jobs")}"><span class="ico">↻</span>
|
||||||
|
<span><b>Läufe</b><i>Protokolle der Kommandozeilen-Tools</i></span></a>
|
||||||
|
<a class="bt-switch-item" href="${BT.rootUrl("/settings")}"><span class="ico">⚙</span>
|
||||||
|
<span><b>Einstellungen</b><i>Tandoor- und OpenAI-Zugänge</i></span></a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<button class="mini ghost icon" data-bt-theme-toggle title="Design wechseln">☾</button>
|
||||||
|
</span>`;
|
||||||
|
|
||||||
|
const anchor = document.getElementById("bt-appbar") || document.body.firstChild;
|
||||||
|
if (anchor && anchor.id === "bt-appbar") anchor.replaceWith(bar);
|
||||||
|
else document.body.insertBefore(bar, document.body.firstChild);
|
||||||
|
|
||||||
|
const btn = document.getElementById("bt-switch-btn");
|
||||||
|
const menu = document.getElementById("bt-switch-menu");
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = menu.classList.toggle("open");
|
||||||
|
btn.setAttribute("aria-expanded", String(open));
|
||||||
|
});
|
||||||
|
document.addEventListener("click", () => menu.classList.remove("open"));
|
||||||
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") menu.classList.remove("open"); });
|
||||||
|
|
||||||
|
bar.querySelector("[data-bt-theme-toggle]").addEventListener("click", () => {
|
||||||
|
const now = document.documentElement.getAttribute("data-bt-theme");
|
||||||
|
BT.setTheme(now === "dark" ? "light" : "dark");
|
||||||
|
});
|
||||||
|
BT.setTheme(BT.currentTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
BT.ready = fetch(BT.rootUrl("/api/plugins"))
|
||||||
|
.then((r) => (r.ok ? r.json() : { plugins: [] }))
|
||||||
|
.catch(() => ({ plugins: [] }))
|
||||||
|
.then((d) => new Promise((resolve) => {
|
||||||
|
BT.plugins = d.plugins || [];
|
||||||
|
onBody(() => {
|
||||||
|
if (document.body.dataset.btShell !== "off") buildAppbar(BT.plugins);
|
||||||
|
document.dispatchEvent(new CustomEvent("bt:ready", { detail: BT }));
|
||||||
|
resolve(BT);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user