chore: initial import
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user