Pantry units and recipes: amounts in pounds, cups and so on, Use some, and Cooked it with a preview of what comes out
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac55520a8c
commit
c236120a72
11 files changed
+204
-21
No files matched your search
@@ -35,6 +35,7 @@ jobs:
|
|||||||
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||||
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
||||||
- run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take
|
- run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take
|
||||||
|
- run: node tools/units-test.js # units: reading recipe lines, converting, what a recipe takes out of the pantry
|
||||||
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
||||||
|
|
||||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
|||||||
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
||||||
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
||||||
- [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
|
- [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
|
||||||
- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand and by scanning; they do not go down when you cook. The Pantry lives at Recipes > Pantry and works without Mealie. A phone can scan a barcode to add or use one (several barcodes can belong to one item, so different brands of corn share a count), and a Take one screen lets anyone, children included, tap an item to take one; children can do nothing else to the pantry.**
|
- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry items can have units (10 lb of flour); **Cooked it** on a recipe (or on a meal plan entry) shows what it would take out, converting cups to pounds for common ingredients, and takes it out when confirmed; **Use some** takes an amount in any convertible unit. These are estimates, and lines that cannot be converted (a whole onion against pounds of onions, pinches) are left alone and listed. The Pantry lives at Recipes > Pantry and works without Mealie. A phone can scan a barcode to add or use one (several barcodes can belong to one item, so different brands of corn share a count), and a Take one screen lets anyone, children included, tap an item to take one; children can do nothing else to the pantry.**
|
||||||
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
|
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
|
||||||
|
|
||||||
## 3. Kids and safety
|
## 3. Kids and safety
|
||||||
|
|||||||
@@ -6,28 +6,30 @@ const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
|||||||
const atomicWrite = require('./atomic');
|
const atomicWrite = require('./atomic');
|
||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
const { readBody } = require('./body');
|
const { readBody } = require('./body');
|
||||||
|
const U = require('./units');
|
||||||
|
|
||||||
const DATA = () => process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA = () => process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
const file = () => path.join(DATA(), 'pantry.json'), cacheFile = () => path.join(DATA(), 'barcode-cache.json');
|
const file = () => path.join(DATA(), 'pantry.json'), cacheFile = () => path.join(DATA(), 'barcode-cache.json');
|
||||||
const OFF = () => (process.env.OPENFOODFACTS_URL || 'https://world.openfoodfacts.org').replace(/\/+$/, '');
|
const OFF = () => (process.env.OPENFOODFACTS_URL || 'https://world.openfoodfacts.org').replace(/\/+$/, '');
|
||||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
const unitClean = v => U.UNITS.includes(String(v || '')) ? String(v || '') : '';
|
||||||
const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100);
|
const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100);
|
||||||
const CODE = /^\d{8,14}$/;
|
const CODE = /^\d{8,14}$/;
|
||||||
|
|
||||||
const read = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null, codes: [] } : { ...x, codes: Array.isArray(x.codes) ? x.codes : [] }).filter(x => x && x.name); } catch { return []; } };
|
const read = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null, unit: '', codes: [] } : { ...x, unit: unitClean(x.unit), codes: Array.isArray(x.codes) ? x.codes : [] }).filter(x => x && x.name); } catch { return []; } };
|
||||||
const write = l => atomicWrite(file(), JSON.stringify(l), { mode: 0o600 });
|
const write = l => atomicWrite(file(), JSON.stringify(l), { mode: 0o600 });
|
||||||
const isLow = x => x.qty != null && x.low != null && x.qty <= x.low;
|
const isLow = x => x.qty != null && x.low != null && x.qty <= x.low;
|
||||||
const view = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, low: x.low, isLow: isLow(x), codes: x.codes })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name));
|
const view = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, unit: x.unit || '', label: U.fmt(x.qty, x.unit || ''), low: x.low, lowLabel: U.fmt(x.low, x.unit || ''), isLow: isLow(x), codes: x.codes })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name));
|
||||||
// Who took what: the last few uses, so parents can see where the snacks went.
|
// Who took what: the last few uses, so parents can see where the snacks went.
|
||||||
const logFile = () => path.join(DATA(), 'pantry-log.json');
|
const logFile = () => path.join(DATA(), 'pantry-log.json');
|
||||||
const readLog = () => { try { const a = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
const readLog = () => { try { const a = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||||
function logUse(me, it, delta) { const l = readLog(); l.unshift({ t: Date.now(), who: me.name, item: it.name, delta }); atomicWrite(logFile(), JSON.stringify(l.slice(0, 60)), { mode: 0o600 }); }
|
function logUse(me, it, delta, note) { const l = readLog(); l.unshift({ t: Date.now(), who: me.name, item: it.name, delta, ...(note ? { note: String(note).slice(0, 80) } : {}) }); atomicWrite(logFile(), JSON.stringify(l.slice(0, 60)), { mode: 0o600 }); }
|
||||||
const isKid = u => !!(u && u.kid && u.kid.on);
|
const isKid = u => !!(u && u.kid && u.kid.on);
|
||||||
/** An item has just gone low: tell the automations of everyone who asked to hear about it. */
|
/** An item has just gone low: tell the automations of everyone who asked to hear about it. */
|
||||||
function lowNow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[pantry] rules failed:', e.message); } }
|
function lowNow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[pantry] rules failed:', e.message); } }
|
||||||
/** Change an item's count, firing the "went low" rules when it crosses into low. */
|
/** Change an item's count, firing the "went low" rules when it crosses into low. */
|
||||||
function bump(it, delta) { const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + delta) * 100) / 100); if (isLow(it) && !before) lowNow(it); }
|
function bump(it, delta) { const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + delta) * 10000) / 10000); if (isLow(it) && !before) lowNow(it); }
|
||||||
|
|
||||||
// ---- matching words: "3 cups flour" has "flour"; "Green Giant Sweet Corn" has "Corn"
|
// ---- matching words: "3 cups flour" has "flour"; "Green Giant Sweet Corn" has "Corn"
|
||||||
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
|
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
|
||||||
@@ -70,6 +72,7 @@ async function handle(req, res) {
|
|||||||
const b = JSON.parse(await readBody(req, 4e3) || '{}'), l = read();
|
const b = JSON.parse(await readBody(req, 4e3) || '{}'), l = read();
|
||||||
if (p === '/use') { // the simple way: take one (or put it back, to undo). Open to everyone, children included.
|
if (p === '/use') { // the simple way: take one (or put it back, to undo). Open to everyone, children included.
|
||||||
const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); const delta = b.undo ? 1 : -1;
|
const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); const delta = b.undo ? 1 : -1;
|
||||||
|
if (it.unit) throw fail(409, 'Use an amount for ' + it.name.toLowerCase() + ' (it is kept in ' + it.unit + ')');
|
||||||
if (it.qty != null) { if (delta < 0 && it.qty <= 0) throw fail(409, 'There is none left of ' + it.name); bump(it, delta); write(l); }
|
if (it.qty != null) { if (delta < 0 && it.qty <= 0) throw fail(409, 'There is none left of ' + it.name); bump(it, delta); write(l); }
|
||||||
logUse(me, it, b.undo ? 0 : -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, items: view(l), recent: readLog().slice(0, 12) });
|
logUse(me, it, b.undo ? 0 : -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, items: view(l), recent: readLog().slice(0, 12) });
|
||||||
}
|
}
|
||||||
@@ -77,19 +80,33 @@ async function handle(req, res) {
|
|||||||
if (p === '/item') { // add an item, or change one (the same name changes the existing item)
|
if (p === '/item') { // add an item, or change one (the same name changes the existing item)
|
||||||
const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name');
|
const 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 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), codes: i >= 0 ? l[i].codes : [] };
|
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), unit: unitClean(b.unit), low: num(b.low), codes: i >= 0 ? l[i].codes : [] };
|
||||||
if (i >= 0) l[i] = it; else l.push(it); write(l); if (isLow(it) && !before) lowNow(it); return json(res, 200, { items: view(l) });
|
if (i >= 0) l[i] = it; else l.push(it); write(l); if (isLow(it) && !before) lowNow(it); return json(res, 200, { items: view(l) });
|
||||||
}
|
}
|
||||||
if (p === '/adjust') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); bump(it, +b.delta || 0); write(l); return json(res, 200, { items: view(l) }); }
|
if (p === '/adjust') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); bump(it, +b.delta || 0); write(l); return json(res, 200, { items: view(l) }); }
|
||||||
if (p === '/delete') { const k = l.filter(x => x.id !== b.id); write(k); return json(res, 200, { items: view(k) }); }
|
if (p === '/delete') { const k = l.filter(x => x.id !== b.id); write(k); return json(res, 200, { items: view(k) }); }
|
||||||
|
if (p === '/take') { // take amounts out (cooking, or a spoonful from the bag): [{ id, amount, unit }], the unit being any that can be turned into the item's own
|
||||||
|
const takes = Array.isArray(b.takes) ? b.takes.slice(0, 80) : [], done = [], skipped = [];
|
||||||
|
for (const t of takes) {
|
||||||
|
const it = l.find(x => x.id === t.id), amt = +t.amount; if (!it || !(amt > 0)) { skipped.push({ id: t.id, why: 'nothing to take' }); continue; }
|
||||||
|
if (it.qty == null) { skipped.push({ id: it.id, name: it.name, why: 'not counted' }); continue; }
|
||||||
|
const unit = t.unit == null ? (it.unit || '') : U.unitOf(t.unit) || '', c = unit === (it.unit || '') ? amt : U.convert(amt, unit, it.unit || '', it.name);
|
||||||
|
if (c == null) { skipped.push({ id: it.id, name: it.name, why: `cannot turn ${unit || 'a count'} into ${it.unit || 'a count'} for ${it.name.toLowerCase()}` }); continue; }
|
||||||
|
const before = it.qty; bump(it, -Math.round(c * 10000) / 10000); done.push({ id: it.id, name: it.name, took: Math.round((before - it.qty) * 10000) / 10000, label: U.fmt(it.qty, it.unit || ''), isLow: isLow(it) });
|
||||||
|
}
|
||||||
|
if (done.length) { write(l); const first = done.map(x => x.name).slice(0, 3).join(', '); logUse(me, { name: first + (done.length > 3 ? '…' : '') }, -1, b.note || 'cooked'); }
|
||||||
|
return json(res, 200, { done, skipped, items: view(l), recent: readLog().slice(0, 12) });
|
||||||
|
}
|
||||||
if (p === '/scan') { // one scanned: add one ("add") or use one ("use"). A new barcode must say which item it is (itemId) or name a new one (name).
|
if (p === '/scan') { // one scanned: add one ("add") or use one ("use"). A new barcode must say which item it is (itemId) or name a new one (name).
|
||||||
const code = String(b.code || ''); if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)'); const delta = b.mode === 'use' ? -1 : 1;
|
const code = String(b.code || ''); if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)'); const delta = b.mode === 'use' ? -1 : 1;
|
||||||
let it = l.find(x => x.codes.includes(code)), made = false;
|
let it = l.find(x => x.codes.includes(code)), made = false;
|
||||||
if (!it && b.itemId) { it = l.find(x => x.id === b.itemId); if (!it) throw fail(404, 'That item is gone'); }
|
if (!it && b.itemId) { it = l.find(x => x.id === b.itemId); if (!it) throw fail(404, 'That item is gone'); }
|
||||||
if (!it && String(b.name || '').trim()) { const name = String(b.name).replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); it = l.find(x => x.name.toLowerCase() === name.toLowerCase()); if (!it) { if (l.length >= 400) throw fail(400, 'The pantry is full'); it = { id: crypto.randomBytes(4).toString('hex'), name, qty: 0, low: null, codes: [] }; l.push(it); made = true; } }
|
if (!it && String(b.name || '').trim()) { const name = String(b.name).replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); it = l.find(x => x.name.toLowerCase() === name.toLowerCase()); if (!it) { if (l.length >= 400) throw fail(400, 'The pantry is full'); it = { id: crypto.randomBytes(4).toString('hex'), name, qty: 0, low: null, unit: unitClean(b.unit), codes: [] }; l.push(it); made = true; } }
|
||||||
if (!it) throw fail(400, 'Say which item this is');
|
if (!it) throw fail(400, 'Say which item this is');
|
||||||
if (!it.codes.includes(code)) { for (const x of l) x.codes = x.codes.filter(c => c !== code); it.codes.push(code); it.codes = it.codes.slice(-30); }
|
if (!it.codes.includes(code)) { for (const x of l) x.codes = x.codes.filter(c => c !== code); it.codes.push(code); it.codes = it.codes.slice(-30); }
|
||||||
bump(it, delta); write(l); if (delta < 0) logUse(me, it, -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, made, items: view(l), recent: readLog().slice(0, 12) });
|
let step = delta; // an item measured in pounds, cups and so on goes up and down by the size of the package scanned (a 15 oz can into an item in ounces); a counted item by one
|
||||||
|
if (it.unit) { const pk = (loadCache()[code] || {}).quantity, pi = pk ? U.parseIngredient(pk) : null; if (pi && pi.amount && pi.unit) { const c = U.convert(pi.amount, pi.unit, it.unit, it.name); if (c && c > 0) step = delta * Math.round(c * 100) / 100; } }
|
||||||
|
bump(it, step); write(l); if (delta < 0) logUse(me, it, -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, label: U.fmt(it.qty, it.unit || ''), isLow: isLow(it) }, step, made, items: view(l), recent: readLog().slice(0, 12) });
|
||||||
}
|
}
|
||||||
if (p === '/unlink') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); it.codes = it.codes.filter(c => c !== String(b.code)); write(l); return json(res, 200, { items: view(l) }); }
|
if (p === '/unlink') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); it.codes = it.codes.filter(c => c !== String(b.code)); write(l); return json(res, 200, { items: view(l) }); }
|
||||||
throw fail(404, 'Not found');
|
throw fail(404, 'Not found');
|
||||||
|
|||||||
+18
-8
@@ -1,9 +1,11 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// ---- Pantry (Recipes > Pantry): what you keep in the kitchen, how many are left, and the level that counts as running low. A phone can scan a barcode to add one or use one;
|
// ---- Pantry (Recipes > Pantry): what you keep in the kitchen, how many are left, and the level that counts as running low. A phone can scan a barcode to add one or use one;
|
||||||
// the first time a barcode is seen you say which item it is (so Green Giant corn and the store brand are both "Corn"). Low items go on the shopping list in a tap.
|
// the first time a barcode is seen you say which item it is (so Green Giant corn and the store brand are both "Corn"). Low items go on the shopping list in a tap.
|
||||||
|
const PA_UNITS = [['', 'each'], ['g', 'g'], ['kg', 'kg'], ['oz', 'oz'], ['lb', 'lb'], ['ml', 'ml'], ['l', 'litres'], ['tsp', 'tsp'], ['tbsp', 'tbsp'], ['cup', 'cups'], ['fl oz', 'fl oz'], ['pint', 'pints'], ['quart', 'quarts'], ['gallon', 'gallons']];
|
||||||
|
const paStep = u => ({ g: 100, ml: 100 }[u] || 1);
|
||||||
const paApi = (p, o) => jfetch('/api/pantry' + p, o), paOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
const paApi = (p, o) => jfetch('/api/pantry' + p, o), paOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||||
const paAgo = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { weekday: 'short' }); };
|
const paAgo = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { weekday: 'short' }); };
|
||||||
const paRecent = r => r.length ? `<h2>Recently</h2><div class="list">${r.map(x => `<div class="row"><span class="grow"><b>${esc(x.who)}</b> ${x.delta < 0 ? 'took' : 'put back'} ${esc(x.item)}<small>${esc(paAgo(x.t))}</small></span></div>`).join('')}</div>` : '';
|
const paRecent = r => r.length ? `<h2>Recently</h2><div class="list">${r.map(x => `<div class="row"><span class="grow"><b>${esc(x.who)}</b> ${x.delta < 0 ? 'took' : 'put back'} ${esc(x.item)}<small>${esc(paAgo(x.t))}${x.note ? ' · ' + esc(x.note) : ''}</small></span></div>`).join('')}</div>` : '';
|
||||||
/** The simple way to use things: a big tile for each item, tap to take one (with Undo). Children see only this. */
|
/** The simple way to use things: a big tile for each item, tap to take one (with Undo). Children see only this. */
|
||||||
async function viewPantryTake() {
|
async function viewPantryTake() {
|
||||||
let st; const v = $('#view'), kid = !!(ME && ME.kid && ME.kid.on);
|
let st; const v = $('#view'), kid = !!(ME && ME.kid && ME.kid.on);
|
||||||
@@ -12,9 +14,9 @@ async function viewPantryTake() {
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>${kid ? 'Pantry' : 'Take one'}</h1><span class="grow"></span>${kid ? '' : `<a class="btn ghost" href="#/recipes/pantry">Manage</a>`}</div>
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>${kid ? 'Pantry' : 'Take one'}</h1><span class="grow"></span>${kid ? '' : `<a class="btn ghost" href="#/recipes/pantry">Manage</a>`}</div>
|
||||||
<p class="sub" style="margin-top:0">Tap what you are taking. It counts one off so the family knows when to buy more.</p>
|
<p class="sub" style="margin-top:0">Tap what you are taking. It counts one off so the family knows when to buy more.</p>
|
||||||
<div class="paTiles">${st.items.map(x => `<button type="button" class="paTile${x.qty === 0 ? ' out' : ''}${x.isLow && x.qty ? ' low' : ''}" data-u="${esc(x.id)}"${x.qty === 0 ? ' disabled' : ''} aria-label="Take one ${esc(x.name)}${x.qty != null ? ', ' + x.qty + ' left' : ''}"><b>${esc(x.name)}</b><small>${x.qty == null ? '' : x.qty === 0 ? 'none left' : x.qty + ' left'}</small></button>`).join('') || '<p class="empty">Nothing in the pantry yet.</p>'}</div>
|
<div class="paTiles">${st.items.filter(x => !kid || !x.unit).map(x => `<button type="button" class="paTile${x.qty === 0 ? ' out' : ''}${x.isLow && x.qty ? ' low' : ''}" data-u="${esc(x.id)}"${x.qty === 0 ? ' disabled' : ''} aria-label="${x.unit ? 'Use some ' : 'Take one '}${esc(x.name)}${x.qty != null ? ', ' + x.label + ' left' : ''}"><b>${esc(x.name)}</b><small>${x.qty == null ? '' : x.qty === 0 ? 'none left' : x.label + ' left'}</small></button>`).join('') || '<p class="empty">Nothing in the pantry yet.</p>'}</div>
|
||||||
<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="pasuse">${icon('plus')}Scan a barcode instead</button></div>${paRecent(st.recent || [])}</div>`;
|
<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="pasuse">${icon('plus')}Scan a barcode instead</button></div>${paRecent(st.recent || [])}</div>`;
|
||||||
v.querySelectorAll('[data-u]').forEach(b => b.onclick = async () => { b.disabled = true; try { const r = await paApi('/use', paOpt({ id: b.dataset.u })); st.items = r.items; st.recent = r.recent; draw(); const it = r.item;
|
v.querySelectorAll('[data-u]').forEach(b => b.onclick = async () => { const own = st.items.find(x => x.id === b.dataset.u); if (own && own.unit) return paUseSome(own, list => { st.items = list.items; st.recent = list.recent; draw(); }); b.disabled = true; try { const r = await paApi('/use', paOpt({ id: b.dataset.u })); st.items = r.items; st.recent = r.recent; draw(); const it = r.item;
|
||||||
toast(`Took one ${it.name}${it.qty != null ? ': ' + it.qty + ' left' : ''}`, { label: 'Undo', run: async () => { try { const u = await paApi('/use', paOpt({ id: it.id, undo: true })); st.items = u.items; st.recent = u.recent; if ($('.paTiles')) draw(); toast('Put back'); } catch (e) { toast(e.message); } } }); } catch (e) { b.disabled = false; toast(e.message); } });
|
toast(`Took one ${it.name}${it.qty != null ? ': ' + it.qty + ' left' : ''}`, { label: 'Undo', run: async () => { try { const u = await paApi('/use', paOpt({ id: it.id, undo: true })); st.items = u.items; st.recent = u.recent; if ($('.paTiles')) draw(); toast('Put back'); } catch (e) { toast(e.message); } } }); } catch (e) { b.disabled = false; toast(e.message); } });
|
||||||
$('#pasuse').onclick = () => pantryScan('use', list => { st.items = list; draw(); }, () => st.items);
|
$('#pasuse').onclick = () => pantryScan('use', list => { st.items = list; draw(); }, () => st.items);
|
||||||
};
|
};
|
||||||
@@ -28,13 +30,14 @@ async function viewPantry(sub) {
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${typeof rcTabs === 'function' ? rcTabs('pantry') : ''}
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${typeof rcTabs === 'function' ? rcTabs('pantry') : ''}
|
||||||
<div class="bar" style="justify-content:flex-start"><a class="btn" href="#/recipes/pantry/take">Take one</a><button class="btn ghost" id="pasadd">${icon('plus')}Scan to add</button><button class="btn ghost" id="pasuse">Scan to use one</button></div>
|
<div class="bar" style="justify-content:flex-start"><a class="btn" href="#/recipes/pantry/take">Take one</a><button class="btn ghost" id="pasadd">${icon('plus')}Scan to add</button><button class="btn ghost" id="pasuse">Scan to use one</button></div>
|
||||||
<form id="paf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pan" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="paq" 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="paw" 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>
|
<form id="paf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pan" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="paq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><select id="pau" aria-label="Unit" style="flex:1;min-width:0">${PA_UNITS.map(u => `<option value="${esc(u[0])}">${esc(u[1])}</option>`).join('')}</select><input type="number" id="paw" 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 : ''}${x.codes.length ? ' · ' + x.codes.length + ' barcode' + (x.codes.length === 1 ? '' : 's') : ''}</small></span>
|
<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.label + ' left'}${x.low != null ? ' · low at ' + x.lowLabel : ''}${x.codes.length ? ' · ' + x.codes.length + ' barcode' + (x.codes.length === 1 ? '' : 's') : ''}</small></span>
|
||||||
${x.isLow && typeof rcApi === 'function' ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.codes.length ? `<button class="ibtn" data-bc="${esc(x.id)}" aria-label="Barcodes for ${esc(x.name)}" title="Barcodes">${icon('list')}</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, or scan a barcode.</small></span></div>'}</div>
|
${x.isLow && typeof rcApi === 'function' ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.codes.length ? `<button class="ibtn" data-bc="${esc(x.id)}" aria-label="Barcodes for ${esc(x.name)}" title="Barcodes">${icon('list')}</button>` : ''}${x.qty != null && x.unit ? `<button class="btn ghost" data-some="${esc(x.id)}" style="min-height:36px">Use some</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-${paStep(x.unit)}" aria-label="Less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:${paStep(x.unit)}" aria-label="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, or scan a barcode.</small></span></div>'}</div>
|
||||||
<p class="sub" style="margin-top:12px">Counts are kept by hand (and by scanning); they do not go down when you cook. The meal plan points out ingredients you already have.</p>${paRecent(recent)}</div>`;
|
<p class="sub" style="margin-top:12px">Counts are kept by hand (and by scanning); they do not go down when you cook. The meal plan points out ingredients you already have.</p>${paRecent(recent)}</div>`;
|
||||||
const go = async (p, o) => { try { items = (await paApi(p, paOpt(o))).items; draw(); } catch (e) { toast(e.message); } };
|
const go = async (p, o) => { try { items = (await paApi(p, paOpt(o))).items; draw(); } catch (e) { toast(e.message); } };
|
||||||
$('#paf').onsubmit = e => { e.preventDefault(); go('/item', { name: $('#pan').value, qty: $('#paq').value, low: $('#paw').value }); };
|
$('#paf').onsubmit = e => { e.preventDefault(); go('/item', { name: $('#pan').value, qty: $('#paq').value, unit: $('#pau').value, low: $('#paw').value }); };
|
||||||
v.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/adjust', { id, delta: +d }); });
|
v.querySelectorAll('[data-some]').forEach(b => b.onclick = () => paUseSome(items.find(x => x.id === b.dataset.some), r => { items = r.items; recent = r.recent || recent; draw(); }));
|
||||||
|
v.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const i = b.dataset.d.lastIndexOf(':'); go('/adjust', { id: b.dataset.d.slice(0, i), delta: +b.dataset.d.slice(i + 1) }); });
|
||||||
v.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/delete', { id: b.dataset.x }));
|
v.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/delete', { id: b.dataset.x }));
|
||||||
v.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); } });
|
v.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); } });
|
||||||
v.querySelectorAll('[data-bc]').forEach(b => b.onclick = () => { const it = items.find(x => x.id === b.dataset.bc), el = openSheet('Barcodes for ' + it.name, 'Scanning any of these counts as ' + it.name + '.', `<div class="list">${it.codes.map(c => `<div class="row"><span class="grow"><b>${esc(c)}</b></span><button class="ibtn" data-u="${esc(c)}" aria-label="Unlink ${esc(c)}">${icon('trash')}</button></div>`).join('')}</div>`); el.querySelectorAll('[data-u]').forEach(u => u.onclick = async () => { closeSheet(); go('/unlink', { id: it.id, code: u.dataset.u }); }); });
|
v.querySelectorAll('[data-bc]').forEach(b => b.onclick = () => { const it = items.find(x => x.id === b.dataset.bc), el = openSheet('Barcodes for ' + it.name, 'Scanning any of these counts as ' + it.name + '.', `<div class="list">${it.codes.map(c => `<div class="row"><span class="grow"><b>${esc(c)}</b></span><button class="ibtn" data-u="${esc(c)}" aria-label="Unlink ${esc(c)}">${icon('trash')}</button></div>`).join('')}</div>`); el.querySelectorAll('[data-u]').forEach(u => u.onclick = async () => { closeSheet(); go('/unlink', { id: it.id, code: u.dataset.u }); }); });
|
||||||
@@ -89,3 +92,10 @@ function pantryScan(mode, onChange, getItems) {
|
|||||||
})();
|
})();
|
||||||
new MutationObserver((_, ob) => { if (!el.isConnected) { stop(); ob.disconnect(); } }).observe(document.body, { childList: true });
|
new MutationObserver((_, ob) => { if (!el.isConnected) { stop(); ob.disconnect(); } }).observe(document.body, { childList: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Use some of an item: an amount in any unit that can be turned into the item's own (4 cups of the flour that is kept in pounds). */
|
||||||
|
function paUseSome(it, done) {
|
||||||
|
const el = openSheet('Use some ' + it.name.toLowerCase(), `${it.label} left. Any unit works if it can be turned into ${it.unit || 'a count'} (cups of flour into pounds, for instance).`, `<form id="pusf"><div class="bar" style="flex-wrap:nowrap;gap:6px"><input type="number" id="pusa" min="0" step="any" inputmode="decimal" placeholder="Amount" required style="flex:1;min-width:0"><select id="pusu" aria-label="Unit" style="flex:1;min-width:0">${PA_UNITS.map(u => `<option value="${esc(u[0])}"${u[0] === it.unit ? ' selected' : ''}>${esc(u[1])}</option>`).join('')}</select></div><div class="err" id="puse"></div><div class="bar"><button class="btn" style="flex:1;justify-content:center">Take it out</button></div></form>`);
|
||||||
|
setTimeout(() => el.querySelector('#pusa').focus(), 80);
|
||||||
|
el.querySelector('#pusf').onsubmit = async e => { e.preventDefault(); try { const r = await paApi('/take', paOpt({ takes: [{ id: it.id, amount: +el.querySelector('#pusa').value, unit: el.querySelector('#pusu').value }], note: 'used some' })); if (r.skipped.length) { el.querySelector('#puse').textContent = 'That cannot be turned into ' + (it.unit || 'a count') + ' for ' + it.name.toLowerCase() + '. Pick a matching unit.'; return; } closeSheet(); const d = r.done[0]; toast(`${d.name}: ${d.label || 'none'} left${d.isLow ? ' (running low)' : ''}`); done(r); } catch (er) { el.querySelector('#puse').textContent = er.message; } };
|
||||||
|
}
|
||||||
+23
-3
@@ -62,7 +62,7 @@ async function rcOne(slug) {
|
|||||||
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
||||||
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
||||||
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
||||||
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</button><button class="btn ghost" id="rccook">${icon('check')}Cooked it</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
||||||
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
||||||
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
||||||
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
||||||
@@ -80,7 +80,7 @@ async function rcOne(slug) {
|
|||||||
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
||||||
paint();
|
paint();
|
||||||
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
|
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
|
||||||
$('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id);
|
$('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id); $('#rccook').onclick = () => rcCook(r.slug, r.name);
|
||||||
$('#rcadd').onclick = async e => {
|
$('#rcadd').onclick = async e => {
|
||||||
const b = e.currentTarget; b.disabled = true;
|
const b = e.currentTarget; b.disabled = true;
|
||||||
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
|
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
|
||||||
@@ -119,10 +119,11 @@ async function rcPlan(start) {
|
|||||||
<div class="bar" style="justify-content:space-between;align-items:center"><a class="btn ghost" href="${shift(-7)}" aria-label="Previous week">${icon('back')}</a><b>${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</b><a class="btn ghost" href="${shift(7)}" aria-label="Next week">${icon('chev')}</a></div>
|
<div class="bar" style="justify-content:space-between;align-items:center"><a class="btn ghost" href="${shift(-7)}" aria-label="Previous week">${icon('back')}</a><b>${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</b><a class="btn ghost" href="${shift(7)}" aria-label="Next week">${icon('chev')}</a></div>
|
||||||
<div class="list">${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal));
|
<div class="list">${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal));
|
||||||
return `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
|
return `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
|
||||||
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
|
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-cook="${esc(e.slug)}|${esc(e.name)}" aria-label="Cooked ${esc(e.name)}" title="Cooked it: take it out of the pantry">${icon('check')}</button><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
|
||||||
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
|
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
|
||||||
<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>`;
|
<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-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
|
||||||
|
document.querySelectorAll('[data-cook]').forEach(b => b.onclick = () => { const i = b.dataset.cook.indexOf('|'); rcCook(b.dataset.cook.slice(0, i), b.dataset.cook.slice(i + 1)); });
|
||||||
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); } });
|
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 = () => { location.hash = '#/recipes/pantry'; };
|
$('#rcpantry').onclick = () => { location.hash = '#/recipes/pantry'; };
|
||||||
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(() => {});
|
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(() => {});
|
||||||
@@ -139,3 +140,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; } };
|
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();
|
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Cooked it": shows what cooking this recipe would take out of the pantry (an estimate), lets you untick lines, and takes the rest out when you confirm. */
|
||||||
|
async function rcCook(slug, name, scale = 1) {
|
||||||
|
const el = openSheet('Cooked ' + name, 'What this takes out of the pantry', '<div id="rcck"><p class="sub">Working it out…</p></div>');
|
||||||
|
const show = async sc => {
|
||||||
|
let r; try { r = await rcApi(`/cook-plan?slug=${encodeURIComponent(slug)}&scale=${sc}`); } catch (e) { el.querySelector('.sheet').insertAdjacentHTML('beforeend', `<p class="err">${esc(e.message)}</p>`); return; }
|
||||||
|
const ok = r.rows.filter(x => x.ok), skip = r.rows.filter(x => !x.ok), body = `<div id="rcck"><div class="seg" role="radiogroup" aria-label="How much did you make">${[[0.5, 'Half'], [1, 'As written'], [2, 'Double'], [3, 'Triple']].map(([v, l]) => `<a href="#" role="radio" aria-checked="${sc === v}" data-sc="${v}" class="${sc === v ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||||
|
${ok.length ? `<div class="list">${ok.map((x, i) => `<label class="row" style="cursor:pointer"><input type="checkbox" data-i="${i}" checked><span class="grow" style="white-space:normal"><b>${esc(x.item.name)}</b> −${esc(x.takeLabel)}<small>${esc(x.line)} · ${x.left === 0 ? 'none' : esc(x.leftLabel || x.left)} left${x.why ? ' · ' + esc(x.why) : ''}</small></span></label>`).join('')}</div>` : '<p class="empty">Nothing in your pantry matches this recipe\'s ingredients that can be counted off.</p>'}
|
||||||
|
${skip.length ? `<h3 class="sub" style="margin:12px 0 4px">Left alone</h3><div class="list">${skip.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.item.name)}</b><small>${esc(x.line)} · ${esc(x.why)}</small></span></div>`).join('')}</div>` : ''}
|
||||||
|
<p class="sub" style="margin-top:8px">These are estimates (a cup of flour is about 4¼ oz). Untick anything that is not right.</p><div id="rcce" class="err"></div>
|
||||||
|
<div class="bar"><a class="btn ghost" href="#/recipes/pantry" id="rccp" style="flex:1;justify-content:center">Pantry</a>${ok.length ? '<button class="btn" id="rccgo" style="flex:1;justify-content:center">Take these out</button>' : ''}</div></div>`;
|
||||||
|
el.querySelector('#rcck').outerHTML = body;
|
||||||
|
el.querySelectorAll('[data-sc]').forEach(a => a.onclick = e => { e.preventDefault(); show(+a.dataset.sc); });
|
||||||
|
const go = el.querySelector('#rccgo'); if (go) go.onclick = async () => { const picks = [...el.querySelectorAll('[data-i]:checked')].map(c => ok[+c.dataset.i]); if (!picks.length) return closeSheet();
|
||||||
|
try { const t = await jfetch('/api/pantry/take', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ takes: picks.map(x => ({ id: x.item.id, amount: x.take, unit: x.unit })), note: 'cooked ' + name }) }); closeSheet(); const low = t.done.filter(d => d.isLow).map(d => d.name); toast(`Took ${t.done.length} item${t.done.length === 1 ? '' : 's'} out of the pantry${low.length ? '. Running low: ' + low.join(', ') : ''}`, low.length ? { label: 'Pantry', run: () => { location.hash = '#/recipes/pantry'; } } : undefined); }
|
||||||
|
catch (e) { el.querySelector('#rcce').textContent = e.message; } };
|
||||||
|
};
|
||||||
|
show(scale);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v125';
|
const CACHE = 'hub-v127';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|||||||
+11
-1
@@ -104,7 +104,7 @@ 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 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 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; };
|
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; };
|
||||||
const pantry = require('./pantry'); // what is in stock (the plan checks its ingredients against it)
|
const pantry = require('./pantry'), U = require('./units'); // what is in stock (the plan checks its ingredients against it)
|
||||||
/** A recipe's ingredients onto Mealie's shopping list, `times` over (Mealie adds up what is already there). */
|
/** A recipe's ingredients onto Mealie's shopping list, `times` over (Mealie adds up what is already there). */
|
||||||
async function addRecipe(slug, times) {
|
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));
|
const r = await mealie('/api/recipes/' + (slugOk(slug) ? slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +times || 1));
|
||||||
@@ -153,6 +153,16 @@ 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 (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 === '/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 === '/cook-plan' && req.method === 'GET') { // what cooking this recipe would take out of the pantry (nothing is changed here; the person confirms in the pantry)
|
||||||
|
const slug = q.get('slug') || '', scale = Math.max(0.25, Math.min(20, +q.get('scale') || 1)); if (!slugOk(slug)) throw fail(400, 'Pick a recipe');
|
||||||
|
const r = await mealie('/api/recipes/' + slug), ings = (r.recipeIngredient || []).filter(i => i && !(i.title && !i.display && !i.originalText && !i.food)).map(i => {
|
||||||
|
const line = ingredientText(i), qn = +i.quantity, food = i.food && (i.food.name || i.food), uname = i.unit && (i.unit.name || i.unit);
|
||||||
|
if (qn > 0 && food) return { amount: qn, unit: U.unitOf(uname) || '', name: [food, i.note].filter(Boolean).join(' '), line }; // (Mealie already split it up)
|
||||||
|
return { ...U.parseIngredient(line), line };
|
||||||
|
});
|
||||||
|
const rows = U.plan(ings, pantry.read(), scale).map(x => ({ ...x, takeLabel: U.fmt(x.take, x.unit), leftLabel: x.left == null ? '' : U.fmt(x.left, x.unit) }));
|
||||||
|
return json(res, 200, { name: r.name, servings: r.recipeServings || r.recipeYield || '', scale, rows, lines: ings.length });
|
||||||
|
}
|
||||||
if (p === '/plan/pantry' && req.method === 'GET') { // which planned ingredients the pantry already covers
|
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 = pantry.read().filter(x => x.qty !== 0), 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 = pantry.read().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 && pantry.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; } }
|
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 && pantry.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; } }
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ const off = http.createServer((req, res) => { const code = /product\/(\d+)\.json
|
|||||||
for (const [p, o] of [['/item', { name: 'Candy', qty: 9 }], ['/adjust', { id: gb, delta: 5 }], ['/delete', { id: gb }], ['/unlink', { id: gb, code: '1' }], ['/scan', { code: '0099999999999', mode: 'add', name: 'Candy' }]]) assert.equal((await post(p, o, 'kid')).status, 403, p + ' is for grown-ups');
|
for (const [p, o] of [['/item', { name: 'Candy', qty: 9 }], ['/adjust', { id: gb, delta: 5 }], ['/delete', { id: gb }], ['/unlink', { id: gb, code: '1' }], ['/scan', { code: '0099999999999', mode: 'add', name: 'Candy' }]]) assert.equal((await post(p, o, 'kid')).status, 403, p + ' is for grown-ups');
|
||||||
assert.equal((await get('/')).status, 200); assert.equal((await j(await fetch(base + '/', { headers: { 'x-user': 'kid' } }))).kid, true);
|
assert.equal((await get('/')).status, 200); assert.equal((await j(await fetch(base + '/', { headers: { 'x-user': 'kid' } }))).kid, true);
|
||||||
r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); const before = r.item.qty; r = await j(await post('/scan', { code: '0016000275287', mode: 'use' }, 'kid')); assert.equal(r.item.qty, before - 1, 'a child can scan a known barcode to take one'); assert.equal((await post('/scan', { code: '0555555555555', mode: 'use', name: 'Mystery' }, 'kid')).status, 403, 'but not make new items');
|
r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); const before = r.item.qty; r = await j(await post('/scan', { code: '0016000275287', mode: 'use' }, 'kid')); assert.equal(r.item.qty, before - 1, 'a child can scan a known barcode to take one'); assert.equal((await post('/scan', { code: '0555555555555', mode: 'use', name: 'Mystery' }, 'kid')).status, 403, 'but not make new items');
|
||||||
|
// units: an item in pounds, amounts in cups, taken out together (grown-ups only), and the shown amount
|
||||||
|
r = await j(await post('/item', { name: 'Flour', qty: 10, unit: 'lb', low: 2 })); let fl = r.items.find(x => x.name === 'Flour'); assert.deepEqual([fl.unit, fl.label, fl.lowLabel], ['lb', '10 lb', '2 lb']); assert.equal((await j(await post('/item', { name: 'Bad', qty: 1, unit: 'furlongs' }))).items.find(x => x.name === 'Bad').unit, '', 'an unknown unit is a count');
|
||||||
|
r = await j(await post('/take', { takes: [{ id: fl.id, amount: 4, unit: 'cup' }], note: 'cooked Pancakes' })); assert.equal(r.done.length, 1); assert.ok(Math.abs(r.done[0].took - 4 * 120 / 453.592) < 0.01); assert.equal(r.items.find(x => x.name === 'Flour').label, '8 lb 15 oz', '10 lb less 4 cups'); assert.equal(r.recent[0].note, 'cooked Pancakes');
|
||||||
|
r = await j(await post('/take', { takes: [{ id: fl.id, amount: 8, unit: 'oz' }, { id: fl.id, amount: 3, unit: 'furlongs' }, { id: 'nope', amount: 1 }, { id: note, amount: 1 }] })); assert.equal(r.done.length, 1); assert.equal(r.skipped.length, 3, 'what cannot be turned into the item\'s unit, is missing or is not counted is skipped, with a reason'); assert.match(r.skipped.find(x => x.name === 'Napkins').why, /not counted/);
|
||||||
|
r = await j(await post('/take', { takes: [{ id: fl.id, amount: 99, unit: 'lb' }] })); assert.equal(r.items.find(x => x.name === 'Flour').qty, 0, 'never below none'); assert.equal((await post('/take', { takes: [{ id: fl.id, amount: 1 }] }, 'kid')).status, 403, 'children only take one');
|
||||||
|
assert.equal((await post('/use', { id: fl.id }, 'kid')).status, 409, 'tapping take-one on a pound of flour is not allowed: use an amount');
|
||||||
|
// scanning goes by the size of the package for an item in a unit
|
||||||
|
r = await j(await post('/item', { name: 'Tomatoes', qty: 0, unit: 'oz' })); const tom = r.items.find(x => x.name === 'Tomatoes').id; const tcache = path.join(process.env.DATA_DIR, 'barcode-cache.json'); const cc = JSON.parse(fs.readFileSync(tcache, 'utf8')); cc['0041331000123'] = { found: true, t: Date.now(), name: 'Diced Tomatoes', brand: 'Muir', quantity: '14.5 oz' }; cc['0041331000456'] = { found: true, t: Date.now(), name: 'Tomato Paste', brand: 'Muir', quantity: '1 lb' }; fs.writeFileSync(tcache, JSON.stringify(cc));
|
||||||
|
r = await j(await post('/scan', { code: '0041331000123', itemId: tom })); assert.equal(r.step, 14.5); assert.equal(r.item.qty, 14.5, 'a 14.5 oz can adds 14.5 oz'); r = await j(await post('/scan', { code: '0041331000456', itemId: tom })); assert.equal(r.step, 16, 'a 1 lb tin is 16 oz'); assert.equal(r.item.qty, 30.5); r = await j(await post('/scan', { code: '0041331000123', mode: 'use' })); assert.equal(r.item.qty, 16, 'using one takes the package size back off');
|
||||||
|
r = await j(await post('/scan', { code: '0070470003344', itemId: gb, mode: 'add' })); assert.equal(r.step, 1, 'a counted item goes up by one');
|
||||||
// the pantry is shared, and old pantry files (a list of names) still load
|
// the pantry is shared, and old pantry files (a list of names) still load
|
||||||
assert.equal((await j(await get('/', 'bo'))).items.length >= 1, true); fs.writeFileSync(path.join(process.env.DATA_DIR, 'pantry.json'), JSON.stringify(['Flour', 'Salt'])); r = await j(await get('/')); assert.deepEqual(r.items.map(x => x.name), ['Flour', 'Salt']); assert.equal(r.items[0].qty, null);
|
assert.equal((await j(await get('/', 'bo'))).items.length >= 1, true); fs.writeFileSync(path.join(process.env.DATA_DIR, 'pantry.json'), JSON.stringify(['Flour', 'Salt'])); r = await j(await get('/')); assert.deepEqual(r.items.map(x => x.name), ['Flour', 'Salt']); assert.equal(r.items[0].qty, null);
|
||||||
assert.deepEqual(pantry.inPantry('3 cups all-purpose flour', ['Flour', 'Salt']), ['Flour']);
|
assert.deepEqual(pantry.inPantry('3 cups all-purpose flour', ['Flour', 'Salt']), ['Flour']);
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ const fake = http.createServer((req, res) => {
|
|||||||
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');
|
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');
|
||||||
fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 0, low: 1, codes: [] }])); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
|
fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 0, low: 1, codes: [] }])); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
|
||||||
fs.writeFileSync(pf, '[]'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty pantry flags nothing');
|
fs.writeFileSync(pf, '[]'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty pantry flags nothing');
|
||||||
|
// what cooking a recipe would take out of the pantry (shown first; nothing changes until it is confirmed in the pantry)
|
||||||
|
fs.writeFileSync(pf, JSON.stringify([{ id: 'fl', name: 'Flour', qty: 10, unit: 'lb', low: 2, codes: [] }, { id: 'bu', name: 'Butter', qty: 1, unit: 'lb', low: null, codes: [] }, { id: 'ca', name: 'Cocoa', qty: null, unit: '', low: null, codes: [] }]));
|
||||||
|
j = await (await get('/cook-plan?slug=bread')).json(); assert.equal(j.name, 'Bread'); assert.deepEqual(j.rows.map(r => [r.item.name, r.ok]), [['Flour', true], ['Butter', true]], 'the glaze heading and anything not in the pantry are left out');
|
||||||
|
assert.ok(Math.abs(j.rows[0].take - 3 * 120 / 453.592) < 0.01, '3 cups of flour in pounds'); assert.equal(j.rows[0].unit, 'lb'); assert.match(j.rows[0].takeLabel, /^(12|13) oz$/); assert.ok(Math.abs(j.rows[1].take - 0.0326) < 0.002, '1 tbsp of butter in pounds (Mealie gave it already split up)');
|
||||||
|
j = await (await get('/cook-plan?slug=bread&scale=2')).json(); assert.ok(Math.abs(j.rows[0].take - 6 * 120 / 453.592) < 0.01, 'doubled'); assert.equal((await get('/cook-plan?slug=..%2Fx')).status, 400);
|
||||||
// a wrong token is explained
|
// 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 = '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);
|
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Units: reading recipe lines, converting (cups of flour to pounds), showing amounts, and working out what a recipe takes out of the pantry.
|
||||||
|
const assert = require('assert'), U = require('../units');
|
||||||
|
const P = U.parseIngredient, near = (a, b, tol = 0.02) => assert.ok(Math.abs(a - b) <= tol * Math.max(1, Math.abs(b)), `${a} is not about ${b}`);
|
||||||
|
// reading lines
|
||||||
|
assert.deepEqual(P('3 cups all-purpose flour'), { amount: 3, unit: 'cup', name: 'all-purpose flour' });
|
||||||
|
assert.deepEqual(P('1 1/2 tsp salt'), { amount: 1.5, unit: 'tsp', name: 'salt' }); assert.deepEqual(P('½ cup sugar'), { amount: 0.5, unit: 'cup', name: 'sugar' }); assert.deepEqual(P('1½ cups milk'), { amount: 1.5, unit: 'cup', name: 'milk' });
|
||||||
|
assert.deepEqual(P('2-3 tbsp olive oil'), { amount: 2.5, unit: 'tbsp', name: 'olive oil' }, 'a range is its middle'); assert.deepEqual(P('1 lb. ground beef, browned'), { amount: 1, unit: 'lb', name: 'ground beef' });
|
||||||
|
assert.deepEqual(P('2 large eggs'), { amount: 2, unit: '', name: 'large eggs' }); assert.deepEqual(P('1 onion, diced'), { amount: 1, unit: '', name: 'onion' }); assert.deepEqual(P('2 (14 oz) cans diced tomatoes'), { amount: 28, unit: 'oz', name: 'diced tomatoes' });
|
||||||
|
assert.deepEqual(P('Salt and pepper to taste'), { amount: null, unit: '', name: 'Salt and pepper to taste' }); assert.deepEqual(P('1 stick butter'), { amount: 1, unit: 'stick', name: 'butter' }); assert.deepEqual(P('8 fl oz cream'), { amount: 8, unit: 'fl oz', name: 'cream' }); assert.deepEqual(P('1 can corn'), { amount: 1, unit: '', name: 'can corn' });
|
||||||
|
assert.deepEqual(P('4 c. flour'), { amount: 4, unit: 'cup', name: 'flour' }); assert.deepEqual(P('2 T sugar'), { amount: 2, unit: 'tbsp', name: 'sugar' }); assert.deepEqual(P('2 cloves garlic'), { amount: 2, unit: '', name: 'cloves garlic' });
|
||||||
|
// converting
|
||||||
|
near(U.convert(1, 'lb', 'oz'), 16); near(U.convert(1, 'cup', 'tbsp'), 16); near(U.convert(1, 'gallon', 'quart'), 4); near(U.convert(1000, 'g', 'kg'), 1); assert.equal(U.convert(2, 'cup', 'lb', 'bananas'), null, 'cups to pounds needs to know the ingredient');
|
||||||
|
near(U.convert(4, 'cup', 'lb', 'all-purpose flour'), 4 * 120 / 453.592, 0.01); near(U.convert(1, 'lb', 'cup', 'flour'), 453.592 / 120, 0.01); near(U.convert(1, 'cup', 'oz', 'brown sugar'), 213 / 28.3495, 0.01); near(U.convert(1, 'stick', 'oz', 'butter'), 4, 0.02); assert.equal(U.convert(1, 'cup', 'lb', 'unobtainium'), null); assert.equal(U.convert(2, '', 'lb', 'flour'), null, 'a count does not become a weight');
|
||||||
|
assert.equal(U.density('light brown sugar, packed'), 213, 'the longest match wins'); assert.equal(U.density('sugar'), 200); assert.equal(U.density('bananas'), 0);
|
||||||
|
// showing amounts
|
||||||
|
assert.equal(U.fmt(8.94, 'lb'), '8 lb 15 oz'); assert.equal(U.fmt(10, 'lb'), '10 lb'); assert.equal(U.fmt(0.94, 'lb'), '15 oz'); assert.equal(U.fmt(2.5, 'cup'), '2 1/2 cups'); assert.equal(U.fmt(1, 'cup'), '1 cup'); assert.equal(U.fmt(12, ''), '12'); assert.equal(U.fmt(null, 'lb'), ''); assert.equal(U.fmt(0.99999, 'lb'), '1 lb'); assert.equal(U.fmt(0.91, 'gallon'), '3 qt 2 1/2 cups'); assert.equal(U.fmt(0.09, 'gallon'), '1 1/2 cups'); assert.equal(U.fmt(1, 'gallon'), '1 gallon'); assert.equal(U.fmt(0.5, 'quart'), '2 cups'); assert.equal(U.fmt(2.25, 'quart'), '2 qt 1 cup');
|
||||||
|
// what cooking takes out of the pantry
|
||||||
|
const items = [{ id: 'f', name: 'Flour', qty: 10, unit: 'lb' }, { id: 'e', name: 'Eggs', qty: 12, unit: '' }, { id: 's', name: 'Salt', qty: null, unit: '' }, { id: 'b', name: 'Butter', qty: 2, unit: 'lb' }, { id: 'm', name: 'Milk', qty: 1, unit: 'gallon' }, { id: 'o', name: 'Onions', qty: 3, unit: 'lb' }, { id: 'bs', name: 'Brown sugar', qty: 2, unit: 'lb' }, { id: 'su', name: 'Sugar', qty: 4, unit: 'lb' }];
|
||||||
|
const pl = U.plan(['4 cups all-purpose flour', '2 large eggs', '1 tsp salt', '1 stick butter', '2 cups milk', '1 onion, diced', '1 cup packed brown sugar', 'pinch of nutmeg', 'Glaze'], items, 1);
|
||||||
|
const by = id => pl.find(r => r.item.id === id);
|
||||||
|
assert.ok(by('f').ok); near(by('f').take, 4 * 120 / 453.592, 0.01); near(by('f').left, 10 - 4 * 120 / 453.592, 0.01, 'flour: 4 cups is about 1 lb 1 oz of the 10 lb');
|
||||||
|
assert.deepEqual([by('e').ok, by('e').take, by('e').left], [true, 2, 10], 'eggs are counted'); assert.equal(by('s').ok, false); assert.match(by('s').why, /not counted/);
|
||||||
|
near(by('b').take, 0.25, 0.02); near(by('m').take, 0.125, 0.01); assert.equal(by('o').ok, false); assert.match(by('o').why, /recipe counts onions/, 'a whole onion cannot come off pounds');
|
||||||
|
near(by('bs').take, 213 / 453.592, 0.01); assert.equal(pl.find(r => r.item.id === 'su'), undefined, '"brown sugar" is not plain sugar... and sugar is not listed for it'); assert.equal(pl.length, 7, 'lines that match nothing are left out');
|
||||||
|
// scaling, and not going below none
|
||||||
|
near(U.plan(['4 cups flour'], items, 2)[0].take, 8 * 120 / 453.592, 0.01); const tiny = U.plan(['4 cups flour'], [{ id: 'f', name: 'Flour', qty: 0.5, unit: 'lb' }])[0]; assert.equal(tiny.left, 0); assert.match(tiny.why, /more than the pantry has/);
|
||||||
|
{ const two = U.plan(['2 cups flour', '1 cup flour'], [{ id: 'f', name: 'Flour', qty: 1, unit: 'lb' }]); near(two[1].left, 1 - 3 * 120 / 453.592, 0.02); /* two lines of the same item add up */ }
|
||||||
|
assert.equal(U.plan(['3 cups flour'], [{ id: 'f', name: 'Flour', qty: 5, unit: 'cup' }])[0].take, 3, 'same unit: no conversion'); near(U.plan(['1 tsp vanilla'], [{ id: 'v', name: 'Vanilla', qty: 4, unit: 'tbsp' }])[0].left, 3.6667, 0.001);
|
||||||
|
console.log('units test ok');
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Amounts and units for the pantry and recipes: read "1 1/2 cups all-purpose flour" into an amount, a unit and a name; convert between units (cups to pounds needs to know the
|
||||||
|
// ingredient, so there is a table of common ones); show an amount nicely ("8 lb 15 oz"); and work out what cooking a recipe takes out of the pantry. Everything here is an estimate:
|
||||||
|
// it is for keeping count of the flour, not for the lab.
|
||||||
|
const VOL = { ml: 1, l: 1000, tsp: 4.92892, tbsp: 14.7868, 'fl oz': 29.5735, cup: 236.588, pint: 473.176, quart: 946.353, gallon: 3785.41, stick: 118.294 }; // to millilitres (a stick of butter is half a cup)
|
||||||
|
const WT = { g: 1, kg: 1000, oz: 28.3495, lb: 453.592 }; // to grams
|
||||||
|
const ALIAS = { c: 'cup', cups: 'cup', tablespoon: 'tbsp', tablespoons: 'tbsp', tbs: 'tbsp', tbl: 'tbsp', tbsps: 'tbsp', t: 'tsp', teaspoon: 'tsp', teaspoons: 'tsp', tsps: 'tsp', ounce: 'oz', ounces: 'oz', ozs: 'oz', pound: 'lb', pounds: 'lb', lbs: 'lb', gram: 'g', grams: 'g', gr: 'g', kilogram: 'kg', kilograms: 'kg', kgs: 'kg', milliliter: 'ml', milliliters: 'ml', millilitre: 'ml', millilitres: 'ml', liter: 'l', liters: 'l', litre: 'l', litres: 'l', pt: 'pint', pints: 'pint', qt: 'quart', quarts: 'quart', gal: 'gallon', gallons: 'gallon', floz: 'fl oz', 'fl. oz': 'fl oz', 'fl oz.': 'fl oz', sticks: 'stick' };
|
||||||
|
/** The canonical unit for a word ("Cups" -> "cup", "lbs." -> "lb"), or '' when it is not a unit this knows. */
|
||||||
|
function unitOf(w) { const s = String(w || '').toLowerCase().trim().replace(/\.$/, ''); if (VOL[s] || WT[s]) return s; return ALIAS[s] || ''; }
|
||||||
|
const family = u => WT[u] ? 'weight' : VOL[u] ? 'volume' : 'count';
|
||||||
|
const UNITS = ['', 'g', 'kg', 'oz', 'lb', 'ml', 'l', 'tsp', 'tbsp', 'cup', 'fl oz', 'pint', 'quart', 'gallon'];
|
||||||
|
|
||||||
|
// grams in one cup, for the things people bake and cook with (King Arthur, USDA). The longest matching name wins ("brown sugar" before "sugar").
|
||||||
|
const DENSITY = { flour: 120, 'bread flour': 120, 'whole wheat flour': 120, 'almond flour': 96, 'coconut flour': 112, 'rice flour': 158, sugar: 200, 'brown sugar': 213, 'powdered sugar': 120, 'confectioners sugar': 120, 'icing sugar': 120, butter: 227, margarine: 227, shortening: 190, milk: 245, buttermilk: 245, cream: 238, 'heavy cream': 238, 'half and half': 242, water: 237, juice: 248, oil: 218, 'olive oil': 216, 'vegetable oil': 218, 'coconut oil': 218, honey: 340, molasses: 340, 'maple syrup': 322, 'corn syrup': 328, syrup: 322, rice: 185, 'brown rice': 190, quinoa: 170, oats: 90, oatmeal: 90, cornmeal: 157, cornstarch: 128, cocoa: 85, 'cocoa powder': 85, salt: 288, 'baking powder': 230, 'baking soda': 230, yeast: 144, 'peanut butter': 258, 'almond butter': 250, 'cream cheese': 227, 'sour cream': 240, yogurt: 245, 'chocolate chips': 170, raisins: 145, nuts: 120, almonds: 140, walnuts: 117, pecans: 109, cheese: 113, parmesan: 100, breadcrumbs: 108, lentils: 192, beans: 190, pasta: 100, mayonnaise: 220, ketchup: 240, 'tomato sauce': 245, 'chicken broth': 240, broth: 240, stock: 240, vinegar: 239, 'soy sauce': 255, coconut: 80, 'cottage cheese': 226, ricotta: 246, 'peanuts': 146, 'sunflower seeds': 140, 'chia seeds': 170, flaxseed: 168 };
|
||||||
|
const stem = w => w.replace(/(ies)$/, 'y').replace(/(ves)$/, 'f').replace(/(es|s)$/, '');
|
||||||
|
const words = t => (String(t).toLowerCase().replace(/&/g, ' and ').match(/[a-z]+/g) || []).map(stem);
|
||||||
|
/** Grams per cup for a named ingredient, or 0 when it is not in the table. */
|
||||||
|
function density(name) {
|
||||||
|
const have = new Set(words(name)); let best = 0, bestLen = 0;
|
||||||
|
for (const [k, g] of Object.entries(DENSITY)) { const ks = words(k); if (ks.length >= bestLen && ks.every(w => have.has(w)) && (ks.length > bestLen || !best)) { best = g; bestLen = ks.length; } }
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
const FR = { '¼': 0.25, '½': 0.5, '¾': 0.75, '⅓': 1 / 3, '⅔': 2 / 3, '⅛': 0.125, '⅜': 0.375, '⅝': 0.625, '⅞': 0.875, '⅕': 0.2, '⅖': 0.4, '⅗': 0.6, '⅘': 0.8, '⅙': 1 / 6, '⅚': 5 / 6 };
|
||||||
|
/** A leading amount ("1 1/2", "½", "1½", "2-3", "2 to 3", "0.5"): { n, rest } or null. A range counts as its middle. */
|
||||||
|
function leadingNumber(text) {
|
||||||
|
let s = String(text || '').trim().replace(/[¼½¾⅓⅔⅛⅜⅝⅞⅕⅖⅗⅘⅙⅚]/g, m => ' ' + FR[m] + ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
const one = /^(\d+\s+\d+\/\d+|\d+\/\d+|\d+\.\d+|\d+\s+0?\.\d+|\d+|0?\.\d+)/.exec(s); if (!one) return null;
|
||||||
|
const val = t => { t = t.trim(); let m; if ((m = /^(\d+)\s+(\d+)\/(\d+)$/.exec(t))) return +m[1] + +m[2] / +m[3]; if ((m = /^(\d+)\/(\d+)$/.exec(t))) return +m[1] / +m[2]; if ((m = /^(\d+)\s+(0?\.\d+)$/.exec(t))) return +m[1] + +m[2]; return parseFloat(t); };
|
||||||
|
let n = val(one[1]), rest = s.slice(one[0].length).trim(); const range = /^(?:-|–|to)\s*(\d+\s+\d+\/\d+|\d+\/\d+|\d+\.\d+|\d+|0?\.\d+)/i.exec(rest); if (range) { n = (n + val(range[1])) / 2; rest = rest.slice(range[0].length).trim(); }
|
||||||
|
return isFinite(n) ? { n, rest } : null;
|
||||||
|
}
|
||||||
|
/** One recipe line -> { amount (number or null), unit ('' for a count), name }. "2 (14 oz) cans tomatoes" is 28 oz of tomatoes. */
|
||||||
|
function parseIngredient(text) {
|
||||||
|
const t = String(text || '').trim(), q = leadingNumber(t); if (!q) return { amount: null, unit: '', name: t };
|
||||||
|
let amount = q.n, rest = q.rest, unit = '', m;
|
||||||
|
if ((m = /^\(\s*([\d./\s]+)\s*([a-z. ]+?)\s*\)\s*/i.exec(rest)) && unitOf(m[2])) { const inner = leadingNumber(m[1]); if (inner) { amount = q.n * inner.n; unit = unitOf(m[2]); rest = rest.slice(m[0].length).replace(/^(cans?|jars?|packages?|pkgs?|bags?|boxes|box|bottles?|containers?)\b\s*/i, '').trim(); } }
|
||||||
|
else { const two = /^(fl\.?\s?oz\.?)\b/i.exec(rest); if (two) { unit = 'fl oz'; rest = rest.slice(two[0].length).trim(); } else if ((m = /^T(\s|$)/.exec(rest))) { unit = 'tbsp'; rest = rest.slice(m[0].length).trim(); } // (a capital T is a tablespoon, a small t a teaspoon)
|
||||||
|
else if ((m = /^([a-zA-Z]+\.?)(\s|$)/.exec(rest)) && unitOf(m[1]) && !(m[1].length === 1 && /^[a-z]$/.test(m[1]) && !/^[ctT]$/.test(m[1]))) { unit = unitOf(m[1]); rest = rest.slice(m[0].length).trim(); } }
|
||||||
|
rest = rest.replace(/^of\s+/i, '').replace(/\(.*?\)/g, ' ').replace(/,.*$/, '').replace(/\s+/g, ' ').trim();
|
||||||
|
return { amount, unit, name: rest || t };
|
||||||
|
}
|
||||||
|
/** Convert an amount between units. Weight and volume need the ingredient (a name, or grams per cup); a count converts only to a count. Returns null when it cannot. */
|
||||||
|
function convert(amount, from, to, name, gPerCup) {
|
||||||
|
if (from === to) return amount; const ff = family(from), tf = family(to);
|
||||||
|
if (ff === 'count' || tf === 'count') return null;
|
||||||
|
if (ff === tf) { const t = ff === 'weight' ? WT : VOL; return amount * t[from] / t[to]; }
|
||||||
|
const g = gPerCup || density(name); if (!g) return null; const gPerMl = g / VOL.cup;
|
||||||
|
return ff === 'volume' ? amount * VOL[from] * gPerMl / WT[to] : amount * WT[from] / gPerMl / VOL[to];
|
||||||
|
}
|
||||||
|
const r2 = n => Math.round(n * 100) / 100, r4 = n => Math.round(n * 10000) / 10000; // (amounts keep four decimals: a spoonful of butter is a few hundredths of a pound)
|
||||||
|
/** An amount to show: "8 lb 15 oz", "2 1/2 cups", "12". */
|
||||||
|
function fmt(qty, unit) {
|
||||||
|
if (qty == null) return ''; const q = Math.round(qty * 100) / 100;
|
||||||
|
if (unit === 'lb' && q % 1) { let lb = Math.floor(q), oz = Math.round((q - lb) * 16); if (oz === 16) { lb++; oz = 0; } return oz ? `${lb ? lb + ' lb ' : ''}${oz} oz` : lb + ' lb'; }
|
||||||
|
if ((unit === 'gallon' || unit === 'quart') && q % 1) { // a fraction of a gallon or quart is easier to picture in smaller measures: "3 qt 2 1/2 cups"
|
||||||
|
let cups = Math.round(q * (unit === 'gallon' ? 16 : 4) * 4) / 4; const parts = []; if (unit === 'gallon') { const g = Math.floor(cups / 16); if (g) parts.push(g + ' gal'); cups -= g * 16; } const qt = Math.floor(cups / 4); if (qt) parts.push(qt + ' qt'); cups -= qt * 4;
|
||||||
|
if (cups) { const w = Math.floor(cups), f = { 0.25: '1/4', 0.5: '1/2', 0.75: '3/4' }[cups - w]; parts.push(`${w ? w + ' ' : ''}${f || ''}`.trim() + (cups > 1 ? ' cups' : ' cup')); } return parts.join(' ') || '0';
|
||||||
|
}
|
||||||
|
const frac = q % 1, whole = Math.floor(q), nice = { 0.25: '1/4', 0.33: '1/3', 0.5: '1/2', 0.67: '2/3', 0.75: '3/4' }[r2(frac)];
|
||||||
|
const num = ['cup', 'tbsp', 'tsp', 'quart', 'pint', 'gallon'].includes(unit) && nice ? `${whole ? whole + ' ' : ''}${nice}` : String(q);
|
||||||
|
return unit ? `${num} ${unit === 'cup' && q > 1 ? 'cups' : unit}` : num;
|
||||||
|
}
|
||||||
|
const aboutWords = (a, b) => { const ws = new Set(words(a)); return words(b).length > 0 && words(b).every(w => ws.has(w)); };
|
||||||
|
/** What cooking `ingredients` (lines, or { amount, unit, name }) takes out of the pantry `items` (each { id, name, qty, unit }), times `scale`.
|
||||||
|
* Returns [{ line, item, take (in the item's own unit), unit, left, ok, why }]. Lines that match nothing in the pantry are not listed. */
|
||||||
|
function plan(ingredients, items, scale = 1) {
|
||||||
|
const out = [], left = new Map(items.map(x => [x.id, x.qty]));
|
||||||
|
for (const raw of ingredients) {
|
||||||
|
const ing = typeof raw === 'string' ? { ...parseIngredient(raw), line: raw } : { ...raw, line: raw.line || [raw.amount, raw.unit, raw.name].filter(x => x != null && x !== '').join(' ') };
|
||||||
|
let best = null; for (const it of items) if (aboutWords(ing.name, it.name) && (!best || words(it.name).length > words(best.name).length)) best = it; if (!best) continue;
|
||||||
|
const row = { line: ing.line, item: { id: best.id, name: best.name }, unit: best.unit || '', ok: false, take: 0, left: null, why: '' };
|
||||||
|
if (best.qty == null) { row.why = 'not counted'; out.push(row); continue; }
|
||||||
|
if (ing.amount == null) { row.why = 'no amount in the recipe (“to taste”)'; out.push(row); continue; }
|
||||||
|
const a = ing.amount * (scale || 1), fromU = ing.unit, toU = best.unit || '';
|
||||||
|
let take = fromU === '' && toU === '' ? a : fromU !== '' && toU !== '' ? convert(a, fromU, toU, best.name + ' ' + ing.name) : null;
|
||||||
|
if (take == null) row.why = fromU === '' ? `the recipe counts ${best.name.toLowerCase()} but the pantry has it in ${toU}` : toU === '' ? `the recipe uses ${fromU} but the pantry counts ${best.name.toLowerCase()}` : `cannot turn ${fromU} into ${toU} for ${best.name.toLowerCase()}`;
|
||||||
|
else { take = r4(take); const now = left.get(best.id); row.ok = true; row.take = take; row.left = Math.max(0, r4(now - take)); left.set(best.id, row.left); if (take > now) row.why = 'more than the pantry has'; }
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
module.exports = { UNITS, unitOf, family, density, leadingNumber, parseIngredient, convert, fmt, plan, words };
|
||||||
Reference in new issue
Block a user