chore: initial import
This commit is contained in:
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user