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