Pantry as an inventory with low levels; automations for a low pantry item and new podcast episodes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 11:29:09 -04:00
1 parent db58bfa982
commit 435f442761
7 files changed
+82 -20

No files matched your search

+24 -6
View File
@@ -104,11 +104,18 @@ const DAY = /^\d{4}-\d{2}-\d{2}$/, MEALS = ['breakfast', 'lunch', 'dinner'], pla
const readPlan = () => { try { const d = JSON.parse(fs.readFileSync(planFile(), 'utf8')); return { days: d && d.days && typeof d.days === 'object' ? d.days : {} }; } catch { return { days: {} }; } };
const writePlan = d => atomicWrite(planFile(), JSON.stringify(d), { mode: 0o600 });
const planBetween = (from, to) => { const out = {}; for (const [k, v] of Object.entries(readPlan().days)) if (k >= from && k <= to) out[k] = v; return out; };
// The pantry: what the family keeps in stock (one shared list). The plan flags planned ingredients that match, so they can be left off the shop.
// The pantry: what the family keeps in stock, one shared list. Each item has a name, how many are left (blank when you do not count it) and the level it counts as
// running low at (blank: never). The meal plan points out planned ingredients you have; an item going low can start an automation and goes on the shopping list in a tap.
const pantryFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pantry.json');
const readPantry = () => { try { const a = JSON.parse(fs.readFileSync(pantryFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100);
const readPantry = () => { try { const a = JSON.parse(fs.readFileSync(pantryFile(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null } : x).filter(x => x && x.name); } catch { return []; } };
const writePantry = l => atomicWrite(pantryFile(), JSON.stringify(l), { mode: 0o600 });
const isLow = x => x.qty != null && x.low != null && x.qty <= x.low;
const pantryView = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, low: x.low, isLow: isLow(x) })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name));
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
const inPantry = (text, items) => { const words = new Set(String(text).toLowerCase().match(/[a-z]+/g) || []), stems = new Set([...words].map(stem)); return items.filter(it => { const ws = (String(it).toLowerCase().match(/[a-z]+/g) || []).map(stem); return ws.length && ws.every(w => stems.has(w)); }); };
const inPantry = (text, items) => { items = items.map(x => typeof x === 'string' ? x : x.name); const words = new Set(String(text).toLowerCase().match(/[a-z]+/g) || []), stems = new Set([...words].map(stem)); return items.filter(it => { const ws = (String(it).toLowerCase().match(/[a-z]+/g) || []).map(stem); return ws.length && ws.every(w => stems.has(w)); }); };
/** An item has just gone low: tell the automations of everyone who asked to hear about it. */
function pantryLow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[recipes] pantry rules failed:', e.message); } }
/** A recipe's ingredients onto Mealie's shopping list, `times` over (Mealie adds up what is already there). */
async function addRecipe(slug, times) {
const r = await mealie('/api/recipes/' + (slugOk(slug) ? slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +times || 1));
@@ -157,10 +164,21 @@ async function handle(req, res) {
if (d.days[b.date].length > 12) throw fail(400, 'That day is full'); writePlan(d); return json(res, 200, { ok: true, id });
}
if (p === '/plan/remove' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), d = readPlan(); for (const k of Object.keys(d.days)) { d.days[k] = d.days[k].filter(e => e.id !== b.id); if (!d.days[k].length) delete d.days[k]; } writePlan(d); return json(res, 200, { ok: true }); }
if (p === '/pantry' && req.method === 'GET') return json(res, 200, { items: readPantry() });
if (p === '/pantry' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e4) || '{}'), items = [...new Set((Array.isArray(b.items) ? b.items : String(b.items || '').split(/[\n,]/)).map(x => String(x).replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60)).filter(Boolean))].slice(0, 300); atomicWrite(pantryFile(), JSON.stringify(items), { mode: 0o600 }); return json(res, 200, { items }); }
if (p === '/pantry' && req.method === 'GET') return json(res, 200, { items: pantryView(readPantry()) });
if (p === '/pantry/item' && req.method === 'POST') { // add an item, or change one
const b = JSON.parse(await readBody(req, 2e3) || '{}'), l = readPantry(), name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name');
const i = b.id ? l.findIndex(x => x.id === b.id) : l.findIndex(x => x.name.toLowerCase() === name.toLowerCase()); if (b.id && i < 0) throw fail(404, 'That item is gone'); if (i < 0 && l.length >= 400) throw fail(400, 'The pantry is full');
const before = i >= 0 ? isLow(l[i]) : false, it = { id: i >= 0 ? l[i].id : crypto.randomBytes(4).toString('hex'), name: i >= 0 && !b.id ? l[i].name : name, qty: num(b.qty), low: num(b.low) }; if (i >= 0) l[i] = it; else l.push(it); writePantry(l);
if (isLow(it) && !before) pantryLow(it);
return json(res, 200, { items: pantryView(l) });
}
if (p === '/pantry/adjust' && req.method === 'POST') { // one more or one less
const b = JSON.parse(await readBody(req, 1e3) || '{}'), l = readPantry(), it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone');
const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + (+b.delta || 0)) * 100) / 100); writePantry(l); if (isLow(it) && !before) pantryLow(it); return json(res, 200, { items: pantryView(l) });
}
if (p === '/pantry/delete' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e3) || '{}'); const l = readPantry().filter(x => x.id !== b.id); writePantry(l); return json(res, 200, { items: pantryView(l) }); }
if (p === '/plan/pantry' && req.method === 'GET') { // which planned ingredients the pantry already covers
const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); const items = readPantry(), out = []; if (!items.length) return json(res, 200, { items: [] });
const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); const items = readPantry().filter(x => x.qty !== 0), out = []; if (!items.length) return json(res, 200, { items: [] });
for (const sl of [...new Set(Object.values(planBetween(from, to)).flat().map(e => e.slug))]) { try { const r = full(await mealie('/api/recipes/' + sl)); for (const i of r.ingredients) { const hit = i.text && inPantry(i.text, items); if (hit && hit.length) out.push({ recipe: r.name, text: i.text, have: hit[0] }); } } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } }
return json(res, 200, { items: out });
}