diff --git a/automations.js b/automations.js index 0485f77..7d06a4b 100644 --- a/automations.js +++ b/automations.js @@ -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 }; diff --git a/public/automations-view.js b/public/automations-view.js index 28f2cc6..57f2fa2 100644 --- a/public/automations-view.js +++ b/public/automations-view.js @@ -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', '', `
-

You can use {title} (the task or message) and {from}.

+

You can use {title} (the task, item or episode), {from} and {show}.

${r ? '' : ''}
`); 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(); }; + const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `` : ``; 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; } }; diff --git a/public/recipes-view.js b/public/recipes-view.js index a3ca1c3..df0612c 100644 --- a/public/recipes-view.js +++ b/public/recipes-view.js @@ -122,7 +122,7 @@ async function rcPlan(start) {
`; 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.', `
`); 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 = `

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.

`; }).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 => `${esc(x.name)}`).join('') || '
No recipes found
'; 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.', '
'); + const draw = () => { + el.querySelector('#pyl').innerHTML = `
+
${items.map(x => `
${esc(x.name)}${x.isLow ? ' running low' : ''}${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''} + ${x.isLow ? `` : ''}${x.qty != null ? `` : ''}
`).join('') || '
Nothing yet. Add what you keep in the kitchen.
'}
+
`; + 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(); +} diff --git a/push.js b/push.js index d6af6d8..3d87981 100644 --- a/push.js +++ b/push.js @@ -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()) { diff --git a/recipes.js b/recipes.js index 4b429b9..aff344e 100644 --- a/recipes.js +++ b/recipes.js @@ -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 }); } diff --git a/tools/automations-test.js b/tools/automations-test.js index 05f6696..7b2ec75 100644 --- a/tools/automations-test.js +++ b/tools/automations-test.js @@ -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; } diff --git a/tools/recipes-test.js b/tools/recipes-test.js index 554c54f..b4c54e1 100644 --- a/tools/recipes-test.js +++ b/tools/recipes-test.js @@ -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);