async function fetchJson(url, options) { const res = await fetch(url, options); const payload = await res.json(); if (!res.ok) throw new Error(payload.detail || 'Request failed for ' + url); return payload; } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function setStatusMessage(message, tone = 'muted') { const status = document.getElementById('search-status'); if (!status) return; status.textContent = message || ''; status.className = tone; } function renderSuggestionCards(target, items) { if (!target) return; if (!items.length) { target.textContent = 'Nothing loaded yet.'; target.classList.add('muted'); return; } target.classList.remove('muted'); target.innerHTML = items.map((item) => { const ingredients = (item.ingredients || []).slice(0, 8).map((entry) => '' + escapeHtml(entry) + '').join(''); const reasons = (item.score_reasons || []).slice(0, 4).map((entry) => '
  • ' + escapeHtml(entry) + '
  • ').join(''); const kitchenowl = item.kitchenowl_status && item.kitchenowl_status !== 'not-imported' ? '

    KitchenOwl: ' + escapeHtml(item.kitchenowl_status) + '

    ' : ''; const canFlagImport = ['created', 'updated', 'duplicate'].includes(String(item.kitchenowl_status || '')); const flagButton = canFlagImport ? '' : ''; return `

    Score ${escapeHtml(item.score)}

    ${escapeHtml(item.title)}

    ${escapeHtml(item.source_query || item.source_title || '')}

    ${escapeHtml(item.status || 'suggested')}

    ${kitchenowl}
    ${ingredients || 'No ingredient list parsed'}
    ${flagButton}
    `; }).join(''); } function renderFailureCards(target, items) { if (!target) return; if (!items.length) { target.textContent = 'No failed searches logged.'; target.classList.add('muted'); return; } target.classList.remove('muted'); target.innerHTML = items.map((item) => { const status = String(item.status || 'open'); const resolved = status === 'resolved-with-recipes'; const titles = (item.sample_titles || []).map((entry) => '
  • ' + escapeHtml(entry) + '
  • ').join(''); const candidateQueries = (item.candidate_queries || []).map((entry) => '' + escapeHtml(entry) + '').join(''); return `

    ${escapeHtml(item.query)}

    ${escapeHtml(status)}

    Logged: ${escapeHtml(item.created_at || '')}

    ${candidateQueries || 'No candidate queries captured'}

    Current matching recipes: ${escapeHtml(item.result_count || 0)}

    Click View recipes to load the current recipe matches for this search.
    `; }).join(''); } function renderImportFlagCards(target, items) { if (!target) return; if (!items.length) { target.textContent = 'No import issues flagged yet.'; target.classList.add('muted'); return; } target.classList.remove('muted'); target.innerHTML = items.map((item) => { const status = String(item.status || 'open'); const resolved = status === 'resolved'; const history = (item.notes_history || []).slice(-3).reverse().map((entry) => '
  • ' + escapeHtml(entry.issue_type || 'issue') + ': ' + escapeHtml(entry.notes || '') + '
  • ').join(''); const sourceLink = item.recipe_url ? 'Open recipe source' : 'No source URL saved'; const recipeId = item.kitchenowl_recipe_id ? 'KitchenOwl #' + escapeHtml(item.kitchenowl_recipe_id) + '' : ''; const resolveButton = resolved ? '' : ''; return `

    ${escapeHtml(item.recipe_title || item.recipe_url || 'KitchenOwl recipe issue')}

    ${escapeHtml(status)}

    ${escapeHtml(item.notes || '')}

    ${recipeId} Flags: ${escapeHtml(item.flag_count || 1)} Last flagged: ${escapeHtml(item.last_flagged_at || item.updated_at || item.created_at || '')}
    ${sourceLink}
    ${resolveButton}
    `; }).join(''); } async function loadDashboard() { const data = await fetchJson('/api/dashboard/summary'); const counts = data.counts || {}; const profile = data.profile || {}; const setText = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; }; setText('count-suggested', counts.suggested || 0); setText('count-approved', counts.approved || 0); setText('count-saved', counts.saved || 0); setText('count-rejected', counts.rejected || 0); setText('count-plans', counts.plans || 0); setText('count-failed-searches', counts.failed_searches || 0); setText('count-import-flags', counts.open_import_flags || 0); setText('backend-note', 'Storage backend: ' + data.backend); const blacklist = document.getElementById('blacklist-items'); if (blacklist) { const items = profile.soft_blacklist || []; if (!items.length) { blacklist.textContent = 'No soft blacklist items yet.'; } else { blacklist.classList.remove('muted'); blacklist.innerHTML = items.map((item) => '').join(''); } } const notes = document.getElementById('profile-notes'); if (notes) notes.value = profile.notes || ''; const threshold = document.getElementById('auto-import-threshold'); if (threshold) threshold.value = profile.auto_import_threshold || 96; const enabled = document.getElementById('auto-import-enabled'); if (enabled) enabled.checked = Boolean(profile.auto_import_enabled); renderSuggestionCards(document.getElementById('suggestion-list'), data.recent_suggestions || []); renderFailureCards(document.getElementById('failed-search-preview'), data.recent_failed_searches || []); renderImportFlagCards(document.getElementById('import-flag-preview'), data.recent_import_flags || []); } async function handleDecision(event) { const button = event.target.closest('button[data-decision]'); if (!button) return; const card = button.closest('[data-suggestion-id]'); const suggestionId = card && card.dataset ? card.dataset.suggestionId : ''; const decision = button.dataset.decision; if (!suggestionId || !decision) return; const failurePanel = card ? card.closest('[data-failure-results]') : null; const buttons = card ? Array.from(card.querySelectorAll('button')) : [button]; const originalText = button.textContent; const workingText = decision === 'approve' ? 'Adding to KitchenOwl…' : (decision === 'later' ? 'Saving…' : 'Rejecting…'); buttons.forEach((entry) => { entry.disabled = true; }); button.textContent = workingText; setStatusMessage(workingText, 'muted'); try { const data = await fetchJson('/api/suggestions/' + encodeURIComponent(suggestionId) + '/decision', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({decision, import_to_kitchenowl: decision === 'approve'}), }); const suggestion = data.suggestion || {}; const title = suggestion.title || 'Recipe'; const kitchenowl = data.kitchenowl || {}; if (decision === 'approve') { const outcome = kitchenowl.status || suggestion.kitchenowl_status || 'updated'; const verb = outcome === 'duplicate' ? 'already exists in' : 'added to'; setStatusMessage(title + ' ' + verb + ' KitchenOwl. Status: ' + outcome + '.', 'pill pill-good'); } else if (decision === 'later') { setStatusMessage(title + ' saved for later review.', 'pill pill-good'); } else { setStatusMessage(title + ' rejected.', 'pill pill-good'); } await loadDashboard(); if (failurePanel && card) { card.remove(); const remaining = failurePanel.querySelectorAll('[data-suggestion-id]'); if (!remaining.length) { failurePanel.innerHTML = '

    No actionable recipes left in this review set.

    '; failurePanel.classList.remove('muted'); } const failureCard = failurePanel.closest('[data-failure-id]'); if (failureCard) { const count = failureCard.querySelector('.failure-count'); if (count) { const currentText = count.textContent || ''; const match = currentText.match(/(\d+)/); if (match) { const nextCount = Math.max(0, Number(match[1]) - 1); count.textContent = 'Current matching recipes: ' + String(nextCount); } } } } if (document.getElementById('search-results')) { await loadSuggestionsInto(document.getElementById('search-results')); } return data; } catch (error) { setStatusMessage('KitchenOwl action failed: ' + error.message, 'pill status-open'); alert(error.message); } finally { buttons.forEach((entry) => { entry.disabled = false; }); button.textContent = originalText; } } async function createImportFlag(payload) { return fetchJson('/api/import-flags', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload), }); } async function loadImportsInto(target) { const data = await fetchJson('/api/import-flags'); renderImportFlagCards(target, data.items || []); } async function loadSuggestionsInto(target) { const data = await fetchJson('/api/suggestions'); renderSuggestionCards(target, data.items || []); } async function loadFailuresInto(target) { const data = await fetchJson('/api/failed-searches'); renderFailureCards(target, data.items || []); } async function loadFailureResults(failureId, target, button) { if (!target || !failureId) return; if (button) { button.disabled = true; button.textContent = 'Loading…'; } target.textContent = 'Loading recipes for this search…'; target.classList.add('muted'); try { const data = await fetchJson('/api/failed-searches/' + encodeURIComponent(failureId) + '/results'); const results = data.results || []; if (!results.length) { target.innerHTML = '

    Still no usable recipes for this search.

    '; target.classList.remove('muted'); } else { renderSuggestionCards(target, results); } const card = target.closest('[data-failure-id]'); if (card && data.item) { const badge = card.querySelector('.pill'); const count = card.querySelector('.failure-count'); const list = card.querySelector('.failure-title-list'); if (badge) { const resolved = String(data.item.status || '') === 'resolved-with-recipes'; badge.textContent = String(data.item.status || 'open'); badge.className = 'pill ' + (resolved ? 'status-resolved' : 'status-open'); } if (count) { count.textContent = 'Current matching recipes: ' + String(data.item.result_count || 0); } if (list) { const titles = (data.item.sample_titles || []).map((entry) => '
  • ' + escapeHtml(entry) + '
  • ').join(''); list.innerHTML = titles || '
  • No recovered recipe titles yet.
  • '; } } } catch (error) { target.textContent = error.message; } finally { if (button) { button.disabled = false; button.textContent = 'View recipes'; } } } function wireDashboard() { document.addEventListener('click', async (event) => { if (event.target.matches('button[data-decision]')) { await handleDecision(event); return; } if (event.target.matches('button[data-flag-suggestion]')) { const button = event.target; const suggestionId = button.dataset.flagSuggestion; const issueType = window.prompt('What kind of import issue is this? (formatting, ingredients, timings, duplicate, other)', 'formatting'); if (!issueType) return; const notes = window.prompt('What went wrong with this import?'); if (!notes) return; await createImportFlag({suggestion_id: suggestionId, issue_type: issueType, notes}); await loadDashboard(); const importsTarget = document.getElementById('import-flags-list'); if (importsTarget) await loadImportsInto(importsTarget); return; } if (event.target.matches('button[data-import-flag-resolve]')) { const button = event.target; const importFlagId = button.dataset.importFlagResolve; const resolutionNotes = window.prompt('Optional resolution note:', ''); await fetchJson('/api/import-flags/' + encodeURIComponent(importFlagId) + '/resolve', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({resolution_notes: resolutionNotes || ''}), }); await loadDashboard(); const importsTarget = document.getElementById('import-flags-list'); if (importsTarget) await loadImportsInto(importsTarget); return; } if (event.target.matches('button[data-failure-open]')) { const button = event.target; const failureId = button.dataset.failureOpen; const card = button.closest('[data-failure-id]'); const target = card ? card.querySelector('[data-failure-results]') : null; await loadFailureResults(failureId, target, button); return; } if (event.target.matches('button[data-blacklist-name]')) { const name = event.target.dataset.blacklistName; const enabled = event.target.dataset.blacklistEnabled !== '1'; await fetchJson('/api/profile/blacklist', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, enabled}), }); await loadDashboard(); } }); const seedButton = document.getElementById('seed-button'); if (seedButton) { seedButton.addEventListener('click', async () => { seedButton.disabled = true; seedButton.textContent = 'Seeding…'; try { await fetchJson('/api/search/seed', {method: 'POST'}); await loadDashboard(); } catch (error) { alert(error.message); } finally { seedButton.disabled = false; seedButton.textContent = 'Seed suggestion queue'; } }); } const blacklistForm = document.getElementById('blacklist-form'); if (blacklistForm) { blacklistForm.addEventListener('submit', async (event) => { event.preventDefault(); const input = document.getElementById('blacklist-name'); if (!input.value.trim()) return; await fetchJson('/api/profile/blacklist', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: input.value.trim(), enabled: true}), }); input.value = ''; await loadDashboard(); }); } const settingsForm = document.getElementById('settings-form'); if (settingsForm) { settingsForm.addEventListener('submit', async (event) => { event.preventDefault(); await fetchJson('/api/profile/settings', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ notes: document.getElementById('profile-notes').value, auto_import_threshold: Number(document.getElementById('auto-import-threshold').value || 96), auto_import_enabled: document.getElementById('auto-import-enabled').checked, }), }); await loadDashboard(); }); } } function wireSearch() { const form = document.getElementById('search-form'); const urlForm = document.getElementById('url-form'); const queryInput = document.getElementById('search-query'); const submitSearch = async (query) => { const target = document.getElementById('search-results'); const status = document.getElementById('search-status'); if (status) status.textContent = ''; target.textContent = 'Searching and normalizing recipes…'; target.classList.add('muted'); const data = await fetchJson('/api/search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({query, limit: 10}), }); if (status && data.needs_attention) { status.textContent = 'No recipes came back for that search. Doris logged it as a search failure for troubleshooting.'; } else if (status) { status.textContent = ''; } renderSuggestionCards(target, data.results || []); }; const submitUrl = async (url) => { const target = document.getElementById('search-results'); const status = document.getElementById('search-status'); if (status) status.textContent = ''; target.textContent = 'Fetching and formatting that recipe link…'; target.classList.add('muted'); const data = await fetchJson('/api/search/url', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({url}), }); if (status) { status.textContent = 'Recipe link ingested and queued for review.'; } renderSuggestionCards(target, data.suggestion ? [data.suggestion] : []); }; document.querySelectorAll('.example-query').forEach((button) => { button.addEventListener('click', async () => { const query = button.dataset.query || ''; if (queryInput) { queryInput.value = query; } try { await submitSearch(query); } catch (error) { const target = document.getElementById('search-results'); const status = document.getElementById('search-status'); if (status) status.textContent = 'Search failed.'; target.textContent = error.message; } }); }); if (form) { form.addEventListener('submit', async (event) => { event.preventDefault(); const query = queryInput ? queryInput.value.trim() : ''; if (!query) return; try { await submitSearch(query); } catch (error) { const target = document.getElementById('search-results'); const status = document.getElementById('search-status'); if (status) status.textContent = 'Search failed.'; target.textContent = error.message; } }); } if (!urlForm) return; urlForm.addEventListener('submit', async (event) => { event.preventDefault(); const url = document.getElementById('recipe-url').value.trim(); if (!url) return; try { await submitUrl(url); } catch (error) { const target = document.getElementById('search-results'); const status = document.getElementById('search-status'); if (status) status.textContent = 'Recipe link import failed.'; target.textContent = error.message; } }); } function wirePlanner() { const form = document.getElementById('planner-form'); if (!form) return; const weekStart = document.getElementById('week-start'); if (weekStart && !weekStart.value) { weekStart.value = new Date().toISOString().slice(0, 10); } form.addEventListener('submit', async (event) => { event.preventDefault(); const anchors = document.getElementById('planner-anchors').value; const target = document.getElementById('plan-output'); target.textContent = 'Building the week…'; target.classList.add('muted'); try { const data = await fetchJson('/api/planner/generate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ week_start: document.getElementById('week-start').value, target_meals: Number(document.getElementById('target-meals').value || 5), anchors, }), }); const plan = data.plan; const shared = (plan.shared_ingredients || []).map((item) => '' + escapeHtml(item) + '').join(''); target.classList.remove('muted'); target.innerHTML = `

    Week of ${escapeHtml(plan.week_start)}

    ${escapeHtml(plan.summary)}

    ${shared || 'No shared ingredients landed yet.'}
    ${(plan.meals || []).map((meal, index) => `

    Meal ${index + 1}

    ${escapeHtml(meal.title)}

    ${escapeHtml(meal.note)}

    ${(meal.ingredients || []).slice(0, 8).map((item) => '' + escapeHtml(item) + '').join('')}
    `).join('')} `; } catch (error) { target.textContent = error.message; } }); } function wireImports() { const target = document.getElementById('import-flags-list'); if (target) { loadImportsInto(target).catch(() => { target.textContent = 'Could not load import flags.'; }); } const form = document.getElementById('import-flag-form'); if (!form) return; form.addEventListener('submit', async (event) => { event.preventDefault(); const urlEl = document.getElementById('import-flag-url'); const titleEl = document.getElementById('import-flag-title'); const typeEl = document.getElementById('import-flag-type'); const notesEl = document.getElementById('import-flag-notes'); const payload = { recipe_url: urlEl ? urlEl.value.trim() : '', recipe_title: titleEl ? titleEl.value.trim() : '', issue_type: typeEl ? typeEl.value : 'formatting', notes: notesEl ? notesEl.value.trim() : '', }; if (!payload.notes) { alert('Tell me what is wrong with the import first.'); return; } await createImportFlag(payload); if (urlEl) urlEl.value = ''; if (titleEl) titleEl.value = ''; if (notesEl) notesEl.value = ''; await loadDashboard(); if (target) await loadImportsInto(target); }); } function wireFailures() { const target = document.getElementById('failures-list'); if (target) { loadFailuresInto(target).catch(() => { target.textContent = 'Could not load failed searches.'; }); } const refresh = document.getElementById('refresh-failures'); if (refresh && target) { refresh.addEventListener('click', async () => { refresh.disabled = true; refresh.textContent = 'Refreshing…'; try { const data = await fetchJson('/api/failed-searches/refresh', {method: 'POST'}); renderFailureCards(target, data.items || []); } finally { refresh.disabled = false; refresh.textContent = 'Refresh progress'; } }); } } loadDashboard().catch(() => {}); wireDashboard(); wireSearch(); wirePlanner(); wireImports(); wireFailures(); if (document.getElementById('search-results')) { loadSuggestionsInto(document.getElementById('search-results')).catch(() => {}); }