Files
2026-07-24 21:37:03 +02:00

625 lines
30 KiB
HTML

<!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>