Big plan-generator addition: each plan can now span multiple meal types,
not just dinner. Default stays dinner-only for back-compat; opt-in via
checkboxes on /plan.
Schema (migrations 028-031):
- cauldron_meal_plan_slots gains meal_type ENUM('breakfast','lunch',
'dinner','snack','dessert','side') NOT NULL DEFAULT 'dinner'.
- Old UNIQUE key (plan_id, day) → (plan_id, day, meal_type) so a
Monday can have breakfast AND lunch AND dinner slots.
- cauldron_meal_plans gains meal_types_json (which meals to plan
for that week — list of strings, defaults to ["dinner"]).
Forge:
- generate_plan accepts meal_types list. Output schema gains meal_type
per slot. Validates expected_total = slots * len(meal_types) and
rejects duplicate (day, meal_type) pairs.
- _build_plan_prompt renders MEAL TYPES TO PLAN block, instructing
Sonnet to match recipe meta.meal_type to slot type (breakfast slot
→ recipe whose meta tags it as breakfast). Falls back gracefully
when the pool is thin for a particular meal type.
Server:
- /api/plan/generate + regenerate accept body.meal_types, persist via
db.set_plan_meal_types.
- plan_view decodes meal_types_json into plan["meal_types_list"] and
builds plan["meal_types_label"] for the readout.
UI (/plan):
- New checkbox row at the top of the pref-block: 🍳 breakfast / 🥪 lunch
/ 🍽️ dinner. Defaults to whatever's persisted (or just dinner).
- Day cards now group multiple meal_type slots per day with small
meal-type tags above each recipe row. Single-meal plans render the
same way they always did (no tag shown when only one meal_type).
- readMealTypes() in JS reads checkboxes and ships in the body.
DB:
- save_plan_slots accepts meal_type per slot, defaults to 'dinner'.
- list_plan_slots orders by day then meal_type via MEAL_ORDER.
==
UX rename: "claude" / "sonnet" → "Sage" across all user-visible copy.
Sage doubles as kitchen-herb (theme fit) and wise advisor (planner
role). The internal field name `sonnet_decision` on consolidate +
dedupe proposals is unchanged (it's a data field, not user-facing).
Renames touched plan, consolidate, dedupe_recipes, list, me,
enrich_recipes, sterilize templates. Cobb can swap to Mim or his own
name later — easy global s/sage/whatever/g.
==
The /list 'clear' button removed earlier today (b4cb48b) — not
re-introduced.
503 lines
17 KiB
HTML
503 lines
17 KiB
HTML
{% extends "_base.html" %}
|
|
{% block title %}Sterilize · Cauldron{% endblock %}
|
|
{% block content %}
|
|
|
|
<style>
|
|
.progress-rail {
|
|
width: 100%; height: 14px;
|
|
background: var(--bg-2);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
margin: 12px 0 6px 0;
|
|
}
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--purple-deep), var(--purple-bright));
|
|
transition: width .3s ease;
|
|
box-shadow: 0 0 12px -2px var(--purple-glow);
|
|
}
|
|
.progress-meta {
|
|
color: var(--bone-dim);
|
|
font-family: var(--mono); font-size: 12px;
|
|
letter-spacing: .1em;
|
|
display: flex; gap: 18px; flex-wrap: wrap;
|
|
}
|
|
.progress-meta strong { color: var(--bone); }
|
|
|
|
.review-bar {
|
|
position: sticky; top: 70px; z-index: 5;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
flex-wrap: wrap; gap: 12px;
|
|
padding: 12px 14px;
|
|
background: var(--bg-2);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.review-bar .left { display: flex; gap: 14px; align-items: center; }
|
|
.review-bar .right { display: flex; gap: 8px; }
|
|
|
|
.proposals-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
|
.proposal-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-left: 3px solid var(--purple-dim);
|
|
border-radius: 6px;
|
|
padding: 12px 14px;
|
|
}
|
|
.proposal-card.rejected { border-left-color: var(--muted); opacity: .55; }
|
|
.proposal-card.approved { border-left-color: var(--green-bright); }
|
|
.proposal-card.errored { border-left-color: var(--crit); }
|
|
|
|
.proposal-head {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 10px; flex-wrap: wrap;
|
|
cursor: pointer; user-select: none;
|
|
}
|
|
.proposal-name {
|
|
color: var(--bone); font-family: var(--serif);
|
|
font-size: 1.05em; line-height: 1.2;
|
|
}
|
|
.proposal-meta {
|
|
color: var(--muted); font-family: var(--mono);
|
|
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
|
|
}
|
|
.toggle {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 4px 10px; border-radius: 999px;
|
|
border: 1px solid var(--line);
|
|
background: var(--bg-2);
|
|
color: var(--bone-dim);
|
|
font-family: var(--mono); font-size: 11px; letter-spacing: .1em;
|
|
cursor: pointer; min-height: 28px;
|
|
}
|
|
.toggle.on { background: rgba(110,168,72,.18); border-color: var(--green-dim); color: var(--green-bright); }
|
|
.toggle.off { background: rgba(232,96,106,.12); border-color: rgba(232,96,106,.3); color: var(--crit); }
|
|
|
|
.diff-table {
|
|
width: 100%; border-collapse: collapse;
|
|
margin-top: 10px; font-size: 13px;
|
|
display: none;
|
|
}
|
|
.proposal-card.expanded .diff-table { display: table; }
|
|
.diff-table th {
|
|
text-align: left; color: var(--purple); font-family: var(--mono);
|
|
font-size: 10px; letter-spacing: .15em; text-transform: uppercase;
|
|
padding: 4px 8px; border-bottom: 1px solid var(--line);
|
|
}
|
|
.diff-table td {
|
|
padding: 4px 8px; vertical-align: top;
|
|
border-bottom: 1px solid var(--line-soft);
|
|
color: var(--bone-dim);
|
|
}
|
|
.diff-table .orig { color: var(--bone-dim); font-style: italic; }
|
|
.diff-table .new { color: var(--bone); }
|
|
|
|
.pill-error { background: rgba(232,96,106,.15); color: var(--crit); }
|
|
.empty-state {
|
|
padding: 28px 14px; text-align: center;
|
|
color: var(--bone-dim);
|
|
}
|
|
</style>
|
|
|
|
<div class="page-head">
|
|
<div class="crumb">// sterilize · bulk recipe parser</div>
|
|
<h1>bulk <span class="accent">sterilize</span></h1>
|
|
<div class="lede">
|
|
walk every recipe in the household, send the unparsed ingredients to
|
|
sage, review the proposals, apply the good ones. mealie's food
|
|
table gets cleaner; the shopping list math gets sharper.
|
|
</div>
|
|
</div>
|
|
|
|
<section class="panel" id="sterilize-shell">
|
|
<div class="panel-head">
|
|
<h2>state</h2>
|
|
<span class="pill" id="state-pill">loading…</span>
|
|
<span class="ctx" id="state-ctx"></span>
|
|
</div>
|
|
|
|
<div id="empty-pane" style="display: none;">
|
|
<p>nothing's running. kick off a bulk parse?</p>
|
|
<button class="btn btn-purple" id="start-btn" type="button" onclick="startBulk()">🪄 start bulk sterilize</button>
|
|
<p class="muted" style="margin-top: 8px;">
|
|
cost: ~5s/recipe via clawdforge. your 226-recipe sweep takes ~15-20 min.
|
|
</p>
|
|
</div>
|
|
|
|
<div id="progress-pane" style="display: none;">
|
|
<div class="progress-rail"><div class="progress-fill" id="bar" style="width: 0%;"></div></div>
|
|
<div class="progress-meta">
|
|
<span><strong id="processed">0</strong> processed</span>
|
|
<span><strong id="skipped">0</strong> already-clean</span>
|
|
<span><strong id="errors">0</strong> errors</span>
|
|
<span>of <strong id="total">?</strong></span>
|
|
<span class="muted" id="current-slug"></span>
|
|
</div>
|
|
<div class="btn-row" style="margin-top: 12px;">
|
|
<button class="btn" type="button" onclick="cancelJob()">cancel</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="review-pane" style="display: none;">
|
|
<div class="review-bar">
|
|
<div class="left">
|
|
<span><strong id="approved-count">0</strong> selected</span>
|
|
<span class="muted" id="review-meta"></span>
|
|
</div>
|
|
<div class="right">
|
|
<button class="btn" type="button" onclick="setAll(true)">select all</button>
|
|
<button class="btn" type="button" onclick="setAll(false)">clear</button>
|
|
<button class="btn btn-purple" type="button" id="apply-btn" onclick="applyApproved()">apply selected →</button>
|
|
</div>
|
|
</div>
|
|
<div class="proposals-grid" id="proposals-grid"></div>
|
|
</div>
|
|
|
|
<div id="done-pane" style="display: none;">
|
|
<p id="done-line"></p>
|
|
<button class="btn btn-purple" type="button" onclick="startBulk()">↻ start a new run</button>
|
|
</div>
|
|
|
|
<div id="failed-pane" style="display: none;">
|
|
<p style="color: var(--crit);" id="failed-line"></p>
|
|
<button class="btn btn-purple" type="button" onclick="startBulk()">↻ retry</button>
|
|
</div>
|
|
</section>
|
|
|
|
<script>
|
|
let job = {{ (latest_job | tojson) if latest_job else 'null' }};
|
|
let pollTimer = null;
|
|
let proposals = [];
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
|
|
function showPane(name) {
|
|
for (const p of ['empty', 'progress', 'review', 'done', 'failed']) {
|
|
$(`${p}-pane`).style.display = (p === name) ? '' : 'none';
|
|
}
|
|
}
|
|
|
|
function setStatePill(text, klass) {
|
|
const el = $('state-pill');
|
|
el.textContent = text;
|
|
el.className = 'pill ' + (klass || 'pill-mute');
|
|
}
|
|
|
|
function paintProgress() {
|
|
if (!job) return;
|
|
const total = job.total_recipes || 0;
|
|
const done = (job.processed_count || 0) + (job.skipped_count || 0) + (job.error_count || 0);
|
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
$('bar').style.width = pct + '%';
|
|
$('processed').textContent = job.processed_count || 0;
|
|
$('skipped').textContent = job.skipped_count || 0;
|
|
$('errors').textContent = job.error_count || 0;
|
|
$('total').textContent = total || '?';
|
|
$('current-slug').textContent = job.current_slug ? `· ${job.current_slug}` : '';
|
|
}
|
|
|
|
async function fetchJob() {
|
|
try {
|
|
const r = await fetch('/api/sterilize/bulk-status');
|
|
const data = await r.json();
|
|
job = data.job || null;
|
|
route();
|
|
} catch (e) {
|
|
console.error('status poll failed', e);
|
|
}
|
|
}
|
|
|
|
function route() {
|
|
if (!job) {
|
|
stopPolling();
|
|
setStatePill('idle', 'pill-mute');
|
|
$('state-ctx').textContent = '';
|
|
showPane('empty');
|
|
return;
|
|
}
|
|
const state = job.state;
|
|
$('state-ctx').textContent = `started ${new Date(job.started_at).toLocaleString()}`;
|
|
if (state === 'running') {
|
|
setStatePill('walking', 'pill-ok');
|
|
paintProgress();
|
|
showPane('progress');
|
|
startPolling();
|
|
} else if (state === 'review') {
|
|
setStatePill('review', 'pill-ok');
|
|
paintProgress();
|
|
showPane('review');
|
|
stopPolling();
|
|
loadProposals();
|
|
} else if (state === 'applying') {
|
|
setStatePill('applying', 'pill-ok');
|
|
paintProgress();
|
|
showPane('progress');
|
|
startPolling();
|
|
} else if (state === 'done') {
|
|
setStatePill('done', 'pill-mute');
|
|
const total = job.processed_count || 0;
|
|
$('done-line').textContent = `applied ${total} recipe${total === 1 ? '' : 's'}. mealie should look cleaner.`;
|
|
showPane('done');
|
|
stopPolling();
|
|
} else if (state === 'failed') {
|
|
setStatePill('failed', 'pill-mute');
|
|
$('failed-line').textContent = job.last_error || 'job failed.';
|
|
showPane('failed');
|
|
stopPolling();
|
|
} else if (state === 'cancelled') {
|
|
setStatePill('cancelled', 'pill-mute');
|
|
$('done-line').textContent = 'job cancelled.';
|
|
showPane('done');
|
|
stopPolling();
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (pollTimer) return;
|
|
pollTimer = setInterval(fetchJob, 2000);
|
|
}
|
|
function stopPolling() {
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
}
|
|
|
|
async function startBulk() {
|
|
const btn = $('start-btn');
|
|
if (btn) { btn.disabled = true; btn.textContent = 'kicking off…'; }
|
|
try {
|
|
const r = await fetch('/api/sterilize/bulk-start', { method: 'POST' });
|
|
if (!r.ok) {
|
|
const j = await r.json().catch(() => ({}));
|
|
throw new Error(j.error || r.status);
|
|
}
|
|
await fetchJob();
|
|
} catch (e) {
|
|
alert('start failed: ' + e.message);
|
|
if (btn) { btn.disabled = false; btn.textContent = '🪄 start bulk sterilize'; }
|
|
}
|
|
}
|
|
|
|
async function cancelJob() {
|
|
if (!job) return;
|
|
if (!confirm('cancel this bulk run?')) return;
|
|
try {
|
|
await fetch('/api/sterilize/bulk-cancel/' + job.id, { method: 'POST' });
|
|
await fetchJob();
|
|
} catch (e) {
|
|
alert('cancel failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function loadProposals() {
|
|
if (!job) return;
|
|
try {
|
|
const r = await fetch('/api/sterilize/bulk-jobs/' + job.id + '/proposals');
|
|
const data = await r.json();
|
|
proposals = data.proposals || [];
|
|
// default: approve everything that has no preview_error
|
|
for (const p of proposals) {
|
|
if (p.approved === null || p.approved === undefined) {
|
|
p.approved = !p.preview_error;
|
|
}
|
|
}
|
|
renderProposals();
|
|
} catch (e) {
|
|
console.error('proposals load failed', e);
|
|
}
|
|
}
|
|
|
|
function renderProposals() {
|
|
const grid = $('proposals-grid');
|
|
grid.innerHTML = '';
|
|
let approvedCount = 0;
|
|
if (!proposals.length) {
|
|
grid.innerHTML = '<div class="empty-state">no proposals — every recipe was already clean.</div>';
|
|
$('apply-btn').disabled = true;
|
|
$('approved-count').textContent = 0;
|
|
$('review-meta').textContent = '';
|
|
return;
|
|
}
|
|
for (const p of proposals) {
|
|
if (p.approved) approvedCount++;
|
|
grid.appendChild(renderOne(p));
|
|
}
|
|
$('approved-count').textContent = approvedCount;
|
|
const total = proposals.filter(p => !p.preview_error).length;
|
|
$('review-meta').textContent = `· ${total} parseable, ${proposals.length - total} errored`;
|
|
$('apply-btn').disabled = approvedCount === 0;
|
|
}
|
|
|
|
function renderOne(p) {
|
|
const card = document.createElement('div');
|
|
card.className = 'proposal-card';
|
|
if (p.preview_error) card.classList.add('errored');
|
|
else if (p.approved) card.classList.add('approved');
|
|
else card.classList.add('rejected');
|
|
|
|
const head = document.createElement('div');
|
|
head.className = 'proposal-head';
|
|
|
|
const left = document.createElement('div');
|
|
left.style.flex = '1';
|
|
const nm = document.createElement('div');
|
|
nm.className = 'proposal-name';
|
|
nm.textContent = p.recipe_name || p.recipe_slug;
|
|
const meta = document.createElement('div');
|
|
meta.className = 'proposal-meta';
|
|
if (p.preview_error) {
|
|
meta.innerHTML = '<span class="pill pill-error">parse failed</span> ' + escapeHtml(p.preview_error);
|
|
} else {
|
|
meta.textContent = `${p.ingredient_count} ingredient${p.ingredient_count === 1 ? '' : 's'}` +
|
|
(p.applied_at ? ` · applied ${new Date(p.applied_at).toLocaleString()}` : '');
|
|
}
|
|
left.appendChild(nm);
|
|
left.appendChild(meta);
|
|
|
|
const toggle = document.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.className = 'toggle ' + (p.approved ? 'on' : 'off');
|
|
toggle.textContent = p.approved ? 'approved' : 'skip';
|
|
toggle.disabled = !!p.preview_error;
|
|
toggle.onclick = (e) => { e.stopPropagation(); flipApproval(p, card, toggle); };
|
|
|
|
head.appendChild(left);
|
|
head.appendChild(toggle);
|
|
|
|
head.onclick = () => { card.classList.toggle('expanded'); };
|
|
|
|
card.appendChild(head);
|
|
|
|
if (!p.preview_error && p.proposal_json && p.proposal_json.proposals) {
|
|
const rows = p.proposal_json.proposals.map(rowProposal).join('');
|
|
if (rows.trim()) {
|
|
const tbl = document.createElement('table');
|
|
tbl.className = 'diff-table';
|
|
tbl.innerHTML = `
|
|
<thead><tr><th>was</th><th>→</th><th>becomes</th></tr></thead>
|
|
<tbody>${rows}</tbody>`;
|
|
card.appendChild(tbl);
|
|
} else {
|
|
// All rows were identity matches — sage thinks this recipe is
|
|
// already clean. Show a marker; user can still skip/approve as
|
|
// a no-op apply (which will be cheap, just refreshes food.id
|
|
// resolution if any food row got renamed in Mealie).
|
|
const note = document.createElement('div');
|
|
note.className = 'proposal-meta';
|
|
note.style.marginTop = '6px';
|
|
note.textContent = 'no changes proposed (all ingredients already matched)';
|
|
card.appendChild(note);
|
|
}
|
|
}
|
|
return card;
|
|
}
|
|
|
|
function renderItem(pa) {
|
|
const parts = [];
|
|
if (pa.quantity !== null && pa.quantity !== undefined) parts.push(pa.quantity);
|
|
if (pa.unit) parts.push(pa.unit);
|
|
if (pa.food) parts.push(pa.food);
|
|
if (pa.note) parts.push(`(${pa.note})`);
|
|
return parts.length ? parts.join(' ') : '—';
|
|
}
|
|
|
|
function rowProposal(rp) {
|
|
// Render one or more rows for a single proposal. Fan-out shows
|
|
// multiple "becomes" rows under one "was" cell; identity rows
|
|
// (parse matches original verbatim) are dropped so the table
|
|
// only displays actual changes.
|
|
const wasParts = [];
|
|
if (rp.original_quantity !== null && rp.original_quantity !== undefined) wasParts.push(rp.original_quantity);
|
|
if (rp.original_unit_name) wasParts.push(rp.original_unit_name);
|
|
if (rp.original_food_name) wasParts.push(rp.original_food_name);
|
|
if (rp.original_note) wasParts.push(`(${rp.original_note})`);
|
|
const wasStr = wasParts.length ? wasParts.join(' ') : (rp.original_display || '—');
|
|
|
|
const items = Array.isArray(rp.parsed_items) ? rp.parsed_items
|
|
: (rp.parsed ? [rp.parsed] : []);
|
|
const fanout = items.length > 1;
|
|
const renderedNew = items.map(renderItem);
|
|
|
|
// Decide which rows to keep. In the 1→1 case, drop if was==new.
|
|
// In the fan-out case, always show every child (even if one happens
|
|
// to match a piece of the original).
|
|
let rows;
|
|
if (!fanout) {
|
|
if (renderedNew.length === 0 || renderedNew[0] === wasStr) return '';
|
|
rows = renderedNew;
|
|
} else {
|
|
rows = renderedNew;
|
|
}
|
|
if (!rows.length) return '';
|
|
|
|
const arrow = fanout
|
|
? `→<sup style="color:var(--purple-bright); margin-left:2px;">${rows.length}</sup>`
|
|
: '→';
|
|
|
|
return rows.map((newStr, idx) => {
|
|
const wasCell = (idx === 0)
|
|
? `<td class="orig" rowspan="${rows.length}">${escapeHtml(wasStr)}</td>`
|
|
: '';
|
|
const arrowCell = (idx === 0)
|
|
? `<td rowspan="${rows.length}">${arrow}</td>`
|
|
: '';
|
|
return `<tr>${wasCell}${arrowCell}<td class="new">${escapeHtml(newStr)}</td></tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, m => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[m]));
|
|
}
|
|
|
|
function flipApproval(p, card, toggle) {
|
|
p.approved = !p.approved;
|
|
card.classList.toggle('approved', p.approved);
|
|
card.classList.toggle('rejected', !p.approved);
|
|
toggle.classList.toggle('on', p.approved);
|
|
toggle.classList.toggle('off', !p.approved);
|
|
toggle.textContent = p.approved ? 'approved' : 'skip';
|
|
const cnt = proposals.filter(x => x.approved).length;
|
|
$('approved-count').textContent = cnt;
|
|
$('apply-btn').disabled = cnt === 0;
|
|
}
|
|
|
|
function setAll(on) {
|
|
for (const p of proposals) {
|
|
if (p.preview_error) continue;
|
|
p.approved = on;
|
|
}
|
|
renderProposals();
|
|
}
|
|
|
|
async function applyApproved() {
|
|
const slugs = proposals.filter(p => p.approved && !p.preview_error).map(p => p.recipe_slug);
|
|
if (!slugs.length) return;
|
|
if (!confirm(`apply ${slugs.length} recipe${slugs.length === 1 ? '' : 's'} to mealie?`)) return;
|
|
const btn = $('apply-btn');
|
|
btn.disabled = true; btn.textContent = 'applying…';
|
|
try {
|
|
const r = await fetch('/api/sterilize/bulk-apply/' + job.id, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ approved_slugs: slugs }),
|
|
});
|
|
if (!r.ok) {
|
|
const j = await r.json().catch(() => ({}));
|
|
throw new Error(j.error || r.status);
|
|
}
|
|
await fetchJob();
|
|
} catch (e) {
|
|
alert('apply failed: ' + e.message);
|
|
btn.disabled = false; btn.textContent = 'apply selected →';
|
|
}
|
|
}
|
|
|
|
// Boot
|
|
route();
|
|
// If we landed on a page mid-run, start polling immediately
|
|
if (job && (job.state === 'running' || job.state === 'applying')) startPolling();
|
|
</script>
|
|
|
|
{% endblock %}
|