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.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 `
Logged query
${escapeHtml(item.query)}
${escapeHtml(status)}
Logged: ${escapeHtml(item.created_at || '')}
${candidateQueries || 'No candidate queries captured'}
Current matching recipes: ${escapeHtml(item.result_count || 0)}
${titles || '- No recovered recipe titles yet.
'}
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.issue_type || 'issue')}
${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}
${history || '- No extra notes logged yet.
'}
${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.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(() => {});
}