chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
+614
View File
@@ -0,0 +1,614 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tandoor AI Import</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<style>
/* ----------------------------------------------------------------
Tandoor AI Import · plugin-eigenes Layout.
Farben, Buttons, Felder, Karten kommen aus /shared/boehmi.css,
damit das Tool wie der Rest der Suite aussieht. Klassennamen und
Markup bleiben identisch zum Einzeltool.
---------------------------------------------------------------- */
/* Kompatibilitaets-Aliase der alten Variablennamen */
:root{
--bg:var(--bt-bg); --panel:var(--bt-surface); --panel-2:var(--bt-surface-2);
--text:var(--bt-ink); --muted:var(--bt-muted); --line:var(--bt-line);
--accent:var(--bt-accent); --danger:var(--bt-err);
--warning:var(--bt-warn); --info:var(--bt-info);
}
body > header{
padding: 18px clamp(14px, 4vw, 24px) 0;
width: min(1520px, 96vw); margin: 0 auto; border: 0; background: none;
}
body > header h1{
margin: 0; font-size: 22px; font-weight: 850; letter-spacing: -.02em;
}
body > header p{ margin: 5px 0 0; color: var(--bt-muted); }
main{ width: min(1520px, 96vw); margin: 14px auto 60px; }
.bar, .panel{
background: var(--bt-surface); border: 1px solid var(--bt-line);
border-radius: var(--bt-r-lg); box-shadow: var(--bt-shadow);
}
.bar{ display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 12px; }
.panel{ padding: 16px; }
textarea#jsonEditor{
min-height: 620px; font-family: var(--bt-mono);
font-size: 12.5px; line-height: 1.5; tab-size: 2;
background: var(--bt-surface-2);
}
button.secondary{
background: var(--bt-surface); color: var(--bt-ink); border: 1px solid var(--bt-line);
font-weight: 650;
}
button.secondary:hover{ background: var(--bt-bg-2); }
#analyze, #import{
background: var(--bt-accent); border-color: var(--bt-accent);
color: var(--bt-accent-ink); font-weight: 700;
}
#analyze:hover:not(:disabled), #import:hover:not(:disabled){
background: var(--bt-accent-hi); border-color: var(--bt-accent-hi);
}
.status{
display: flex; align-items: center; gap: 8px;
margin: 12px 0; padding: 9px 13px; min-height: 40px;
border: 1px solid var(--bt-line); border-radius: var(--bt-r);
background: var(--bt-surface); color: var(--bt-ink-soft); font-size: 13px;
box-shadow: var(--bt-shadow);
}
.status.error{ color: var(--bt-err); border-color: var(--bt-err); background: var(--bt-err-soft); }
.status.ok{ color: var(--bt-ok); border-color: var(--bt-ok); background: var(--bt-ok-soft); }
.layout{
display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(420px, .9fr);
gap: 16px; align-items: start;
}
.tabs{ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.tabs button{ padding: 6px 12px; font-size: 12.5px; border-radius: var(--bt-r-sm); }
.tabs button.secondary.active, .tabs button.active{
background: var(--bt-accent-soft); color: var(--bt-accent);
border-color: var(--bt-accent); outline: none;
}
.tab{ display: none; }
.tab.active{ display: block; }
.warnings{ display: grid; gap: 8px; margin-bottom: 16px; }
.warning{
padding: 10px 13px; font-size: 12.5px; color: var(--bt-ink-soft);
border: 1px solid var(--bt-line); border-left: 3px solid var(--bt-muted);
border-radius: 0 var(--bt-r) var(--bt-r) 0; background: var(--bt-surface-2);
}
.warning.blocking{ border-left-color: var(--bt-err); background: var(--bt-err-soft); }
.warning.warning{ border-left-color: var(--bt-warn); background: var(--bt-warn-soft); }
.warning.info{ border-left-color: var(--bt-info); background: var(--bt-info-soft); }
.recipe h2{ margin: 0 0 5px; font-size: 19px; font-weight: 800; letter-spacing: -.02em; }
.meta{ color: var(--bt-muted); margin-bottom: 18px; font-size: 12.5px; }
.step{ padding: 15px 0; border-top: 1px solid var(--bt-line-soft); }
.step h3{
margin: 0 0 9px; font-size: 11.5px; font-weight: 800;
letter-spacing: .09em; text-transform: uppercase; color: var(--bt-teal);
}
.ingredients{
padding: 11px 14px; background: var(--bt-surface-2);
border: 1px solid var(--bt-line-soft); border-radius: var(--bt-r); margin-bottom: 11px;
}
.ingredients ul{ margin: 0; padding-left: 19px; font-size: 13px; }
.instruction{ white-space: pre-line; line-height: 1.6; color: var(--bt-ink-soft); }
.mapping{ overflow-x: auto; }
table{ width: 100%; border-collapse: collapse; font-size: 12.5px; }
th{
text-align: left; padding: 9px 8px; border-bottom: 1px solid var(--bt-line);
font-size: 11px; font-weight: 800; letter-spacing: .07em;
text-transform: uppercase; color: var(--bt-muted);
}
td{ text-align: left; vertical-align: top; padding: 9px 8px; border-bottom: 1px solid var(--bt-line-soft); }
.pill{
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 700;
background: var(--bt-bg-2); border: 1px solid var(--bt-line-soft); color: var(--bt-ink-soft);
}
.pill.bad{ color: var(--bt-err); background: var(--bt-err-soft); border-color: transparent; }
.pill.good{ color: var(--bt-ok); background: var(--bt-ok-soft); border-color: transparent; }
.actions{
position: sticky; bottom: 12px; display: flex; gap: 10px;
justify-content: flex-end; align-items: center; padding: 11px;
margin-top: 14px; background: var(--bt-surface);
border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg);
box-shadow: var(--bt-shadow-lg);
}
.empty{
color: var(--bt-muted); padding: 56px 10px; text-align: center;
font-size: 13px;
}
.checkbox{
display: flex; align-items: center; gap: 7px;
color: var(--bt-ink-soft); font-size: 13px; font-weight: 600;
margin-right: auto;
}
.checkbox input{ width: auto; }
@media (max-width: 950px){
.layout{ grid-template-columns: 1fr; }
.bar{ grid-template-columns: 1fr; }
textarea#jsonEditor{ min-height: 420px; }
.object-picker{ min-width: 0; }
}
/* --- ab v1.1: Zuordnungs-Dropdowns --------------------------------- */
button.small{ padding: 6px 11px; font-size: 12px; border-radius: var(--bt-r-sm); }
.pill.review{ color: var(--bt-warn); background: var(--bt-warn-soft); border-color: transparent; }
.object-picker{ min-width: 280px; display: grid; gap: 6px; }
.object-picker select{
font-size: 12.5px; padding: 7px 9px; background: var(--bt-surface);
font-family: var(--bt-mono);
}
.object-picker select:has(option[value=""]:checked){ border-color: var(--bt-err); }
.manual-row{ display: grid; grid-template-columns: 1fr auto; gap: 6px; }
.manual-row input{ font-size: 12.5px; padding: 7px 9px; }
.hint{ color: var(--bt-muted); font-size: 11.5px; line-height: 1.4; }
.source-text{ color: var(--bt-muted); font-size: 12px; line-height: 1.45; }
</style>
</head>
<body data-bt-title="Tandoor AI Import" data-bt-icon="🥕">
<header>
<h1>🥕 Tandoor AI Import</h1>
<p>URL analysieren, Rezept prüfen, Zutaten zuordnen und kontrolliert importieren.</p>
</header>
<main>
<section class="bar">
<input id="url" type="text" placeholder="example.com/rezept.html oder https://…" autocomplete="off">
<button id="analyze">URL analysieren</button>
</section>
<div id="status" class="status">Bereit.</div>
<section class="layout">
<div class="panel">
<div id="warnings" class="warnings"></div>
<div id="preview" class="recipe empty">Noch kein Rezept analysiert.</div>
</div>
<div class="panel">
<div class="tabs">
<button class="secondary active" data-tab="mapping">Zuordnungen</button>
<button class="secondary" data-tab="json">JSON</button>
</div>
<div id="tab-mapping" class="tab active">
<div id="mapping" class="mapping empty">Noch keine Zuordnungen.</div>
</div>
<div id="tab-json" class="tab">
<textarea id="jsonEditor" spellcheck="false" placeholder="Das analysierte Rezept erscheint hier."></textarea>
</div>
<div class="actions">
<label class="checkbox">
<input id="importImage" type="checkbox" checked>
Bild übernehmen
</label>
<button id="validate" class="secondary" disabled>Änderungen prüfen</button>
<button id="import" disabled>In Tandoor importieren</button>
</div>
</div>
</section>
</main>
<script>
const state = {
runId: null,
recipe: null,
resolution: null,
warnings: [],
blocking: true,
};
const $ = (id) => document.getElementById(id);
const status = $("status");
const analyzeButton = $("analyze");
const validateButton = $("validate");
const importButton = $("import");
const editor = $("jsonEditor");
function setStatus(message, kind = "") {
status.textContent = message;
status.className = "status " + kind;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function amountText(ingredient) {
if (ingredient.no_amount) return "";
const base = Number(ingredient.amount).toLocaleString("de-DE");
const max = ingredient.amount_max == null
? ""
: "" + Number(ingredient.amount_max).toLocaleString("de-DE");
const unit = ingredient.unit_name ? " " + ingredient.unit_name : "";
return `${base}${max}${unit}`;
}
function renderPreview() {
if (!state.recipe) {
$("preview").innerHTML = "Noch kein Rezept analysiert.";
$("preview").className = "recipe empty";
return;
}
const r = state.recipe;
const steps = r.steps.map(step => `
<section class="step">
<h3>${escapeHtml(step.name)}</h3>
${step.ingredients.length ? `
<div class="ingredients"><ul>
${step.ingredients.map(i => `
<li>
<strong>${escapeHtml(amountText(i))}</strong>
${escapeHtml(i.food_name)}
${i.note ? `<span>(${escapeHtml(i.note)})</span>` : ""}
</li>
`).join("")}
</ul></div>` : ""}
<div class="instruction">${escapeHtml(step.instruction)}</div>
</section>
`).join("");
$("preview").className = "recipe";
$("preview").innerHTML = `
<h2>${escapeHtml(r.name)}</h2>
<div class="meta">
${escapeHtml(r.servings_text || `${r.servings} Portionen`)}
· ${r.working_time} Min. Arbeit
· ${r.waiting_time} Min. Warte-/Garzeit
· Vertrauen: ${escapeHtml(r.confidence)}
</div>
${r.description ? `<p>${escapeHtml(r.description)}</p>` : ""}
${steps}
<p><a href="${escapeHtml(r.source_url)}" target="_blank" rel="noopener noreferrer">Originalquelle öffnen</a></p>
`;
}
function renderWarnings() {
const container = $("warnings");
if (!state.warnings.length) {
container.innerHTML = `<div class="warning info">Keine Warnungen. Der Küchenpass ist grün. ✅</div>`;
return;
}
container.innerHTML = state.warnings.map(w => `
<div class="warning ${escapeHtml(w.severity)}">
<strong>${escapeHtml(w.severity.toUpperCase())}</strong>
· ${escapeHtml(w.message)}
</div>
`).join("");
}
function objectFields(kind) {
return kind === "food"
? {
id: "preferred_food_id",
create: "create_food",
name: "food_name",
plural: "food_plural_name",
label: "Food",
}
: {
id: "preferred_unit_id",
create: "create_unit",
name: "unit_name",
plural: "unit_plural_name",
label: "Einheit",
};
}
function statusPill(resolution) {
if (!resolution) return `<span class="pill good">keine Einheit</span>`;
let css = resolution.blocking ? "bad" : (resolution.needs_review ? "review" : "good");
return `<span class="pill ${css}">${escapeHtml(resolution.status)}</span>`;
}
function objectPicker(mapping, kind) {
const resolution = mapping[kind];
if (!resolution) return `<span class="hint">Keine Einheit vorgesehen</span>`;
const fields = objectFields(kind);
const selectedId = resolution.selected_id;
const createSelected = resolution.status === "create";
const noSelection = resolution.blocking && !createSelected && selectedId == null;
const candidates = (resolution.candidates || []).map(candidate => {
const selected = Number(candidate.id) === Number(selectedId) && !createSelected;
const extras = [
candidate.reason,
`${candidate.score}%`,
candidate.has_properties ? "Properties" : "",
].filter(Boolean).join(" · ");
return `
<option value="id:${candidate.id}" data-name="${escapeHtml(candidate.name)}" ${selected ? "selected" : ""}>
${escapeHtml(candidate.name)} [${candidate.id}]${extras ? ` · ${escapeHtml(extras)}` : ""}
</option>
`;
}).join("");
const lookupName = resolution.lookup_name || resolution.requested || "";
return `
<div class="object-picker" data-kind="${kind}" data-step="${mapping.step_index}" data-ingredient="${mapping.ingredient_index}">
${statusPill(resolution)}
<select class="object-select">
<option value="" ${noSelection ? "selected" : ""}>Bitte zuordnen …</option>
${candidates}
<option value="create" ${createSelected ? "selected" : ""}> Neu anlegen: ${escapeHtml(lookupName)}</option>
</select>
<div class="manual-row">
<input class="object-name" value="${escapeHtml(lookupName)}" aria-label="${fields.label}-Name">
<button class="secondary small object-search" type="button">Suchen</button>
</div>
<div class="hint">
${resolution.message ? escapeHtml(resolution.message) : "Treffer ist vorausgewählt und kann jederzeit geändert werden."}
</div>
</div>
`;
}
function renderMappings() {
const container = $("mapping");
const rows = state.resolution?.mappings || [];
if (!rows.length) {
container.className = "mapping empty";
container.innerHTML = "Noch keine Zuordnungen.";
return;
}
container.className = "mapping";
container.innerHTML = `
<table>
<thead>
<tr>
<th>Quelle</th>
<th>Food-Zuordnung</th>
<th>Einheit-Zuordnung</th>
</tr>
</thead>
<tbody>
${rows.map(mapping => `
<tr>
<td>
<strong>${escapeHtml(mapping.step_name)}</strong><br>
<span class="source-text">${escapeHtml(mapping.original_text)}</span>
</td>
<td>${objectPicker(mapping, "food")}</td>
<td>${objectPicker(mapping, "unit")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
container.querySelectorAll(".object-picker").forEach(picker => {
const stepIndex = Number(picker.dataset.step);
const ingredientIndex = Number(picker.dataset.ingredient);
const kind = picker.dataset.kind;
const fields = objectFields(kind);
const select = picker.querySelector(".object-select");
const nameInput = picker.querySelector(".object-name");
const searchButton = picker.querySelector(".object-search");
select.addEventListener("change", async () => {
const ingredient = state.recipe.steps[stepIndex].ingredients[ingredientIndex];
const value = select.value;
if (value.startsWith("id:")) {
const option = select.options[select.selectedIndex];
ingredient[fields.id] = Number(value.slice(3));
ingredient[fields.create] = false;
ingredient[fields.name] = option.dataset.name;
nameInput.value = option.dataset.name;
} else if (value === "create") {
const enteredName = nameInput.value.trim();
if (!enteredName) {
setStatus(`${fields.label}-Name darf nicht leer sein.`, "error");
select.value = "";
return;
}
ingredient[fields.id] = null;
ingredient[fields.create] = true;
ingredient[fields.name] = enteredName;
} else {
ingredient[fields.id] = null;
ingredient[fields.create] = false;
}
syncEditor();
await validateRecipe();
});
searchButton.addEventListener("click", async () => {
const enteredName = nameInput.value.trim();
if (!enteredName) {
setStatus(`${fields.label}-Name darf nicht leer sein.`, "error");
return;
}
const ingredient = state.recipe.steps[stepIndex].ingredients[ingredientIndex];
ingredient[fields.name] = enteredName;
ingredient[fields.id] = null;
ingredient[fields.create] = false;
syncEditor();
await validateRecipe();
});
nameInput.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
searchButton.click();
}
});
});
}
function syncEditor() {
editor.value = state.recipe ? JSON.stringify(state.recipe, null, 2) : "";
}
function updateButtons() {
validateButton.disabled = !state.recipe;
importButton.disabled = !state.recipe || state.blocking;
}
function applyResponse(data) {
state.runId = data.run_id;
state.recipe = data.recipe;
state.resolution = data.resolution;
state.warnings = data.warnings || [];
state.blocking = Boolean(data.blocking);
syncEditor();
renderPreview();
renderWarnings();
renderMappings();
updateButtons();
}
async function api(url, options = {}) {
const response = await fetch(BT.url(url), {
headers: {"Content-Type": "application/json"},
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`);
return data;
}
async function analyze() {
const url = $("url").value.trim();
if (!url) {
setStatus("Bitte eine URL eintragen.", "error");
return;
}
analyzeButton.disabled = true;
importButton.disabled = true;
setStatus("Quelle wird gelesen und von OpenAI strukturiert …");
try {
const data = await api("/api/analyze", {
method: "POST",
body: JSON.stringify({url}),
});
applyResponse(data);
setStatus(
data.blocking
? "Analyse abgeschlossen. Noch nicht zugeordnete Einträge sind markiert."
: "Analyse und Tandoor-Abgleich erfolgreich. Vorauswahlen bitte prüfen.",
data.blocking ? "error" : "ok"
);
} catch (error) {
setStatus(error.message, "error");
} finally {
analyzeButton.disabled = false;
}
}
async function validateRecipe() {
if (!state.runId) return;
setStatus("Rezept wird validiert und erneut mit Tandoor abgeglichen …");
try {
const parsed = JSON.parse(editor.value);
const data = await api(`/api/runs/${state.runId}/validate`, {
method: "POST",
body: JSON.stringify({recipe: parsed}),
});
applyResponse(data);
setStatus(
data.blocking ? "Noch nicht zugeordnete Einträge vorhanden." : "Validierung erfolgreich.",
data.blocking ? "error" : "ok"
);
} catch (error) {
setStatus(`Validierung fehlgeschlagen: ${error.message}`, "error");
}
}
async function importRecipe() {
if (!state.runId || state.blocking) return;
const newObjects = (state.resolution?.mappings || []).flatMap(mapping =>
[mapping.food, mapping.unit]
.filter(item => item?.status === "create")
.map(item => item.lookup_name)
);
const addition = newObjects.length
? `\n\nNeu in Tandoor anzulegen:\n${newObjects.join("\n• ")}`
: "";
const confirmed = window.confirm(
`${state.recipe.name}“ jetzt wirklich in Tandoor anlegen?${addition}`
);
if (!confirmed) return;
importButton.disabled = true;
setStatus("Stammdaten und Rezept werden angelegt und anschließend verifiziert …");
try {
const parsed = JSON.parse(editor.value);
const data = await api(`/api/runs/${state.runId}/import`, {
method: "POST",
body: JSON.stringify({
recipe: parsed,
import_image: $("importImage").checked,
force_duplicate: false,
}),
});
if (data.status === "duplicate") {
const list = data.duplicates.map(d => `ID ${d.id}: ${d.name}`).join(", ");
setStatus(`Nicht importiert: Rezept existiert bereits (${list}).`, "error");
} else {
const created = (data.created_objects || []).map(o => o.name).join(", ");
setStatus(
`Import erfolgreich und verifiziert.${created ? ` Neu angelegt: ${created}.` : ""}`,
"ok"
);
$("preview").insertAdjacentHTML(
"afterbegin",
`<p><a href="${escapeHtml(data.recipe_url)}" target="_blank" rel="noopener noreferrer"><strong>In Tandoor öffnen →</strong></a></p>`
);
}
} catch (error) {
setStatus(`Import fehlgeschlagen: ${error.message}`, "error");
} finally {
updateButtons();
}
}
document.querySelectorAll("[data-tab]").forEach(button => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-tab]").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab").forEach(tab => tab.classList.remove("active"));
button.classList.add("active");
$(`tab-${button.dataset.tab}`).classList.add("active");
});
});
analyzeButton.addEventListener("click", analyze);
validateButton.addEventListener("click", validateRecipe);
importButton.addEventListener("click", importRecipe);
$("url").addEventListener("keydown", event => {
if (event.key === "Enter") analyze();
});
</script>
</body>
</html>