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:
1 parent
db58bfa982
commit
435f442761
7 files changed
+82
-20
No files matched your search
+18
-5
@@ -5,7 +5,7 @@ const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
const WHEN = ['time', 'task.done', 'mail.new'], THEN = ['task', 'shopping', 'notify'], MAX_RULES = 30, DAILY_CAP = 20;
|
||||
const WHEN = ['time', 'task.done', 'mail.new', 'pantry.low', 'podcast.new'], THEN = ['task', 'shopping', 'notify'], MAX_RULES = 30, DAILY_CAP = 20;
|
||||
const file = () => path.join(users.dir(), 'automations.json');
|
||||
const load = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const save = l => atomicWrite(file(), JSON.stringify(l, null, 1), { mode: 0o600 });
|
||||
@@ -20,10 +20,11 @@ function clean(b, old) {
|
||||
if (!WHEN.includes(wt)) throw fail(400, 'Choose when it should happen'); if (!THEN.includes(tt)) throw fail(400, 'Choose what it should do');
|
||||
const when = { type: wt }; if (wt === 'time') { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(b.when.at || ''))) throw fail(400, 'Pick a time of day'); when.at = b.when.at; } else when.match = str(b.when.match, 80);
|
||||
if (wt === 'mail.new' && !when.match) throw fail(400, 'Say what the sender or subject should contain (so it is not every email)');
|
||||
if (wt === 'podcast.new' && !when.match) throw fail(400, 'Say which show (part of its name)');
|
||||
const text = str(b.then.text, 200); if (!text) throw fail(400, tt === 'notify' ? 'Write the message' : 'Write the task');
|
||||
return { id: old ? old.id : crypto.randomBytes(5).toString('hex'), name: str(b.name, 60) || (wt === 'time' ? 'Every day at ' + when.at : wt === 'task.done' ? 'When a task is finished' : 'When mail arrives'), on: b.on !== false, when, then: { type: tt, text }, runs: old ? old.runs || 0 : 0, last: old ? old.last || 0 : 0, day: old ? old.day : '', today: old ? old.today || 0 : 0 };
|
||||
return { id: old ? old.id : crypto.randomBytes(5).toString('hex'), name: str(b.name, 60) || (wt === 'time' ? 'Every day at ' + when.at : wt === 'task.done' ? 'When a task is finished' : wt === 'pantry.low' ? 'When the pantry runs low' : wt === 'podcast.new' ? 'When a new episode comes out' : 'When mail arrives'), on: b.on !== false, when, then: { type: tt, text }, runs: old ? old.runs || 0 : 0, last: old ? old.last || 0 : 0, day: old ? old.day : '', today: old ? old.today || 0 : 0 };
|
||||
}
|
||||
const fill = (text, ctx) => text.replace(/\{(title|from|subject)\}/g, (_, k) => str(ctx[k] || ctx.title || '', 120));
|
||||
const fill = (text, ctx) => text.replace(/\{(title|from|subject|show)\}/g, (_, k) => str(ctx[k] || ctx.title || '', 120));
|
||||
/** Do a rule's action for the person it belongs to (run inside their context). */
|
||||
async function act(rule, ctx) {
|
||||
const me = users.current(), text = fill(rule.then.text, ctx);
|
||||
@@ -42,11 +43,13 @@ async function fire(uid, type, ctx) {
|
||||
const u = users.get(uid); if (!u || u.disabled) return;
|
||||
await users.runAs(u, async () => {
|
||||
for (const r of load()) {
|
||||
if (!r.on || r.when.type !== type) continue; const m = r.when.match.toLowerCase(), hay = (type === 'mail.new' ? (ctx.from || '') + ' ' + (ctx.subject || '') : ctx.title || '').toLowerCase();
|
||||
if (!r.on || r.when.type !== type) continue; const m = r.when.match.toLowerCase(), hay = (type === 'mail.new' ? (ctx.from || '') + ' ' + (ctx.subject || '') : type === 'podcast.new' ? (ctx.show || '') + ' ' + (ctx.title || '') : ctx.title || '').toLowerCase();
|
||||
if (m && !hay.includes(m)) continue; await run(r, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Something happened that concerns everyone (the pantry went low, a new episode): run every person's matching rules. */
|
||||
async function fireAll(type, ctx) { for (const u of users.all()) await fire(u.id, type, ctx).catch(() => {}); }
|
||||
/** Daily rules whose time has come, for one person; `lt` is their own { day, hm }. Run once a minute. */
|
||||
async function tickUser(u, lt) {
|
||||
await users.runAs(u, async () => {
|
||||
@@ -56,6 +59,16 @@ async function tickUser(u, lt) {
|
||||
}
|
||||
});
|
||||
}
|
||||
/** New podcast episodes since the last look (not the first look, which only notes what is already there): run the "new episode" rules. Checked every half hour by push.js. */
|
||||
const SEEN = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'podcast-seen.json');
|
||||
async function checkEpisodes(getRecent) {
|
||||
let seen = null; try { seen = JSON.parse(fs.readFileSync(SEEN(), 'utf8')); } catch { /* the first look */ }
|
||||
const eps = await getRecent(5); if (!Array.isArray(eps)) return 0;
|
||||
const ids = new Set(Array.isArray(seen) ? seen : []), fresh = Array.isArray(seen) && seen.length ? eps.filter(e => e.id && !ids.has(e.id)) : [];
|
||||
atomicWrite(SEEN(), JSON.stringify([...new Set([...eps.map(e => e.id), ...ids])].slice(-600)), { mode: 0o600 });
|
||||
for (const e of fresh.slice(0, 10)) await fireAll('podcast.new', { title: e.title, show: e.show });
|
||||
return fresh.length;
|
||||
}
|
||||
const minutes = hm => +hm.slice(0, 2) * 60 + +hm.slice(3);
|
||||
|
||||
async function handle(req, res) {
|
||||
@@ -73,4 +86,4 @@ async function handle(req, res) {
|
||||
throw fail(404, 'Not found');
|
||||
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||
}
|
||||
module.exports = { handle, fire, tickUser };
|
||||
module.exports = { handle, fire, fireAll, tickUser, checkEpisodes };
|
||||
@@ -2,8 +2,8 @@
|
||||
// ---- Automations (Settings): "when this happens, do that" rules for you. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to
|
||||
// the Shopping list, or send yourself a notification. {title}, {from} and {subject} in the text are filled in from what happened.
|
||||
const auApi = (p, o) => jfetch('/api/auto' + p, o), auOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const AU_WHEN = [['time', 'Every day at a time'], ['task.done', 'A task is finished'], ['mail.new', 'New mail arrives']], AU_THEN = [['task', 'Add a task'], ['shopping', 'Add to the Shopping list'], ['notify', 'Notify me']];
|
||||
const auSummary = r => `${r.when.type === 'time' ? 'Every day at ' + r.when.at : r.when.type === 'task.done' ? 'When a task' + (r.when.match ? ' with “' + r.when.match + '”' : '') + ' is finished' : 'When mail from or about “' + r.when.match + '” arrives'} → ${r.then.type === 'task' ? 'add the task' : r.then.type === 'shopping' ? 'add to Shopping' : 'notify me'} “${r.then.text}”`;
|
||||
const AU_WHEN = [['time', 'Every day at a time'], ['task.done', 'A task is finished'], ['mail.new', 'New mail arrives'], ['pantry.low', 'A pantry item runs low'], ['podcast.new', 'A new podcast episode comes out']], AU_THEN = [['task', 'Add a task'], ['shopping', 'Add to the Shopping list'], ['notify', 'Notify me']];
|
||||
const auSummary = r => `${r.when.type === 'time' ? 'Every day at ' + r.when.at : r.when.type === 'task.done' ? 'When a task' + (r.when.match ? ' with “' + r.when.match + '”' : '') + ' is finished' : r.when.type === 'pantry.low' ? 'When ' + (r.when.match ? '“' + r.when.match + '”' : 'a pantry item') + ' runs low' : r.when.type === 'podcast.new' ? 'When a new episode of “' + r.when.match + '” comes out' : 'When mail from or about “' + r.when.match + '” arrives'} → ${r.then.type === 'task' ? 'add the task' : r.then.type === 'shopping' ? 'add to Shopping' : 'notify me'} “${r.then.text}”`;
|
||||
function autoSettings(el) {
|
||||
if (!el) return;
|
||||
const draw = rules => {
|
||||
@@ -20,10 +20,10 @@ function autoSettings(el) {
|
||||
const sh = openSheet(r ? 'Edit automation' : 'New automation', '', `<form id="auf"><label for="aun">Name (optional)</label><input type="text" id="aun" value="${esc(r ? r.name : '')}" autocomplete="off">
|
||||
<label for="auw">When</label><select id="auw">${AU_WHEN.map(([v, l]) => `<option value="${v}"${v === w.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<div id="auwd"></div><label for="aut">Then</label><select id="aut">${AU_THEN.map(([v, l]) => `<option value="${v}"${v === t.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<label for="aux" id="auxl"></label><input type="text" id="aux" value="${esc(t.text)}" autocomplete="off" maxlength="200"><p class="sub" style="margin:4px 0 0">You can use {title} (the task or message) and {from}.</p><div class="err" id="aue"></div>
|
||||
<label for="aux" id="auxl"></label><input type="text" id="aux" value="${esc(t.text)}" autocomplete="off" maxlength="200"><p class="sub" style="margin:4px 0 0">You can use {title} (the task, item or episode), {from} and {show}.</p><div class="err" id="aue"></div>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button>${r ? '<button type="button" class="btn ghost" id="autest">Test</button><button type="button" class="btn danger" id="aude">Delete</button>' : ''}</div></form>`);
|
||||
const label = () => { sh.querySelector('#auxl').textContent = sh.querySelector('#aut').value === 'notify' ? 'Message' : 'Task'; };
|
||||
const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `<label for="aua">At</label><input type="time" id="aua" value="${esc(w.at || '08:00')}" required>` : `<label for="aum">${ty === 'mail.new' ? 'Sender or subject contains' : 'Task title contains (blank: any task)'}</label><input type="text" id="aum" value="${esc(w.type === ty ? w.match || '' : '')}" autocomplete="off"${ty === 'mail.new' ? ' required' : ''}>`; label(); };
|
||||
const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `<label for="aua">At</label><input type="time" id="aua" value="${esc(w.at || '08:00')}" required>` : `<label for="aum">${ty === 'mail.new' ? 'Sender or subject contains' : ty === 'podcast.new' ? 'Show name contains' : ty === 'pantry.low' ? 'Item name contains (blank: any item)' : 'Task title contains (blank: any task)'}</label><input type="text" id="aum" value="${esc(w.type === ty ? w.match || '' : '')}" autocomplete="off"${ty === 'mail.new' || ty === 'podcast.new' ? ' required' : ''}>`; label(); };
|
||||
detail(); label(); sh.querySelector('#auw').onchange = detail; sh.querySelector('#aut').onchange = label;
|
||||
const body = () => { const ty = sh.querySelector('#auw').value; return { id: r && r.id, name: sh.querySelector('#aun').value, on: r ? r.on : true, when: { type: ty, at: ty === 'time' ? sh.querySelector('#aua').value : undefined, match: ty === 'time' ? '' : sh.querySelector('#aum').value }, then: { type: sh.querySelector('#aut').value, text: sh.querySelector('#aux').value } }; };
|
||||
sh.querySelector('#auf').onsubmit = async e => { e.preventDefault(); try { const l = (await auApi('/', auOpt(body()))).rules; closeSheet(); draw(l); toast('Saved'); } catch (er) { sh.querySelector('#aue').textContent = er.message; } };
|
||||
|
||||
+20
-1
@@ -122,7 +122,7 @@ async function rcPlan(start) {
|
||||
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button><button class="btn ghost" id="rcpantry">Pantry</button></div><div id="rcphave"></div></div>`;
|
||||
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
|
||||
document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } });
|
||||
$('#rcpantry').onclick = async () => { let items = []; try { items = (await rcApi('/pantry')).items; } catch {} const el = openSheet('Pantry', 'What you keep in stock. The plan points out planned ingredients you may already have.', `<form id="rcpyf"><label for="rcpyt">One item per line</label><textarea id="rcpyt" rows="8" placeholder="flour salt olive oil">${esc(items.join('\n'))}</textarea><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#rcpyf').onsubmit = async e => { e.preventDefault(); try { await rcApi('/pantry', rcOpt('POST', { items: el.querySelector('#rcpyt').value })); closeSheet(); toast('Pantry saved'); rcPlan(rcYmd(mon)); } catch (er) { toast(er.message); } }; };
|
||||
$('#rcpantry').onclick = () => rcPantry(() => rcPlan(rcYmd(mon)));
|
||||
if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `<p class="sub" style="white-space:normal">You may already have ${esc(names.slice(0, 8).join(', '))}${names.length > 8 ? '…' : ''} (${r.items.length} ingredient${r.items.length === 1 ? '' : 's'} this week). They still go on the shopping list; tick them off there.</p>`; }).catch(() => {});
|
||||
$('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; };
|
||||
}
|
||||
@@ -137,3 +137,22 @@ function rcPlanPick(slug, name, rid, date) {
|
||||
let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
|
||||
}
|
||||
|
||||
/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */
|
||||
async function rcPantry(onClose) {
|
||||
let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); }
|
||||
const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', '<div id="pyl"></div>');
|
||||
const draw = () => {
|
||||
el.querySelector('#pyl').innerHTML = `<form id="pyf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pyn" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="pyq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="pyw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
|
||||
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}</small></span>
|
||||
${x.isLow ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen.</small></span></div>'}</div>
|
||||
<div class="bar"><button class="btn" id="pydone" style="flex:1;justify-content:center">Done</button></div>`;
|
||||
const go = async (p, o) => { try { items = (await rcApi(p, rcOpt('POST', o))).items; draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#pyf').onsubmit = e => { e.preventDefault(); go('/pantry/item', { name: el.querySelector('#pyn').value, qty: el.querySelector('#pyq').value, low: el.querySelector('#pyw').value }); };
|
||||
el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/pantry/adjust', { id, delta: +d }); });
|
||||
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/pantry/delete', { id: b.dataset.x }));
|
||||
el.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
|
||||
el.querySelector('#pydone').onclick = () => { closeSheet(); if (onClose) onClose(); };
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -56,10 +56,11 @@ async function goalReminder(u, dir, st, now) {
|
||||
}
|
||||
|
||||
let lastEvents = 0;
|
||||
let lastDigest = 0;
|
||||
let lastDigest = 0, lastEps = 0;
|
||||
/** One pass over everyone: runs every minute. */
|
||||
async function tick(now = new Date(), force = false) {
|
||||
if (now.getTime() - lastDigest >= 10 * 60e3) { lastDigest = now.getTime(); try { require('./activity').weeklyDigest(now); } catch (e) { console.warn('[push] weekly summary failed:', e.message); } } // (each child's week, to the parents, on Monday morning)
|
||||
if (now.getTime() - lastEps >= 30 * 60e3) { lastEps = now.getTime(); try { await require('./automations').checkEpisodes(n => require('./podcast').recentEpisodes(n)); } catch (e) { console.warn('[push] new episodes check failed:', e.message); } }
|
||||
const checkEvents = force || now.getTime() - lastEvents >= 4.5 * 60e3;
|
||||
if (checkEvents) lastEvents = now.getTime();
|
||||
for (const u of users.all()) {
|
||||
|
||||
+24
-6
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
|
||||
r = await j(await get()); assert.equal(r.rules.length, 4); assert.equal((await j(await get('bo'))).rules.length, 0, 'rules are personal'); assert.equal((await post('/delete', { id: daily.id }, 'bo')).status, 404, 'and cannot be touched by someone else');
|
||||
const cheer = r.rules.find(x => x.name === 'Cheer'); await post('/toggle', { id: cheer.id, on: false }); const n0 = tasksOf(ann).length; await auto.fire(ann.id, 'task.done', { title: 'x' }); assert.equal(tasksOf(ann).length, n0, 'a rule that is off does not run');
|
||||
await post('/', { when: { type: 'task.done', match: 'spam' }, then: { type: 'task', text: 'Again' } }); for (let i = 0; i < 30; i++) await auto.fire(ann.id, 'task.done', { title: 'spam' }); assert.equal(tasksOf(ann).filter(t => t.title === 'Again').length, 20, 'a rule runs at most 20 times a day');
|
||||
// the pantry going low, and new podcast episodes (for everyone's rules; the first look only notes what is there)
|
||||
assert.equal((await post('/', { when: { type: 'podcast.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'a show must be named');
|
||||
await post('/', { when: { type: 'pantry.low', match: 'flour' }, then: { type: 'shopping', text: 'Buy {title}' } }); await post('/', { when: { type: 'podcast.new', match: 'daily' }, then: { type: 'task', text: 'Listen: {title} ({show})' } });
|
||||
const n1 = tasksOf(ann).length; await auto.fireAll('pantry.low', { title: 'Milk' }); assert.equal(tasksOf(ann).length, n1, 'another item does not match'); await auto.fireAll('pantry.low', { title: 'Flour' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy Flour'), 'a low item starts the rule');
|
||||
let eps = [{ id: '1', title: 'Old', show: 'The Daily' }]; const recent = async () => eps; assert.equal(await auto.checkEpisodes(recent), 0, 'the first look only notes what is there'); assert.ok(!tasksOf(ann).some(t => /^Listen/.test(t.title)));
|
||||
eps = [{ id: '2', title: 'Big news', show: 'The Daily' }, { id: '3', title: 'Other', show: 'Cooking Hour' }, ...eps]; assert.equal(await auto.checkEpisodes(recent), 2); assert.deepEqual(tasksOf(ann).filter(t => /^Listen/.test(t.title)).map(t => t.title), ['Listen: Big news (The Daily)'], 'only the show asked for'); assert.equal(await auto.checkEpisodes(recent), 0, 'and not again');
|
||||
assert.equal((await j(await post('/delete', { id: daily.id }))).rules.some(x => x.id === daily.id), false);
|
||||
console.log('automations test ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
|
||||
@@ -62,11 +62,16 @@ const fake = http.createServer((req, res) => {
|
||||
j = await (await post('/plan/shop', { from: '2027-03-02', to: '2027-03-08' })).json(); assert.equal(j.ok, true); assert.equal(j.recipes, 1, 'a recipe planned twice is added once, twice over');
|
||||
assert.equal((await post('/plan/shop', { from: '2027-04-01', to: '2027-04-07' })).status, 400, 'nothing planned, nothing to add');
|
||||
await post('/plan/remove', { id: (await (await get('/plan?from=2027-03-02&to=2027-03-02')).json())['2027-03-02'][0].id }); assert.deepEqual(await (await get('/plan?from=2027-03-02&to=2027-03-02')).json(), {});
|
||||
// the pantry flags planned ingredients you already have ("3 cups flour" is covered by "flour"; butter is not in the pantry)
|
||||
// the pantry: items with a count and a low level; the plan flags planned ingredients you have ("3 cups flour" is covered by "flour"; butter is not in the pantry)
|
||||
await post('/plan/add', { date: '2027-05-03', slug: 'bread', name: 'Bread' });
|
||||
j = await (await post('/pantry', { items: 'Flour\n \nflour, Salt' })).json(); assert.deepEqual(j.items, ['Flour', 'flour', 'Salt'].filter((x, i, a) => a.indexOf(x) === i), 'trimmed and de-duplicated by exact text');
|
||||
assert.equal((await post('/pantry/item', { name: ' ' })).status, 400, 'an item needs a name');
|
||||
j = await (await post('/pantry/item', { name: 'Flour', qty: 3, low: 1 })).json(); assert.deepEqual(j.items.map(x => [x.name, x.qty, x.low, x.isLow]), [['Flour', 3, 1, false]]); const fid = j.items[0].id;
|
||||
j = await (await post('/pantry/item', { name: 'salt', qty: '', low: '' })).json(); assert.equal(j.items.find(x => x.name === 'salt').qty, null, 'an item need not be counted'); assert.equal(j.items.find(x => x.name === 'salt').isLow, false);
|
||||
j = await (await post('/pantry/item', { name: 'FLOUR', qty: 3, low: 1 })).json(); assert.equal(j.items.filter(x => x.name.toLowerCase() === 'flour').length, 1, 'the same name updates the item');
|
||||
j = await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json(); assert.deepEqual(j.items.map(i => i.text), ['3 cups flour']); assert.equal(j.items[0].have.toLowerCase(), 'flour');
|
||||
await post('/pantry', { items: [] }); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty pantry flags nothing');
|
||||
j = await (await post('/pantry/adjust', { id: fid, delta: -2 })).json(); assert.equal(j.items[0].qty, 1); assert.equal(j.items[0].isLow, true, 'at the low level counts as running low'); assert.equal(j.items[0].name, 'Flour', 'low ones come first');
|
||||
j = await (await post('/pantry/adjust', { id: fid, delta: -5 })).json(); assert.equal(j.items[0].qty, 0, 'never below none'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
|
||||
await post('/pantry/delete', { id: fid }); j = await (await get('/pantry')).json(); assert.deepEqual(j.items.map(x => x.name), ['salt']);
|
||||
// a wrong token is explained
|
||||
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
||||
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||
|
||||
Reference in new issue
Block a user