Mealie's "Parse" button on every recipe is per-recipe; clicking through
226 of them is the toil Cobb explicitly hates. The bulk sterilizer wraps
cauldron's existing per-recipe Sterilizer (clawdforge → Sonnet, parses
free-form ingredient strings into structured form) into a household-wide
job with progress tracking, batch human review, and one-shot apply.
Flow:
1. POST /api/sterilize/bulk-start — creates cauldron_sterilize_jobs row,
spawns a daemon thread that walks every recipe in the user's
household. Recipes whose ingredients all already have food.id are
skipped (no point re-parsing Cobb's manual cleanup). For each
recipe needing work, Sonnet returns a structured proposal that gets
persisted to cauldron_sterilize_proposals.
2. GET /api/sterilize/bulk-status — polled every 2s by the UI for
{state, processed_count, skipped_count, error_count, current_slug}.
3. After the walk completes, state moves to 'review'. UI loads
/api/sterilize/bulk-jobs/<id>/proposals and renders one card per
recipe with a was→becomes diff per ingredient. User toggles
approve/skip per recipe.
4. POST /api/sterilize/bulk-apply/<id> with {approved_slugs: [...]}.
A second daemon thread iterates approved proposals, calls
Sterilizer.apply_recipe (which resolves food/unit IDs in Mealie,
creating any missing rows, then PUT /api/recipes/<slug>).
Job state machine: running → review → applying → done (or 'cancelled' /
'failed' along the way). At app startup, fail_stuck_sterilize_jobs()
recovers any 'running' / 'applying' rows older than 10 min with no
progress — covers the case where the daemon thread's gunicorn worker
died mid-job. New job state lives in DB; thread is just a runner.
Concurrency: the start endpoint blocks if the household already has a
running/applying job. Sterilize calls cost clawdforge time and parallel
jobs would race on Mealie writes, so one-at-a-time per household is the
right ceiling.
UI lives at /sterilize. Linked from /me → "tools → bulk sterilize" since
it's a one-off admin action, not part of the daily flow. Mobile-aware,
diff cards expand on click. Disabled "apply" button when no recipes
are selected; preview-error rows can't be approved.
DB: two new tables (migrations 015+016). cauldron_sterilize_jobs tracks
overall job state with last_progress_at for the stuck-job recovery;
cauldron_sterilize_proposals holds per-recipe JSON proposals with the
approval flag and the final apply_at/apply_error.
464 lines
16 KiB
HTML
464 lines
16 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
|
|
sonnet, 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 tbl = document.createElement('table');
|
|
tbl.className = 'diff-table';
|
|
tbl.innerHTML = `
|
|
<thead><tr>
|
|
<th>was</th><th>→</th><th>becomes</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
${p.proposal_json.proposals.map(rowProposal).join('')}
|
|
</tbody>`;
|
|
card.appendChild(tbl);
|
|
}
|
|
return card;
|
|
}
|
|
|
|
function rowProposal(rp) {
|
|
const pa = rp.parsed || {};
|
|
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 newParts = [];
|
|
if (pa.quantity !== null && pa.quantity !== undefined) newParts.push(pa.quantity);
|
|
if (pa.unit) newParts.push(pa.unit);
|
|
if (pa.food) newParts.push(pa.food);
|
|
if (pa.note) newParts.push(`(${pa.note})`);
|
|
const newStr = newParts.length ? newParts.join(' ') : '—';
|
|
|
|
return `<tr>
|
|
<td class="orig">${escapeHtml(wasStr)}</td>
|
|
<td>→</td>
|
|
<td class="new">${escapeHtml(newStr)}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
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 %}
|