625 lines
30 KiB
HTML
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>
|
|
~ ${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>
|