Files
boehmitools-main/plugins/tandoor-ai-import/static/index.html
T
2026-07-24 21:37:03 +02:00

615 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>