chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
# Stammdaten aufräumen
Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet
Karteileichen.
## Bitte einmal lesen, bevor du es benutzt
Tandoors `merge` hängt alle Verweise der Quelle auf das Ziel um und **löscht
die Quelle**. Das ist nicht umkehrbar. Im Tandoor-Quelltext steht dazu:
```python
# TODO these checks could be improved to merge existing properties and
# conversion in a smart way. For now it will just loose them to prevent
# duplicates
if isinstance(source, Food):
source.properties.all().delete()
```
Die Nährwerte des Quelleintrags gehen also verloren. Dieses Werkzeug rettet
sie deshalb vorher: Werte, die nur die Quelle hat, werden zuerst ans Ziel
geschrieben. Abschaltbar über das Häkchen bzw. `--ohne-rettung`.
## Was vorgeschlagen wird — und was nicht
**Sichere Dubletten** (vorausgewählt) bei praktisch sicheren Fällen:
* gleicher Name (`Zwiebel` / `zwiebel`, `Olivenöl` / `Olivenoel`)
* Singular/Plural (`Zwiebel` / `Zwiebeln`, `Tomate` / `Tomaten`)
**Ähnlich** — anhakbar, aber nie vorausgewählt und mit Pflicht zur
Richtungswahl:
* echte Wort-Teilmengen (`Mehl` / `Mehl Type 550`, `Olivenöl` /
`Olivenöl nativ extra`) — jedes Wort der kürzeren Seite kommt als
ganzes Wort in der längeren vor
Der Unterschied ist Absicht. `Rote Zwiebel` sieht `Zwiebel` sehr ähnlich, ist
aber etwas anderes — deshalb entscheidet hier der Mensch, ob und in welche
Richtung zusammengeführt wird, statt dass etwas vorausgewählt ist.
**Bekannte Lücke:** unregelmäßige Plurale mit Umlaut (`Ei` / `Eier`,
`Apfel` / `Äpfel`) erkennt die Heuristik nicht. Die tauchen in keiner der
beiden Listen auf.
**Löschen** wird grundsätzlich nicht vorausgewählt — manches hebt man
absichtlich auf.
## Welcher Eintrag bleibt
Ziel ist der Eintrag, der am meisten zu verlieren hätte, in dieser Reihenfolge:
die meisten Rezepte → die meisten Nährwerte → gepflegter Plural →
Supermarkt-Kategorie → kürzerer Name.
## Ablauf
1. **Prüfen** — schreibt eine Plandatei. Verändert nichts.
2. Plan durchsehen, Häkchen setzen.
3. **Trockenübung** — zeigt Zeile für Zeile, was passieren würde.
4. **Ausführen** — erst dann wird es ernst. Vor jedem Schritt wird der
Zustand der betroffenen Einträge gesichert.
Zusammengeführt wird vor dem Löschen: eine Zusammenführung kann einen Eintrag
erst unbenutzt machen.
## Ohne Suite
export TANDOOR_URL=https://kitchen.example.de
export TANDOOR_TOKEN=...
python3 plugins/tandoor-cleanup/tool/cleanup.py pruefen
python3 plugins/tandoor-cleanup/tool/cleanup.py pruefen --arten food
python3 plugins/tandoor-cleanup/tool/cleanup.py ausfuehren \
--plan data/tandoor-cleanup/plaene/<datei>.json # Trockenübung
python3 plugins/tandoor-cleanup/tool/cleanup.py ausfuehren \
--plan data/tandoor-cleanup/plaene/<datei>.json --apply # ernst
## Dateien
data/tandoor-cleanup/plaene/<zeit>.json Plan
data/tandoor-cleanup/laeufe/<zeit>/ Sicherungen + ausgeführter Plan
## Bessere Ähnlich-Erkennung (Wort-Teilmenge statt Zeichenkette)
Früher galt schon eine bloße Zeichenketten-Enthaltung als „Teilwort“. Das
erzeugte Fehlpaare wie `Mango` ~ `Mangold`, `Buchweizen` ~ `Buchweizenmehl`
oder `getrocknete Tomate` ~ `getrocknete Tomaten in Öl`. Jetzt zählt nur eine
echte **Wort-Teilmenge**: jedes Wort der kürzeren Seite muss als *ganzes* Wort
in der längeren vorkommen. Dadurch verschwinden diese Fehlpaare, während echte
Untertypen (`Mehl` / `Mehl Type 550`) erhalten bleiben.
## Plurale pflegen (Zutaten und Einheiten)
Eigener Abschnitt „Plurale“ je Art — für **Zutaten und Einheiten**. Er zeigt
alle Einträge mit ihrem aktuellen Plural in einem editierbaren Feld; fehlende
stehen oben und sind hervorgehoben. Ein Filterfeld macht große Listen
bedienbar. Bearbeiten hakt die Zeile automatisch an. Geschrieben wird nur, was
sich gegenüber dem Ist-Stand geändert hat — Unverändertes wird übersprungen.
Setzen und Ändern sind nicht destruktiv und **voll rückspielbar**.
## KI-Vorauswahl für ähnliche Paare (ChatGPT)
Das Finden der Paare bleibt die Heuristik. Ist beim Prüfen „Ähnliche Paare per
KI vorfiltern“ aktiv (Vorgabe an, sobald ein `OPENAI_API_KEY` hinterlegt ist),
ordnet ChatGPT jedes gefundene Paar in drei Töpfe:
* **gleich** zweifelsfrei dasselbe (Schreibvarianten, fehlendes Leerzeichen,
Groß/Klein, Singular/Plural). Wird **angehakt** und die Richtung vorgewählt
(der allgemeinere bzw. korrekt geschriebene Name bleibt).
* **unklar** könnte dasselbe sein. Wird angezeigt, aber nicht vorgewählt —
du entscheidest.
* **verschieden** zweifelsfrei verschiedene Sorten/Produkte (Weizenmehl 550
vs. 1050, Frischkäse vs. körniger Frischkäse). Standardmäßig **ausgeblendet**.
Ein Filter über der Liste schaltet zwischen **„Nur KI-Treffer“** (gleich +
unklar) und **„Alle“** (auch die verschiedenen) um.
So sind die eindeutigen Fälle mit einem Blick abgehakt, die strittigen bleiben
sichtbar, und der Lärm ist weg. Nichts wird ausgeführt — du prüfst und startest
selbst; Zusammenführen bleibt gesichert und rückspielbar. Kommandozeile:
`pruefen --ki-filter`. Fällt die KI aus, bleiben alle Paare unverändert stehen.
## Plurale per KI (ChatGPT)
Getrennt davon schlägt ChatGPT auf Wunsch die **Plurale** von Zutaten und
Einheiten vor — auch die Umlaut-Fälle (`Apfel``Äpfel`), die die Heuristik
nicht kann (`vorbelegen --plan <datei>`, Knopf „Plurale vorschlagen“).
Standardmäßig nur dort, wo noch kein Plural gepflegt ist; `--alle-plurale` nimmt
auch bestehende. Einträge, die durch eine angehakte Zusammenführung ohnehin
verschwinden, werden übersprungen. Auch das ist nur ein Vorschlag und voll
rückspielbar. Verbindung prüfen: `probe` bzw. „OpenAI testen“.
## Hinweis: Einheiten-Plurale und Tandoor-Serverfehler
Manche Tandoor-Versionen stürzen beim Ändern einer **Einheit** über einen
schlichten Teil-PATCH mit „HTTP 500“ ab (Zutaten sind nicht betroffen). Das
Werkzeug setzt Einheiten-Plurale deshalb robust: zuerst per vollständigem PUT
(anderer Code-Pfad, alle vorhandenen Felder bleiben erhalten), danach mit
mitgesendetem Namen, zuletzt der schlichte Weg. Lehnt der Server alle Wege ab,
werden die restlichen Einheiten-Plurale übersprungen (mit klarer Meldung) statt
sie einzeln durchzuprobieren — dann bitte in der Tandoor-Oberfläche setzen.
Zutaten-Plurale laufen davon unberührt weiter.
+275
View File
@@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
"""
Plugin-Adapter für den Stammdaten-Aufräumer.
Verändernde Schritte laufen ausschließlich über tool/cleanup.py im Job-Runner,
nie direkt aus einer Anfrage heraus. Was ausgeführt wird, steht vorher in einer
Plandatei — die lässt sich ansehen, ändern und im Zweifel einfach nicht
ausführen.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from core.jobs import job_router
from core.backups import backup_router
TOOL = "cleanup.py"
KINDS = {"food", "unit", "keyword"}
class ScanRequest(BaseModel):
kinds: list[str] = Field(default_factory=list)
ki_filter: bool = False
model: str | None = None
insecure: bool = False
class ExecuteRequest(BaseModel):
plan: str
apply: bool = False
rescue: bool = True
continue_on_error: bool = True
insecure: bool = False
class EditRequest(BaseModel):
"""Nur die Häkchen aus der Oberfläche zurückschreiben."""
accepted: list[dict[str, Any]]
class PrefillRequest(BaseModel):
plan: str
model: str | None = None
alle_plurale: bool = False
class ProbeRequest(BaseModel):
model: str | None = None
def create_app(ctx):
tool = ctx.path("tool", TOOL)
plans_dir = ctx.data_dir / "plaene"
plans_dir.mkdir(parents=True, exist_ok=True)
def tool_env() -> dict:
"""
Umgebung für den Subprozess.
Wichtig: DATA_DIR ausdrücklich mitgeben. Ohne das schreibt das Skript
seinen Rückfallpfad neben die Anwendung — im Container ist der
schreibgeschützt, und die Oberfläche würde den Bericht nie finden.
"""
env = ctx.settings.tool_env()
env["DATA_DIR"] = str(ctx.data_dir)
return env
def resolve_plan(name: str) -> Path:
candidate = plans_dir / Path(name).name
if candidate.suffix != ".json" or not candidate.is_file():
raise HTTPException(404, f"Plandatei „{name}“ gibt es nicht.")
return candidate
def clean_plural(value: Any) -> str:
"""Pluraltext aus der Oberfläche säubern und begrenzen."""
text = str(value or "").strip()
return text[:120]
app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None)
@app.get("/", include_in_schema=False)
def index():
return FileResponse(ctx.path("static", "index.html"))
@app.get("/api/state")
def state() -> dict[str, Any]:
running = [j.info() for j in ctx.jobs.running(ctx.id)]
return {
"plans": sorted((p.name for p in plans_dir.glob("*.json")), reverse=True),
"tandoor": ctx.settings.status()["tandoor"],
"openai": ctx.settings.status()["openai"],
"running": running[0] if running else None,
"data_dir": str(ctx.data_dir),
}
@app.get("/api/plans/{name}")
def read_plan(name: str) -> dict[str, Any]:
return json.loads(resolve_plan(name).read_text(encoding="utf-8"))
@app.post("/api/plans/{name}")
def save_plan(name: str, request: EditRequest) -> dict[str, Any]:
"""
Übernimmt die Auswahl aus der Oberfläche in den Plan.
Bewusst eng: Aktionen werden über ihre feste `aid` angesprochen, und je
Aktionstyp lassen sich nur bestimmte Felder ändern. So kann die
Oberfläche keine beliebige Zusammenführung unterschieben.
Erlaubt ist:
alle accept
ähnlich-Merge source_id/target_id — aber NUR die beiden Paar-Mitglieder,
und set_plural (nur Food)
Dubletten-Merge set_plural (nur Food)
set_plural plural_value (Text)
"""
file = resolve_plan(name)
plan = json.loads(file.read_text(encoding="utf-8"))
by_aid = {a.get("aid"): a for a in plan.get("actions", []) if a.get("aid")}
for wish in request.accepted:
action = by_aid.get(wish.get("aid"))
if action is None:
continue
action["accept"] = bool(wish.get("accept"))
if action["action"] == "merge" and action.get("origin") == "similar":
# Richtung nur auf genau eines der beiden Paar-Mitglieder.
paar = {action.get("left_id"), action.get("right_id")}
src = wish.get("source_id")
tgt = wish.get("target_id")
if src in paar and tgt in paar and src != tgt:
action["source_id"] = src
action["source_name"] = (action["left_name"] if src == action.get("left_id")
else action["right_name"])
action["target_id"] = tgt
action["target_name"] = (action["left_name"] if tgt == action.get("left_id")
else action["right_name"])
elif src is None and tgt is None:
action["source_id"] = action["target_id"] = None
action["source_name"] = action["target_name"] = None
if action["kind"] == "food":
action["set_plural"] = bool(wish.get("set_plural"))
# Der Pluralwert kommt aus dem Scan, je nach Richtung — nicht
# aus beliebiger Eingabe.
if action.get("source_id") == action.get("left_id"):
action["plural_value"] = action.get("plural_left_to_right", "")
else:
action["plural_value"] = action.get("plural_right_to_left", "")
elif action["action"] == "merge" and action["kind"] == "food":
action["set_plural"] = bool(wish.get("set_plural"))
if wish.get("set_plural") and wish.get("plural_value") is not None:
action["plural_value"] = clean_plural(wish.get("plural_value"))
elif action["action"] == "set_plural":
if wish.get("plural_value") is not None:
action["plural_value"] = clean_plural(wish.get("plural_value"))
file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
angehakt = [a for a in plan["actions"] if a.get("accept")]
return {
"ok": True,
"merges": sum(1 for a in angehakt if a["action"] == "merge"),
"set_plurals": sum(1 for a in angehakt if a["action"] == "set_plural"),
"deletes": sum(1 for a in angehakt if a["action"] == "delete"),
}
@app.post("/api/run/scan")
async def run_scan(request: ScanRequest) -> 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 siehe Einstellungen.")
bad = [k for k in request.kinds if k not in KINDS]
if bad:
raise HTTPException(400, f"Unbekannte Art: {bad}")
argv = [sys.executable, str(tool)]
if request.insecure:
argv.append("--insecure")
argv.append("pruefen")
if request.kinds:
argv += ["--arten", ",".join(request.kinds)]
if request.ki_filter and ctx.settings.status()["openai"]:
argv.append("--ki-filter")
if request.model:
argv += ["--model", request.model]
label = "Stammdaten prüfen" + (" · KI-Vorfilter" if request.ki_filter
and ctx.settings.status()["openai"] else "")
job = await ctx.jobs.start(
plugin=ctx.id, label=label, argv=argv,
cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
@app.post("/api/run/execute")
async def run_execute(request: ExecuteRequest) -> 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.")
file = resolve_plan(request.plan)
argv = [sys.executable, str(tool)]
if request.insecure:
argv.append("--insecure")
argv += ["ausfuehren", "--plan", str(file)]
if request.apply:
argv.append("--apply")
if not request.rescue:
argv.append("--ohne-rettung")
if request.continue_on_error:
argv.append("--continue-on-error")
job = await ctx.jobs.start(
plugin=ctx.id,
label=("Aufräumen" if request.apply else "Trockenübung") + f": {file.stem}",
argv=argv, cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
@app.post("/api/run/vorbelegen")
async def run_prefill(request: PrefillRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["openai"]:
raise HTTPException(400, "OpenAI-Key fehlt siehe Einstellungen.")
file = resolve_plan(request.plan)
argv = [sys.executable, str(tool), "vorbelegen", "--plan", str(file)]
if request.model:
argv += ["--model", request.model]
if request.alle_plurale:
argv.append("--alle-plurale")
job = await ctx.jobs.start(
plugin=ctx.id, label=f"KI-Vorauswahl: {file.stem}",
argv=argv, cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
@app.post("/api/run/probe")
async def run_probe(request: ProbeRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["openai"]:
raise HTTPException(400, "OpenAI-Key fehlt siehe Einstellungen.")
argv = [sys.executable, str(tool), "probe"]
if request.model:
argv += ["--model", request.model]
job = await ctx.jobs.start(
plugin=ctx.id, label="OpenAI-Verbindung testen",
argv=argv, cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
def restore_argv(run: str, apply: bool, force: bool) -> list[str]:
argv = [sys.executable, str(tool), "zurueck", "--lauf", run]
if apply:
argv.append("--apply")
if force:
argv.append("--force")
return argv
app.include_router(backup_router(ctx, "laeufe", restore_argv))
app.include_router(job_router(ctx.jobs, ctx.id))
return app
+19
View File
@@ -0,0 +1,19 @@
{
"id": "tandoor-cleanup",
"name": "Stammdaten aufräumen",
"summary": "Dubletten zusammenführen, Karteileichen finden",
"description": "Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet Einträge, die in keinem Rezept mehr vorkommen. Rettet vorher die Nährwerte des Quelleintrags, die Tandoor beim Zusammenführen sonst verwirft.",
"icon": "🧹",
"category": "Tandoor",
"version": "1.0.0",
"entrypoint": "backend:create_app",
"order": 45,
"requires": ["tandoor"],
"features": [
"Erkennt Zwiebel/Zwiebeln, lässt Rote Zwiebel in Ruhe",
"Rettet Nährwerte, die beim Zusammenführen sonst verloren gingen",
"Trockenübung, Einzelfreigabe, Sicherung je Schritt",
"Grenzfälle nur zur Ansicht, nie vorausgewählt"
],
"docs": "TOOL-README.md"
}
+624
View File
@@ -0,0 +1,624 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stammdaten aufräumen</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<script src="/shared/boehmi-runner.js"></script>
<script src="/shared/boehmi-backups.js"></script>
<style>
.facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
.fact { background: var(--bt-surface); border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg); padding: 13px 14px; box-shadow: var(--bt-shadow); }
.fact b { display: block; font-size: 24px; font-weight: 850; letter-spacing: -.03em; }
.fact span { font-size: 10.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--bt-muted); }
.fact.warn b { color: var(--bt-warn); }
.grp { border: 1px solid var(--bt-line); border-radius: var(--bt-r); margin-bottom: 9px; overflow: hidden; }
.grp > header { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center;
padding: 9px 12px; background: var(--bt-surface-2); border-bottom: 1px solid var(--bt-line); }
.tgt { font-size: 13px; font-weight: 700; }
.tgt small { font-weight: 500; color: var(--bt-muted); }
.src { display: grid; grid-template-columns: 24px 1fr auto; gap: 9px; align-items: center;
padding: 8px 12px; border-bottom: 1px solid var(--bt-line-soft); font-size: 12.5px; }
.src:last-child { border-bottom: none; }
.src .arrow { color: var(--bt-muted); font-family: var(--bt-mono); }
.rescue { font-size: 11px; color: var(--bt-warn); background: var(--bt-warn-soft);
padding: 1px 6px; border-radius: 4px; font-weight: 650; }
.pair, .un { display: grid; grid-template-columns: 24px 1fr auto; gap: 9px; align-items: center;
padding: 8px 11px; border-bottom: 1px solid var(--bt-line-soft); font-size: 12.5px; }
.pair:last-child, .un:last-child { border-bottom: none; }
.list { border: 1px solid var(--bt-line); border-radius: var(--bt-r); max-height: 46vh; overflow: auto; }
.idn { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
.score { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
.kindsel { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px; }
.chip { cursor: pointer; user-select: none; padding: 4px 11px; border-radius: 999px;
border: 1px solid var(--bt-line); background: var(--bt-surface); font-size: 12px; font-weight: 650; }
.chip.on { background: var(--bt-accent-soft); border-color: var(--bt-accent); color: var(--bt-accent); }
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } }
/* Ähnliche Paare — jetzt bedienbar */
.simpair { border-bottom: 1px solid var(--bt-line-soft); padding: 9px 11px; }
.simpair:last-child { border-bottom: none; }
.simrow { display: grid; grid-template-columns: 24px 1fr; gap: 9px; align-items: baseline; }
.simnames { font-size: 12.5px; }
.simdir { margin: 6px 0 0 33px; display: flex; flex-direction: column; gap: 3px; }
.simdir label { display: flex; align-items: baseline; gap: 7px; font-size: 12px;
color: var(--bt-ink); cursor: pointer; }
.simdir .idn { color: var(--bt-muted); }
.plural-toggle { display: inline-flex; align-items: baseline; gap: 6px; margin-top: 3px;
font-size: 11.5px; color: var(--bt-accent); }
/* Zutaten ohne Plural */
.plrow { display: grid; grid-template-columns: 24px 1fr 200px; gap: 9px; align-items: center;
padding: 7px 11px; border-bottom: 1px solid var(--bt-line-soft); font-size: 12.5px; }
.plrow:last-child { border-bottom: none; }
.plinput { width: 100%; padding: 5px 8px; font-size: 12.5px; border: 1px solid var(--bt-line);
border-radius: var(--bt-r-sm); background: var(--bt-surface); color: var(--bt-ink); }
.plfilter { width: 100%; box-sizing: border-box; margin-bottom: 10px; padding: 7px 10px;
font-size: 12.5px; border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); color: var(--bt-ink); }
.plmissing { background: color-mix(in srgb, var(--bt-accent) 7%, transparent); }
.plmissing .plinput { border-color: color-mix(in srgb, var(--bt-accent) 45%, var(--bt-line)); }
.aibadge { display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 10.5px;
font-weight: 600; border-radius: 999px; vertical-align: middle;
color: var(--bt-accent);
background: color-mix(in srgb, var(--bt-accent) 14%, transparent); }
.simlist[data-filter="ki"] .simpair[data-verdict="verschieden"] { display: none; }
.simfilter { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; flex-wrap: wrap; }
.segbtn { padding: 5px 12px; font-size: 12.5px; border: 1px solid var(--bt-line);
border-radius: var(--bt-r-sm); background: var(--bt-surface); color: var(--bt-muted);
cursor: pointer; }
.segbtn.on { color: var(--bt-ink); border-color: var(--bt-accent);
background: color-mix(in srgb, var(--bt-accent) 12%, transparent); font-weight: 600; }
.verdict-gleich { color: var(--bt-ok, #2e7d32);
background: color-mix(in srgb, var(--bt-ok, #2e7d32) 14%, transparent); }
.verdict-verschieden { color: var(--bt-muted);
background: color-mix(in srgb, var(--bt-muted) 16%, transparent); }
.airejected { margin: 0 0 12px; font-size: 12.5px; }
.airejected summary { cursor: pointer; color: var(--bt-muted); padding: 4px 0; }
.airejected .rejrow { display: flex; justify-content: space-between; gap: 10px;
padding: 5px 11px; border-bottom: 1px solid var(--bt-line-soft);
color: var(--bt-muted); }
.airejected .rejrow:last-child { border-bottom: none; }
@media (max-width: 700px) { .plrow { grid-template-columns: 24px 1fr; } .plrow .plinput { grid-column: 2; } }
</style>
</head>
<body data-bt-title="Stammdaten aufräumen" data-bt-icon="🧹">
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>Stammdaten aufräumen</h1>
<p>Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet
Karteileichen. Erst prüfen, dann in Ruhe durchsehen, dann ausführen.</p>
</div>
<div id="warn" class="bt-notice err bt-hidden"></div>
<div class="bt-notice warn">
<b>Zusammenführen und Löschen sind in Tandoor nicht umkehrbar.</b>
Tandoor hängt alle Rezeptverweise um und löscht den Quelleintrag; dessen
Nährwerte gehen dabei laut Tandoor-Code verloren. Dieses Werkzeug rettet sie
deshalb vorher ans Ziel und schreibt vor jedem Schritt eine Sicherung.
Vorgeschlagen wird nur, was praktisch sicher dasselbe ist — Grenzfälle
stehen unter „Ähnlich“ und werden nie vorausgewählt.
</div>
<div class="bt-card">
<div class="bt-row" style="align-items:flex-end">
<div style="flex:1">
<label>Was soll geprüft werden?</label>
<div class="kindsel">
<span class="chip on" data-kind="food">Zutaten</span>
<span class="chip on" data-kind="unit">Einheiten</span>
<span class="chip on" data-kind="keyword">Schlagworte</span>
</div>
</div>
<button class="primary" id="scan">Prüfen</button>
</div>
<label class="bt-check" style="margin-top:10px">
<input type="checkbox" id="kiFilter">
Ähnliche Paare per KI vorfiltern — definitiv verschiedene (z. B. „Weizenmehl
550“ vs. „1050“, „Frischkäse“ vs. „körniger Frischkäse“) werden aussortiert,
angezeigt wird nur, was uneindeutig ist
</label>
<span id="kiFilterHint" class="idn"></span>
<p class="hint">Für Einheiten und Schlagworte werden alle Rezepte gelesen —
anders als Zutaten führen sie keine Rezeptzahl mit.</p>
<div id="scanStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="scanLog" class="bt-log bt-hidden"></div>
</div>
<div class="bt-card">
<div class="bt-row" style="align-items:flex-end">
<div style="flex:1">
<label for="planFile">Plandatei</label>
<select id="planFile"></select>
</div>
<button class="ghost" id="reload">Neu laden</button>
</div>
</div>
<div class="bt-card" id="aiCard" style="display:none">
<h2>Plurale per KI <small style="color:var(--bt-muted);font-weight:normal">(ChatGPT)</small></h2>
<p class="hint">Lässt ChatGPT die <b>Plurale</b> von Zutaten und Einheiten
vorschlagen — auch die Umlaut-Fälle (Apfel → Äpfel), die die Heuristik
nicht kann. Nur ein Vorschlag; du prüfst und startest wie gewohnt selbst.
<br>(Die ähnlichen Paare filtert die KI schon beim Prüfen vor.)</p>
<label class="bt-check">
<input type="checkbox" id="allePlurale">
auch bereits gepflegte Plurale neu vorschlagen (überschreibt Vorhandenes)
</label>
<div class="bt-row" style="margin-top:12px;align-items:center">
<button class="primary" id="prefill">Plurale vorschlagen</button>
<button class="ghost" id="probe">OpenAI testen</button>
<span class="bt-spacer"></span>
<span class="bt-badge" id="aiHint"></span>
</div>
<div id="aiStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="aiLog" class="bt-log bt-hidden"></div>
</div>
<div id="box" class="bt-empty">Noch nicht geprüft.</div>
<div class="bt-card" id="runCard" style="display:none">
<h2>Ausführen</h2>
<label class="bt-check">
<input type="checkbox" id="rescue" checked>
Nährwerte der Quelle vorher ans Ziel retten (empfohlen)
</label>
<div class="bt-row" style="margin-top:12px;align-items:center">
<button id="dry">Trockenübung</button>
<button class="danger" id="apply">Jetzt ausführen</button>
<span class="bt-spacer"></span>
<span class="bt-badge" id="planCount"></span>
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
</div>
<div id="runStatus" class="bt-status">Bereit.</div>
<div id="runLog" class="bt-log"></div>
</div>
<section id="backups"></section>
</main>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
let state = {};
let plan = null;
const kinds = new Set(["food", "unit", "keyword"]);
document.querySelectorAll("[data-kind]").forEach((c) => c.addEventListener("click", () => {
const k = c.dataset.kind;
kinds.has(k) ? kinds.delete(k) : kinds.add(k);
c.classList.toggle("on", kinds.has(k));
$("scan").disabled = !kinds.size || !state.tandoor;
}));
function makeRunner(endpoint, logId, statusId, after) {
return BT.Runner({
endpoint,
log: $(logId), status: $(statusId),
onStart: () => { $(logId).classList.remove("bt-hidden"); $(statusId).classList.remove("bt-hidden"); },
onFinish: (info) => {
$("cancel").disabled = true;
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
if (after) after();
},
});
}
const scanRunner = makeRunner("/api/run/scan", "scanLog", "scanStatus", () => load(true));
let lastRunWasApply = false;
const runRunner = makeRunner("/api/run/execute", "runLog", "runStatus", () => {
if (lastRunWasApply) {
lastRunWasApply = false;
// Nach echtem Ausführen automatisch neu prüfen: sonst zeigte der eben
// ausgeführte (jetzt veraltete) Plan die bereits erledigten Einträge weiter.
BT.toast("Ausgeführt — wird neu geprüft …", "ok");
scanRunner.start({ kinds: [...kinds], ki_filter: $("kiFilter").checked });
} else {
load();
}
});
const prefillRunner = makeRunner("/api/run/vorbelegen", "aiLog", "aiStatus", () => {
const cur = $("planFile").value;
if (cur) loadPlan(cur); // Vorauswahl sichtbar machen
});
const probeRunner = makeRunner("/api/run/probe", "aiLog", "aiStatus");
/* ------------------------------------------------------ Sicherungen */
const restoreRunner = BT.Runner({
endpoint: "/api/run/restore",
onFinish: (info) => {
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
backupsView.reload();
load();
},
});
const backupsView = BT.Backups({
mount: $("backups"),
runner: restoreRunner,
detail: true,
note: `<b>Was das Zurückspielen kann:</b> Zusammengeführte und gelöschte
Einträge werden neu angelegt und die aufgezeichneten Rezeptverweise wieder
auf sie zurückgehängt — die Rezepte stimmen danach wieder.
<b>Was es nicht kann:</b> Die alte ID ist in Tandoor vergeben und kommt
nicht zurück. Einkaufslisten-Einträge, Einheiten-Umrechnungen und
Automatisierungen, die auf den alten Eintrag zeigten, bleiben beim Ziel.`,
});
$("scan").addEventListener("click", () => scanRunner.start({
kinds: [...kinds],
ki_filter: $("kiFilter").checked,
}));
$("cancel").addEventListener("click", () => runRunner.cancel());
$("reload").addEventListener("click", () => load());
$("planFile").addEventListener("change", (e) => loadPlan(e.target.value));
$("prefill").addEventListener("click", () => {
const plan = $("planFile").value;
if (!plan) { BT.toast("Erst prüfen und einen Plan laden.", "err"); return; }
$("aiStatus").classList.remove("bt-hidden");
$("aiLog").classList.remove("bt-hidden");
prefillRunner.start({ plan, alle_plurale: $("allePlurale").checked });
});
$("probe").addEventListener("click", () => {
$("aiStatus").classList.remove("bt-hidden");
$("aiLog").classList.remove("bt-hidden");
probeRunner.start({});
});
// Einmalig: Änderungen im Ergebnis-Container zählen. Wird ein Plural-Feld
// bearbeitet, die Zeile automatisch anhaken — Tippen heißt „das will ich“.
$("box").addEventListener("input", (e) => {
const t = e.target;
if (t && t.dataset && t.dataset.role === "plural") {
const acc = document.querySelector(`[data-aid="${t.dataset.aid}"][data-role="accept"]`);
if (acc && !acc.checked) acc.checked = true;
}
updateCount();
});
$("box").addEventListener("change", updateCount);
/* -------------------------------------------------------------- Anzeige */
function actionByAid(aid) {
return (plan.actions || []).find((a) => a.aid === aid);
}
function render() {
if (!plan) return;
const kindList = Object.entries(plan.kinds);
const dupMerges = plan.actions.filter((a) => a.action === "merge" && a.origin === "duplicate");
const simMerges = plan.actions.filter((a) => a.action === "merge" && a.origin === "similar");
const plurals = plan.actions.filter((a) => a.action === "set_plural");
const deletes = plan.actions.filter((a) => a.action === "delete");
const rescues = dupMerges.filter((a) => (a.rescue_properties || []).length);
$("box").className = "";
$("box").innerHTML = `
<div class="facts">
<div class="fact"><b>${dupMerges.length}</b><span>sichere Dubletten</span></div>
<div class="fact"><b>${simMerges.length}</b><span>ähnlich, zur Wahl</span></div>
<div class="fact"><b>${plurals.length}</b><span>ohne Plural</span></div>
<div class="fact"><b>${deletes.length}</b><span>unbenutzt</span></div>
</div>
${kindList.map(([kind, d]) => `
${d.duplicate_groups.length ? `
<section class="bt-card">
<h2>${BT.escape(d.plural)} zusammenführen</h2>
<p class="hint">Sichere Dubletten (gleicher Name, Singular/Plural). Ziel
ist der Eintrag, der am meisten zu verlieren hätte.</p>
${d.duplicate_groups.map((g) => `
<div class="grp">
<header>
<span class="tgt">bleibt: ${BT.escape(g.target.name)}
<small>[${g.target.id}] · ${g.target.numrecipe} Rezepte · ${g.target.properties} Eigenschaften</small>
</span>
<span class="score">${g.reason} · ${g.score}%</span>
</header>
${g.sources.map((s) => `
<div class="src">
<input type="checkbox" data-aid="${s.aid}" data-role="accept"
${actionByAid(s.aid)?.accept ? "checked" : ""}>
<span><span class="arrow">→</span> ${BT.escape(s.name)}
<span class="idn">[${s.id}] · ${s.numrecipe} Rezepte</span>
${s.rescue_properties.length
? `<span class="rescue">${s.rescue_properties.length} Werte werden vorher gerettet</span>` : ""}
${s.plural_suggestion
? `<label class="plural-toggle"><input type="checkbox" data-aid="${s.aid}"
data-role="setplural" ${actionByAid(s.aid)?.set_plural ? "checked" : ""}>
Plural „${BT.escape(s.plural_suggestion)}“ am Ziel ergänzen</label>` : ""}
</span>
<span class="idn">wird gelöscht</span>
</div>`).join("")}
</div>`).join("")}
</section>` : ""}
${d.similar.length ? `
<section class="bt-card">
<h2>${BT.escape(d.plural)} — ähnlich</h2>
<p class="hint">Was die KI als <b>gleich</b> erkennt, ist bereits
angehakt (mit Richtung); <b>unklare</b> Paare entscheidest du selbst.
Mit dem Filter blendest du zwischen allen Treffern und nur den
KI-relevanten um.</p>
${(() => {
const mitUrteil = d.similar.some((s) => s.ai_verdict);
if (!mitUrteil) return `<p class="hint" style="margin-top:0">
<i>KI-Filter inaktiv — beim Prüfen „Ähnliche Paare per KI vorfiltern“
aktivieren (braucht einen OpenAI-Key). Es werden alle Treffer gezeigt.</i></p>`;
const versch = d.similar.filter((s) => s.ai_verdict === "verschieden").length;
return `<div class="simfilter" role="group" aria-label="Anzeige filtern">
<span class="idn">Anzeigen:</span>
<button class="segbtn on" data-simfilter="${kind}" data-mode="ki">Nur KI-Treffer</button>
<button class="segbtn" data-simfilter="${kind}" data-mode="alle">Alle${
versch ? ` (+${versch} verschieden)` : ""}</button>
</div>`;
})()}
<div class="list simlist" data-simlist="${kind}" data-filter="ki">${d.similar.map((p) => {
const a = actionByAid(p.aid);
const dir = (a && a.source_id === p.right.id) ? "R2L"
: (a && a.source_id === p.left.id) ? "L2R" : "";
const pvL2R = p.plural_left_to_right || "";
const pvR2L = p.plural_right_to_left || "";
const showPlural = kind === "food" && (pvL2R || pvR2L);
return `
<div class="simpair" data-pair="${p.aid}" data-verdict="${p.ai_verdict || ""}">
<div class="simrow">
<input type="checkbox" data-aid="${p.aid}" data-role="accept"
${a?.accept ? "checked" : ""}>
<span class="simnames">
${BT.escape(p.left.name)} <span class="idn">[${p.left.id}]</span>
&nbsp;~&nbsp; ${BT.escape(p.right.name)} <span class="idn">[${p.right.id}]</span>
<span class="score">${p.score}% · ${BT.escape(p.reason)}</span>
${p.ai_verdict ? `<span class="aibadge verdict-${p.ai_verdict}">KI: ${
p.ai_verdict === "gleich" ? "gleich" : p.ai_verdict === "verschieden" ? "verschieden" : "unklar"
}${p.ai_reason ? " — " + BT.escape(p.ai_reason) : ""}</span>` : ""}
</span>
</div>
<div class="simdir">
<label><input type="radio" name="dir-${kind}-${p.aid}" value="L2R" ${dir === "L2R" ? "checked" : ""}>
${BT.escape(p.left.name)}“ → „${BT.escape(p.right.name)}
<span class="idn">(${BT.escape(p.left.name)} wird gelöscht)</span></label>
<label><input type="radio" name="dir-${kind}-${p.aid}" value="R2L" ${dir === "R2L" ? "checked" : ""}>
${BT.escape(p.right.name)}“ → „${BT.escape(p.left.name)}
<span class="idn">(${BT.escape(p.right.name)} wird gelöscht)</span></label>
${showPlural ? `<label class="plural-toggle">
<input type="checkbox" data-aid="${p.aid}" data-role="setplural"
data-pv-l2r="${BT.escape(pvL2R)}" data-pv-r2l="${BT.escape(pvR2L)}"
${a?.set_plural ? "checked" : ""}>
Plural am Ziel ergänzen, wenn passend</label>` : ""}
</div>
</div>`;
}).join("")}</div>
</section>` : ""}
${(d.plurals && d.plurals.length) ? `
<section class="bt-card">
<h2>Plurale — ${BT.escape(d.plural)}
<small style="color:var(--bt-muted);font-weight:normal">
(${d.plurals.filter((x) => !x.plural).length} ohne Plural von ${d.plurals.length})</small></h2>
<p class="hint">Ist der Plural gepflegt, erkennt Tandoor „2 Zwiebeln“
beim Import als dieselbe Zutat. Bestehende lassen sich korrigieren,
fehlende eintragen — <b>nur Geändertes wird geschrieben</b>. Bearbeiten
hakt die Zeile automatisch an. Nicht destruktiv, voll rückspielbar.</p>
<input type="text" class="plfilter" data-plfilter="${kind}"
placeholder="filtern nach Name …">
<div class="list" data-pllist="${kind}">${d.plurals.map((m) => `
<div class="plrow${m.plural ? "" : " plmissing"}"
data-plname="${BT.escape((m.name || "").toLowerCase())}">
<input type="checkbox" data-aid="${m.aid}" data-role="accept"
${actionByAid(m.aid)?.accept ? "checked" : ""}>
<span>${BT.escape(m.name)}
<span class="idn">[${m.id}]${m.used ? "" : " · unbenutzt"}</span>
${actionByAid(m.aid)?.ai ? `<span class="aibadge">KI</span>` : ""}</span>
<input type="text" class="plinput" data-aid="${m.aid}" data-role="plural"
placeholder="${BT.escape(m.suggestion || "Plural …")}"
value="${BT.escape(actionByAid(m.aid)?.plural_value ?? m.plural ?? "")}">
</div>`).join("")}</div>
</section>` : ""}
${d.unused.length ? `
<section class="bt-card">
<h2>${BT.escape(d.plural)} — unbenutzt (${d.unused.length})</h2>
<p class="hint">In keinem Rezept verwendet. Löschen ist bewusst nicht
vorausgewählt — manches hebt man ja absichtlich auf.</p>
<div class="bt-row" style="margin-bottom:8px">
<button class="mini ghost" data-all="${kind}">Alle anhaken</button>
<button class="mini ghost" data-none="${kind}">Keine</button>
</div>
<div class="list">${d.unused.map((u) => {
const a = plan.actions.find((x) => x.action === "delete" && x.kind === kind && x.source_id === u.id);
return `
<div class="un">
<input type="checkbox" data-aid="${a ? a.aid : ""}" data-role="accept" data-del="${kind}"
${a?.accept ? "checked" : ""}>
<span>${BT.escape(u.name)} <span class="idn">[${u.id}]</span></span>
<span class="idn">löschen</span>
</div>`;
}).join("")}</div>
</section>` : ""}
`).join("")}`;
document.querySelectorAll("[data-all]").forEach((b) => b.addEventListener("click", () => {
document.querySelectorAll(`[data-role="accept"][data-del="${b.dataset.all}"]`)
.forEach((c) => c.checked = true);
updateCount();
}));
document.querySelectorAll("[data-none]").forEach((b) => b.addEventListener("click", () => {
document.querySelectorAll(`[data-role="accept"][data-del="${b.dataset.none}"]`)
.forEach((c) => c.checked = false);
updateCount();
}));
// Filter für die Plural-Listen (große Listen bedienbar machen).
document.querySelectorAll("[data-plfilter]").forEach((box) => {
box.addEventListener("input", () => {
const q = box.value.trim().toLowerCase();
const list = document.querySelector(`[data-pllist="${box.dataset.plfilter}"]`);
if (!list) return;
list.querySelectorAll(".plrow").forEach((row) => {
row.style.display = (!q || (row.dataset.plname || "").includes(q)) ? "" : "none";
});
});
});
// Ähnlich-Filter: alle Treffer oder nur die KI-relevanten (ohne „verschieden“).
document.querySelectorAll("[data-simfilter]").forEach((btn) => {
btn.addEventListener("click", () => {
const kind = btn.dataset.simfilter;
const list = document.querySelector(`[data-simlist="${kind}"]`);
if (list) list.dataset.filter = btn.dataset.mode;
document.querySelectorAll(`[data-simfilter="${kind}"]`).forEach((b) =>
b.classList.toggle("on", b === btn));
});
});
$("runCard").style.display = "";
updateCount();
}
function collect() {
return (plan.actions || []).map((a) => {
const accEl = document.querySelector(`[data-aid="${a.aid}"][data-role="accept"]`);
const row = { aid: a.aid, accept: accEl ? accEl.checked : false };
if (a.action === "merge" && a.origin === "similar") {
const dir = document.querySelector(`input[name="dir-${a.kind}-${a.aid}"]:checked`);
if (dir && dir.value === "L2R") { row.source_id = a.left_id; row.target_id = a.right_id; }
else if (dir && dir.value === "R2L") { row.source_id = a.right_id; row.target_id = a.left_id; }
else { row.source_id = null; row.target_id = null; }
const sp = document.querySelector(`[data-aid="${a.aid}"][data-role="setplural"]`);
if (sp) row.set_plural = sp.checked;
} else if (a.action === "merge") {
const sp = document.querySelector(`[data-aid="${a.aid}"][data-role="setplural"]`);
if (sp) { row.set_plural = sp.checked; row.plural_value = a.plural_value; }
} else if (a.action === "set_plural") {
const inp = document.querySelector(`[data-aid="${a.aid}"][data-role="plural"]`);
if (inp) row.plural_value = inp.value;
}
return row;
});
}
function updateCount() {
const rows = collect().filter((r) => r.accept);
const merges = rows.filter((r) => {
const a = actionByAid(r.aid);
return a && a.action === "merge" &&
(a.origin !== "similar" || (Number.isInteger(r.source_id) && Number.isInteger(r.target_id)));
}).length;
const pl = rows.filter((r) => {
const a = actionByAid(r.aid);
if (!a || a.action !== "set_plural") return false;
const neu = (r.plural_value || "").trim();
const alt = (a.plural_before_scan || "").trim();
return neu !== alt; // nur echte Änderungen zählen
}).length;
const del = rows.filter((r) => actionByAid(r.aid)?.action === "delete").length;
$("planCount").textContent =
`${merges} zusammenführen · ${pl} Plural · ${del} löschen`;
$("apply").disabled = !(merges + pl + del) || !state.tandoor;
$("dry").disabled = !(merges + pl + del);
}
async function save() {
return BT.api(`/api/plans/${encodeURIComponent($("planFile").value)}`,
{ method: "POST", body: JSON.stringify({ accepted: collect() }) });
}
async function run(apply) {
try {
const saved = await save();
if (!saved.merges && !saved.set_plurals && !saved.deletes) {
BT.toast("Nichts angehakt.", "err"); return;
}
if (apply && !confirm(
`Wirklich ausführen?\n\n` +
`${saved.merges} Zusammenführungen\n` +
`${saved.set_plurals} Plural-Ergänzungen\n` +
`${saved.deletes} Löschungen\n\n` +
`Zusammenführen und Löschen lassen sich in Tandoor nicht direkt rückgängig ` +
`machen (Plural-Ergänzungen schon). Sicherungen werden vorher ins ` +
`Laufverzeichnis geschrieben und lassen sich im Reiter „Sicherungen“ ` +
`zurückspielen.`)) return;
$("cancel").disabled = false;
lastRunWasApply = apply;
runRunner.start({
plan: $("planFile").value,
apply,
rescue: $("rescue").checked,
continue_on_error: true,
});
} catch (err) { BT.toast(err.message, "err"); }
}
$("dry").addEventListener("click", () => run(false));
$("apply").addEventListener("click", () => run(true));
async function loadPlan(name) {
if (!name) { plan = null; return; }
plan = await BT.api(`/api/plans/${encodeURIComponent(name)}`);
render();
}
async function load(selectNewest = false) {
state = await BT.api("/api/state");
$("warn").classList.toggle("bt-hidden", state.tandoor);
if (!state.tandoor) {
$("warn").innerHTML = `Tandoor-URL und Token fehlen. Bitte in den
<a href="/settings">Einstellungen</a> hinterlegen.`;
}
$("scan").disabled = !state.tandoor || !kinds.size;
// KI-Vorfilter braucht OpenAI. Standardmäßig an, wenn verfügbar.
const kf = $("kiFilter");
kf.disabled = !state.openai;
if (!state.openai) {
kf.checked = false;
$("kiFilterHint").textContent = "OpenAI-Key fehlt (siehe Einstellungen) — Vorfilter aus.";
} else {
if (!kf.dataset.touched) kf.checked = true;
$("kiFilterHint").textContent = "";
}
kf.addEventListener("change", () => { kf.dataset.touched = "1"; }, { once: true });
const sel = $("planFile");
const previous = sel.value;
sel.innerHTML = state.plans.map((f) =>
`<option value="${BT.escape(f)}">${BT.escape(f.replace(".json", ""))}</option>`).join("");
if (state.plans.length) {
// Nach einem frischen Scan den neuesten Plan zeigen, sonst die bisherige
// Auswahl behalten. Sonst bliebe man nach erneutem Prüfen auf dem alten
// Plan hängen und sähe bereits zusammengeführte Einträge weiter.
sel.value = (!selectNewest && state.plans.includes(previous)) ? previous : state.plans[0];
await loadPlan(sel.value);
} else {
$("runCard").style.display = "none";
}
// KI-Karte nur zeigen, wenn ein Plan geladen ist. Prefill braucht OpenAI.
$("aiCard").style.display = state.plans.length ? "" : "none";
$("prefill").disabled = !state.openai;
$("aiHint").textContent = state.openai ? "" : "OpenAI-Key fehlt (Einstellungen)";
if (state.running) {
const lbl = state.running.label || "";
const r = lbl.startsWith("Stammdaten") ? scanRunner
: lbl.includes("Zurückspielen") ? restoreRunner
: lbl.startsWith("KI-Vorauswahl") ? prefillRunner
: lbl.startsWith("OpenAI-Verbindung") ? probeRunner
: runRunner;
if (r === prefillRunner || r === probeRunner) {
$("aiStatus").classList.remove("bt-hidden");
$("aiLog").classList.remove("bt-hidden");
}
r.attach(state.running.id);
}
backupsView.reload();
}
load();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff