Pantry: barcode scanning with several barcodes per item, a Take one screen for everyone including children, and no longer tied to Mealie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7483164b8
commit
ac55520a8c
15 files changed
+520
-62
No files matched your search
@@ -34,6 +34,7 @@ jobs:
|
||||
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
||||
- 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/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take
|
||||
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
||||
|
||||
- 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] **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] **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; they do not go down when you cook.**
|
||||
- [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] **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
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// The pantry: what the family keeps in the kitchen, one shared list. Each item has a name, how many are left (blank when you do not count it), the level it counts as running low
|
||||
// at (blank: never), and any number of barcodes linked to it, so Green Giant corn and the store brand are both "Corn" with one count. Scanning a barcode (on a phone) looks the product
|
||||
// up in Open Food Facts (through this server, so the phone never contacts it) and adds or uses one; the first time a barcode is seen the person says which item it is.
|
||||
// An item going low starts the "pantry item runs low" automations.
|
||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
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 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 fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
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 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 write = l => atomicWrite(file(), JSON.stringify(l), { mode: 0o600 });
|
||||
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));
|
||||
// Who took what: the last few uses, so parents can see where the snacks went.
|
||||
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 []; } };
|
||||
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 }); }
|
||||
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. */
|
||||
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. */
|
||||
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); }
|
||||
|
||||
// ---- matching words: "3 cups flour" has "flour"; "Green Giant Sweet Corn" has "Corn"
|
||||
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
|
||||
const wordsOf = t => (String(t).toLowerCase().match(/[a-z]+/g) || []).map(stem);
|
||||
/** The pantry names (from items or strings) that every word of is in `text`. */
|
||||
function inPantry(text, items) { const have = new Set(wordsOf(text)); return items.map(x => typeof x === 'string' ? x : x.name).filter(n => { const ws = wordsOf(n); return ws.length && ws.every(w => have.has(w)); }); }
|
||||
/** A short name for a product: the brand and the size taken off ("Heinz Tomato Ketchup 500 ml" -> "Tomato Ketchup"). */
|
||||
function shortName(name, brand) {
|
||||
let n = String(name || '').replace(/\b\d+([.,]\d+)?\s?(x\s?\d+\s?)?(ml|cl|l|g|kg|oz|fl\.?\s?oz|lb|lbs|ct|count|pack|pk)\b\.?/gi, ' ');
|
||||
for (const b of String(brand || '').split(',').map(x => x.trim()).filter(Boolean)) n = n.replace(new RegExp('\\b' + b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'ig'), ' ');
|
||||
n = n.replace(/[()\[\],]/g, ' ').replace(/\s+/g, ' ').trim(); return (n || String(name || '').trim()).replace(/\b\w/g, c => c.toUpperCase()).slice(0, 60);
|
||||
}
|
||||
|
||||
// ---- the product database (Open Food Facts), remembered so a barcode is only looked up once
|
||||
const loadCache = () => { try { return JSON.parse(fs.readFileSync(cacheFile(), 'utf8')) || {}; } catch { return {}; } };
|
||||
async function product(code) {
|
||||
const c = loadCache(), hit = c[code]; if (hit && (hit.found || Date.now() - hit.t < 24 * 3600e3)) return hit.found ? hit : null; // (a miss is tried again after a day)
|
||||
let found = null;
|
||||
try {
|
||||
const r = await fetch(`${OFF()}/api/v2/product/${code}.json?fields=product_name,brands,quantity,generic_name`, { headers: { 'user-agent': 'NE-Hub/1.0 (family pantry)', accept: 'application/json' }, signal: AbortSignal.timeout(6000) });
|
||||
if (r.ok) { const j = await r.json(), p = j && j.status === 1 && j.product; if (p && (p.product_name || p.generic_name)) found = { name: String(p.product_name || p.generic_name).slice(0, 120), brand: String(p.brands || '').slice(0, 80), quantity: String(p.quantity || '').slice(0, 30) }; }
|
||||
} catch { return undefined; } // (could not ask: not remembered)
|
||||
c[code] = found ? { found: true, t: Date.now(), ...found } : { found: false, t: Date.now() };
|
||||
const keys = Object.keys(c); if (keys.length > 3000) for (const k of keys.sort((a, b) => c[a].t - c[b].t).slice(0, 500)) delete c[k];
|
||||
fs.mkdirSync(DATA(), { recursive: true }); atomicWrite(cacheFile(), JSON.stringify(c), { mode: 0o600 }); return found ? c[code] : null;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const me = users.current(), u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/pantry'.length) || '/';
|
||||
try {
|
||||
if (!me) throw fail(401, 'Sign in');
|
||||
if (p === '/' && req.method === 'GET') return json(res, 200, { items: view(read()), recent: readLog().slice(0, 12), kid: isKid(me) });
|
||||
if (p === '/lookup' && req.method === 'GET') { // what is this barcode? Linked to an item already, or which products and items it might be
|
||||
const code = u.searchParams.get('code') || ''; if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)');
|
||||
const l = read(), linked = l.find(x => x.codes.includes(code)); if (linked) return json(res, 200, { code, linked: { id: linked.id, name: linked.name } });
|
||||
const pr = await product(code), name = pr ? shortName(pr.name, pr.brand) : '';
|
||||
return json(res, 200, { code, linked: null, product: pr ? { name: pr.name, brand: pr.brand, quantity: pr.quantity, short: name } : null, lookedUp: pr !== undefined, matches: pr ? view(l.filter(x => inPantry(pr.name, [x.name]).length)).map(x => ({ id: x.id, name: x.name })) : [] });
|
||||
}
|
||||
if (req.method !== 'POST') throw fail(404, 'Not found');
|
||||
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.
|
||||
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.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) });
|
||||
}
|
||||
if (isKid(me) && !(p === '/scan' && b.mode === 'use' && l.some(x => x.codes.includes(String(b.code))))) throw fail(403, 'Ask a grown-up to change the pantry. You can tap an item to take one.');
|
||||
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 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 : [] };
|
||||
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 === '/delete') { const k = l.filter(x => x.id !== b.id); write(k); return json(res, 200, { items: view(k) }); }
|
||||
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;
|
||||
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 && 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) 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); }
|
||||
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) });
|
||||
}
|
||||
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');
|
||||
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||
}
|
||||
module.exports = { handle, read, isLow, inPantry, shortName };
|
||||
@@ -81,6 +81,7 @@
|
||||
<script defer src="/radio-view.js"></script>
|
||||
<script defer src="/store-view.js"></script>
|
||||
<script defer src="/recipes-view.js"></script>
|
||||
<script defer src="/pantry-view.js"></script>
|
||||
<script defer src="/contacts-view.js"></script>
|
||||
<script defer src="/bible-view.js"></script>
|
||||
<script defer src="/devotion-view.js"></script>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'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;
|
||||
// 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 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 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>` : '';
|
||||
/** 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() {
|
||||
let st; const v = $('#view'), kid = !!(ME && ME.kid && ME.kid.on);
|
||||
try { st = await paApi('/'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/recipes')) return;
|
||||
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>
|
||||
<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="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;
|
||||
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);
|
||||
};
|
||||
draw();
|
||||
}
|
||||
async function viewPantry(sub) {
|
||||
if (sub === 'take' || (ME && ME.kid && ME.kid.on)) return viewPantryTake();
|
||||
let items = [], recent = []; const v = $('#view');
|
||||
try { const r = await paApi('/'); items = r.items; recent = r.recent || []; } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/recipes/pantry')) return;
|
||||
const draw = () => {
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
${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>
|
||||
<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); } };
|
||||
$('#paf').onsubmit = e => { e.preventDefault(); go('/item', { name: $('#pan').value, qty: $('#paq').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-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-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 }); }); });
|
||||
$('#pasadd').onclick = () => pantryScan('add', list => { items = list; draw(); }, () => items); $('#pasuse').onclick = () => pantryScan('use', list => { items = list; draw(); }, () => items);
|
||||
};
|
||||
draw();
|
||||
}
|
||||
|
||||
/** The scanner: the camera looks for barcodes and each one found adds one (or uses one). Keeps going so a bag of shopping can be done in a row. */
|
||||
let paZx = null;
|
||||
const paLoadZx = () => paZx || (paZx = new Promise((ok, no) => { if (window.ZXing) return ok(window.ZXing); const s = document.createElement('script'); s.src = '/vendor/zxing/zxing.min.js'; s.onload = () => ok(window.ZXing); s.onerror = () => { paZx = null; no(new Error('Could not load the scanner')); }; document.head.appendChild(s); }));
|
||||
function pantryScan(mode, onChange, getItems) {
|
||||
const el = openSheet(mode === 'use' ? 'Scan to use one' : 'Scan to add', mode === 'use' ? 'Each barcode takes one off the count.' : 'Each barcode adds one to the count.', `<video id="pscv" playsinline muted autoplay style="width:100%;border-radius:12px;background:#000;aspect-ratio:4/3;object-fit:cover"></video>
|
||||
<p class="sub" id="pscs" role="status" style="margin:8px 0">Starting the camera…</p><div id="pscq"></div>
|
||||
<form id="pscm" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pscn" inputmode="numeric" placeholder="Or type the barcode number" autocomplete="off" aria-label="Barcode number" style="flex:1;min-width:0"><button class="btn ghost">Go</button></form>`);
|
||||
const video = el.querySelector('#pscv'), status = t => { const s = el.querySelector('#pscs'); if (s) s.textContent = t; };
|
||||
let stream = null, stopped = false, busy = false, last = '', lastAt = 0, reader = null, timer = 0;
|
||||
const stop = () => { stopped = true; clearInterval(timer); try { reader && reader.reset(); } catch { /* not started */ } if (stream) stream.getTracks().forEach(t => t.stop()); };
|
||||
const alive = () => !stopped && el.isConnected;
|
||||
const resume = () => { busy = false; if (alive()) { el.querySelector('#pscq').innerHTML = ''; status('Point the camera at a barcode'); } };
|
||||
const finish = (r, said) => { onChange(r.items); status(said); toast(said); try { navigator.vibrate && navigator.vibrate(60); } catch { /* no vibration */ } setTimeout(resume, 1300); };
|
||||
const found = async code => {
|
||||
if (busy || !alive() || (code === last && Date.now() - lastAt < 4000)) return; busy = true; last = code; lastAt = Date.now(); status('Looking up ' + code + '…');
|
||||
try {
|
||||
const r = await paApi('/lookup?code=' + code); if (!alive()) return;
|
||||
if (r.linked) { const o = await paApi('/scan', paOpt({ code, mode })); return finish(o, `${o.item.name}: ${o.item.qty} left${o.item.isLow ? ' (running low)' : ''}`); }
|
||||
const list = (getItems() || []), matches = (r.matches || []).map(m => m.id), sorted = [...list.filter(x => matches.includes(x.id)), ...list.filter(x => !matches.includes(x.id))];
|
||||
el.querySelector('#pscq').innerHTML = `<form id="pscf"><p style="margin:0 0 6px"><b>${esc(r.product ? r.product.name : 'Barcode ' + code)}</b>${r.product && r.product.brand ? ' <small>· ' + esc(r.product.brand) + '</small>' : ''}</p>
|
||||
${!r.product ? `<p class="sub" style="margin:0 0 6px">${r.lookedUp ? 'The product database does not know this one.' : 'Could not reach the product database.'} Say what it is once and it is remembered.</p>` : '<p class="sub" style="margin:0 0 6px">First time this barcode is seen. Which item is it?</p>'}
|
||||
<label for="pscw">Which item</label><select id="pscw">${sorted.map(x => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}<option value=""${matches.length ? '' : ' selected'}>A new item…</option></select>
|
||||
<div id="pscnw"${matches.length ? ' hidden' : ''}><label for="pscnn">Name of the new item</label><input type="text" id="pscnn" value="${esc(r.product ? r.product.short : '')}" autocomplete="off" maxlength="60"></div>
|
||||
<div class="bar"><button type="button" class="btn ghost" id="pscx" style="flex:1;justify-content:center">Skip</button><button class="btn" style="flex:1;justify-content:center">${mode === 'use' ? 'Use one' : 'Add one'}</button></div><div class="err" id="psce"></div></form>`;
|
||||
const sel = el.querySelector('#pscw'), nw = el.querySelector('#pscnw'); sel.onchange = () => { nw.hidden = !!sel.value; }; if (sel.value === '' ) nw.hidden = false; status('Which item is this?');
|
||||
el.querySelector('#pscx').onclick = resume;
|
||||
el.querySelector('#pscf').onsubmit = async e => { e.preventDefault(); try { const o = await paApi('/scan', paOpt({ code, mode, itemId: sel.value || undefined, name: sel.value ? undefined : el.querySelector('#pscnn').value })); finish(o, `${o.item.name}: ${o.item.qty} left${o.made ? ' (new item)' : ''}`); } catch (er) { el.querySelector('#psce').textContent = er.message; } };
|
||||
} catch (e) { status(e.message); toast(e.message); setTimeout(resume, 1500); }
|
||||
};
|
||||
el.querySelector('#pscm').onsubmit = e => { e.preventDefault(); const c = el.querySelector('#pscn').value.replace(/\D/g, ''); el.querySelector('#pscn').value = ''; if (/^\d{8,14}$/.test(c)) { last = ''; found(c); } else status('A barcode is 8 to 14 digits.'); };
|
||||
(async () => {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return status('This browser cannot use the camera here (it needs a secure page). Type the number instead.');
|
||||
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); } catch (e) { return status(e && e.name === 'NotAllowedError' ? 'The camera is blocked. Allow it for this site, or type the number instead.' : 'No camera could be started. Type the number instead.'); }
|
||||
if (!alive()) return stop(); video.srcObject = stream; try { await video.play(); } catch { /* autoplay is allowed on a tap */ } status('Point the camera at a barcode');
|
||||
if ('BarcodeDetector' in window) { // Chrome and Android have a built-in reader
|
||||
let det; try { det = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e'] }); } catch { det = null; }
|
||||
if (det) { timer = setInterval(async () => { if (!alive()) return stop(); if (busy || video.readyState < 2) return; try { const r = await det.detect(video); if (r[0] && r[0].rawValue) found(r[0].rawValue); } catch { /* a frame that cannot be read */ } }, 300); return; }
|
||||
}
|
||||
try { // everything else (iPhone Safari): a reader written in JavaScript, loaded only now
|
||||
const Z = await paLoadZx(), hints = new Map(); hints.set(Z.DecodeHintType.POSSIBLE_FORMATS, [Z.BarcodeFormat.EAN_13, Z.BarcodeFormat.EAN_8, Z.BarcodeFormat.UPC_A, Z.BarcodeFormat.UPC_E]); reader = new Z.BrowserMultiFormatReader(hints);
|
||||
timer = setInterval(() => { if (!alive()) stop(); }, 500);
|
||||
reader.decodeFromVideoElement(video, (res) => { if (res && alive() && !busy) found(res.getText()); });
|
||||
} catch (e) { status(e.message + ' Type the number instead.'); }
|
||||
})();
|
||||
new MutationObserver((_, ob) => { if (!el.isConnected) { stop(); ob.disconnect(); } }).observe(document.body, { childList: true });
|
||||
}
|
||||
+5
-22
@@ -4,7 +4,7 @@
|
||||
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
|
||||
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
||||
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
|
||||
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
||||
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'pantry' ? '' : ' ghost'}" href="#/recipes/pantry">${icon('list')}Pantry</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
||||
let rcQuery = '', rcPage = 1;
|
||||
|
||||
async function viewRecipes(args = []) {
|
||||
@@ -12,12 +12,14 @@ async function viewRecipes(args = []) {
|
||||
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty">Loading…</p></div>';
|
||||
try {
|
||||
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
||||
if (args[0] === 'pantry') return await viewPantry(args[1]);
|
||||
if (ME && ME.kid && ME.kid.on && !args[0]) return await viewPantry('take'); // children see the pantry to take from
|
||||
if (args[0] === 'shop') return await rcShop();
|
||||
if (args[0] === 'plan') return await rcPlan(args[1]);
|
||||
return await rcHome();
|
||||
} catch (e) {
|
||||
if (!location.hash.startsWith('#/recipes')) return;
|
||||
v.innerHTML = `<div class="wrap"><h1>Recipes</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`;
|
||||
v.innerHTML = `<div class="wrap"><h1>Recipes</h1>${rcTabs('')}<p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`; // (the Pantry does not need Mealie, so its tab is here even when recipes are not set up)
|
||||
$('#rcretry').onclick = () => route();
|
||||
}
|
||||
}
|
||||
@@ -122,7 +124,7 @@ async function rcPlan(start) {
|
||||
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button><button class="btn ghost" id="rcpantry">Pantry</button></div><div id="rcphave"></div></div>`;
|
||||
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
|
||||
document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } });
|
||||
$('#rcpantry').onclick = () => rcPantry(() => rcPlan(rcYmd(mon)));
|
||||
$('#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(() => {});
|
||||
$('#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,22 +139,3 @@ function rcPlanPick(slug, name, rid, date) {
|
||||
let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
|
||||
}
|
||||
|
||||
/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */
|
||||
async function rcPantry(onClose) {
|
||||
let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); }
|
||||
const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', '<div id="pyl"></div>');
|
||||
const draw = () => {
|
||||
el.querySelector('#pyl').innerHTML = `<form id="pyf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pyn" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="pyq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="pyw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
|
||||
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}</small></span>
|
||||
${x.isLow ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen.</small></span></div>'}</div>
|
||||
<div class="bar"><button class="btn" id="pydone" style="flex:1;justify-content:center">Done</button></div>`;
|
||||
const go = async (p, o) => { try { items = (await rcApi(p, rcOpt('POST', o))).items; draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#pyf').onsubmit = e => { e.preventDefault(); go('/pantry/item', { name: el.querySelector('#pyn').value, qty: el.querySelector('#pyq').value, low: el.querySelector('#pyw').value }); };
|
||||
el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/pantry/adjust', { id, delta: +d }); });
|
||||
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/pantry/delete', { id: b.dataset.x }));
|
||||
el.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
|
||||
el.querySelector('#pydone').onclick = () => { closeSheet(); if (onClose) onClose(); };
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -1106,3 +1106,4 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho
|
||||
html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
|
||||
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
|
||||
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v122';
|
||||
const CACHE = 'hub-v125';
|
||||
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 SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
Vendored
+245
@@ -0,0 +1,245 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
========================================================================
|
||||
jai-imageio
|
||||
========================================================================
|
||||
|
||||
Copyright (c) 2005 Sun Microsystems, Inc.
|
||||
Copyright © 2010-2014 University of Manchester
|
||||
Copyright © 2010-2015 Stian Soiland-Reyes
|
||||
Copyright © 2015 Peter Hull
|
||||
All Rights Reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
- Redistribution of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
- Redistribution in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
Neither the name of Sun Microsystems, Inc. or the names of
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
This software is provided "AS IS," without a warranty of any
|
||||
kind. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND
|
||||
WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE OR NON-INFRINGEMENT, ARE HEREBY
|
||||
EXCLUDED. SUN MIDROSYSTEMS, INC. ("SUN") AND ITS LICENSORS SHALL
|
||||
NOT BE LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF
|
||||
USING, MODIFYING OR DISTRIBUTING THIS SOFTWARE OR ITS
|
||||
DERIVATIVES. IN NO EVENT WILL SUN OR ITS LICENSORS BE LIABLE FOR
|
||||
ANY LOST REVENUE, PROFIT OR DATA, OR FOR DIRECT, INDIRECT, SPECIAL,
|
||||
CONSEQUENTIAL, INCIDENTAL OR PUNITIVE DAMAGES, HOWEVER CAUSED AND
|
||||
REGARDLESS OF THE THEORY OF LIABILITY, ARISING OUT OF THE USE OF OR
|
||||
INABILITY TO USE THIS SOFTWARE, EVEN IF SUN HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
You acknowledge that this software is not designed or intended for
|
||||
use in the design, construction, operation or maintenance of any
|
||||
nuclear facility.
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).ZXing={})}(this,(function(t){"use strict";var e,r,n=(e=function(t,r){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},e(t,r)},function(t,r){if("function"!=typeof r&&null!==r)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");function n(){this.constructor=t}e(t,r),t.prototype=null===r?Object.create(r):(n.prototype=r.prototype,new n)}),i=function(t){function e(e,r){var n,i,s,o=this.constructor,a=t.call(this,e,r)||this;return Object.defineProperty(a,"name",{value:o.name,enumerable:!1,configurable:!0}),n=a,i=o.prototype,(s=Object.setPrototypeOf)?s(n,i):n.__proto__=i,function(t,e){void 0===e&&(e=t.constructor);var r=Error.captureStackTrace;r&&r(t,e)}(a),a}return n(e,t),e}(Error);class s extends i{constructor(t=void 0){super(t),this.message=t}getKind(){return this.constructor.kind}}s.kind="Exception";class o extends s{}o.kind="ArgumentException";class a extends s{}a.kind="IllegalArgumentException";class h{constructor(t){if(this.binarizer=t,null===t)throw new a("Binarizer must be non-null.")}getWidth(){return this.binarizer.getWidth()}getHeight(){return this.binarizer.getHeight()}getBlackRow(t,e){return this.binarizer.getBlackRow(t,e)}getBlackMatrix(){return null!==this.matrix&&void 0!==this.matrix||(this.matrix=this.binarizer.getBlackMatrix()),this.matrix}isCropSupported(){return this.binarizer.getLuminanceSource().isCropSupported()}crop(t,e,r,n){const i=this.binarizer.getLuminanceSource().crop(t,e,r,n);return new h(this.binarizer.createBinarizer(i))}isRotateSupported(){return this.binarizer.getLuminanceSource().isRotateSupported()}rotateCounterClockwise(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise();return new h(this.binarizer.createBinarizer(t))}rotateCounterClockwise45(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise45();return new h(this.binarizer.createBinarizer(t))}toString(){try{return this.getBlackMatrix().toString()}catch(t){return""}}}class l extends s{static getChecksumInstance(){return new l}}l.kind="ChecksumException";class c{constructor(t){this.source=t}getLuminanceSource(){return this.source}getWidth(){return this.source.getWidth()}getHeight(){return this.source.getHeight()}}class d{static arraycopy(t,e,r,n,i){for(;i--;)r[n++]=t[e++]}static currentTimeMillis(){return Date.now()}}class u extends s{}u.kind="IndexOutOfBoundsException";class g extends u{constructor(t=void 0,e=void 0){super(e),this.index=t,this.message=e}}g.kind="ArrayIndexOutOfBoundsException";class f{static fill(t,e){for(let r=0,n=t.length;r<n;r++)t[r]=e}static fillWithin(t,e,r,n){f.rangeCheck(t.length,e,r);for(let i=e;i<r;i++)t[i]=n}static rangeCheck(t,e,r){if(e>r)throw new a("fromIndex("+e+") > toIndex("+r+")");if(e<0)throw new g(e);if(r>t)throw new g(r)}static asList(...t){return t}static create(t,e,r){return Array.from({length:t}).map((t=>Array.from({length:e}).fill(r)))}static createInt32Array(t,e,r){return Array.from({length:t}).map((t=>Int32Array.from({length:e}).fill(r)))}static equals(t,e){if(!t)return!1;if(!e)return!1;if(!t.length)return!1;if(!e.length)return!1;if(t.length!==e.length)return!1;for(let r=0,n=t.length;r<n;r++)if(t[r]!==e[r])return!1;return!0}static hashCode(t){if(null===t)return 0;let e=1;for(const r of t)e=31*e+r;return e}static fillUint8Array(t,e){for(let r=0;r!==t.length;r++)t[r]=e}static copyOf(t,e){return t.slice(0,e)}static copyOfUint8Array(t,e){if(t.length<=e){const r=new Uint8Array(e);return r.set(t),r}return t.slice(0,e)}static copyOfRange(t,e,r){const n=r-e,i=new Int32Array(n);return d.arraycopy(t,e,i,0,n),i}static binarySearch(t,e,r){void 0===r&&(r=f.numberComparator);let n=0,i=t.length-1;for(;n<=i;){const s=i+n>>1,o=r(e,t[s]);if(o>0)n=s+1;else{if(!(o<0))return s;i=s-1}}return-n-1}static numberComparator(t,e){return t-e}}class w{static numberOfTrailingZeros(t){let e;if(0===t)return 32;let r=31;return e=t<<16,0!==e&&(r-=16,t=e),e=t<<8,0!==e&&(r-=8,t=e),e=t<<4,0!==e&&(r-=4,t=e),e=t<<2,0!==e&&(r-=2,t=e),r-(t<<1>>>31)}static numberOfLeadingZeros(t){if(0===t)return 32;let e=1;return t>>>16==0&&(e+=16,t<<=16),t>>>24==0&&(e+=8,t<<=8),t>>>28==0&&(e+=4,t<<=4),t>>>30==0&&(e+=2,t<<=2),e-=t>>>31,e}static toHexString(t){return t.toString(16)}static toBinaryString(t){return String(parseInt(String(t),2))}static bitCount(t){return t=(t=(858993459&(t-=t>>>1&1431655765))+(t>>>2&858993459))+(t>>>4)&252645135,t+=t>>>8,63&(t+=t>>>16)}static truncDivision(t,e){return Math.trunc(t/e)}static parseInt(t,e=void 0){return parseInt(t,e)}}w.MIN_VALUE_32_BITS=-2147483648,w.MAX_VALUE=Number.MAX_SAFE_INTEGER;class C{constructor(t,e){void 0===t?(this.size=0,this.bits=new Int32Array(1)):(this.size=t,this.bits=null==e?C.makeArray(t):e)}gLine truncated
|
||||
+3
-27
@@ -104,18 +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 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. 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 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) => { 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); } }
|
||||
const pantry = require('./pantry'); // 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). */
|
||||
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));
|
||||
@@ -164,22 +153,9 @@ 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: 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().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; } }
|
||||
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; } }
|
||||
return json(res, 200, { items: out });
|
||||
}
|
||||
if (p === '/plan/shop' && req.method === 'POST') { // every planned meal in the range goes on the shopping list (a recipe planned twice counts twice)
|
||||
|
||||
@@ -48,6 +48,7 @@ const contacts = require('./contacts'); // each person's address book (mail su
|
||||
const activity = require('./activity'), alertParents = activity.alertParents;
|
||||
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
|
||||
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
|
||||
const pantry = require('./pantry'); // the family pantry, with barcode scanning
|
||||
const automations = require('./automations'); // when this happens, do that (per person)
|
||||
const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list
|
||||
const chores = require('./chores'); // chores for the family, with points
|
||||
@@ -586,6 +587,7 @@ const ACCESS = [
|
||||
[/^\/api\/bible\//, ['bible']],
|
||||
[/^\/api\/read\//, ['read']],
|
||||
[/^\/api\/radio\//, ['radio']],
|
||||
[/^\/api\/pantry(\/|$)/, ['recipes']],
|
||||
[/^\/api\/moviefam(\/|$)/, ['video']],
|
||||
[/^\/api\/chores(\/|$)/, ['chores']],
|
||||
[/^\/api\/notes(\/|$)/, ['notes']],
|
||||
@@ -612,7 +614,7 @@ function securityHeaders(res) {
|
||||
res.setHeader('Referrer-Policy', 'same-origin');
|
||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'");
|
||||
res.setHeader('Content-Security-Policy-Report-Only', screentime.policy()); // (reports only: nothing is blocked)
|
||||
res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()');
|
||||
res.setHeader('Permissions-Policy', 'camera=(self), geolocation=(), payment=()');
|
||||
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
|
||||
}
|
||||
/** Cross-site request check: a browser request that changes something must come from this same site. */
|
||||
@@ -716,6 +718,7 @@ async function handleRequest(req, res) {
|
||||
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
|
||||
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
|
||||
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
|
||||
if (pathname === '/api/pantry' || pathname.startsWith('/api/pantry/')) return pantry.handle(req, res);
|
||||
if (pathname === '/api/auto' || pathname.startsWith('/api/auto/')) return automations.handle(req, res);
|
||||
if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res);
|
||||
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
|
||||
|
||||
@@ -32,7 +32,7 @@ for (const f of listed) {
|
||||
}
|
||||
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
||||
// the pieces the app calls by name across files are there
|
||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||
// the watch-stats counting (a pure function)
|
||||
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
|
||||
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// The pantry: items with counts and low levels, several barcodes per item (corn from different brands is one item), product lookup (a stand-in for Open Food Facts), and what happens when a count goes low.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-pantry-'));
|
||||
let asked = [], down = false;
|
||||
const off = http.createServer((req, res) => { const code = /product\/(\d+)\.json/.exec(req.url)[1]; asked.push(code); if (down) { res.destroy(); return; }
|
||||
const P = { '0016000275287': { product_name: 'Green Giant Sweet Corn 15.25 oz', brands: 'Green Giant', quantity: '15.25 oz' }, '0024000163275': { product_name: "Libby's Whole Kernel Corn 425 g", brands: "Libby's" }, '0070470003344': { product_name: 'Greek Yogurt', brands: 'Chobani' } };
|
||||
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(P[code] ? { status: 1, product: P[code] } : { status: 0 })); }).listen(0, () => {
|
||||
process.env.OPENFOODFACTS_URL = 'http://127.0.0.1:' + off.address().port;
|
||||
const users = require('../users'), pantry = require('../pantry'), automations = require('../automations');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['recipes'], handle: 'ZP' }), kid = users.create({ name: 'Kit', role: 'user', tabs: ['recipes'], handle: 'KD' }); kid.kid = { on: true };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : req.headers['x-user'] === 'kid' ? kid : ann, () => pantry.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/pantry', j = r => r.json(), get = p => fetch(base + p), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} });
|
||||
const fired = []; const realFireAll = automations.fireAll; automations.fireAll = async (t, c) => { fired.push(t + ':' + c.title); };
|
||||
try {
|
||||
// names are cleaned from products
|
||||
assert.equal(pantry.shortName('Heinz Tomato Ketchup 500 ml', 'Heinz'), 'Tomato Ketchup'); assert.equal(pantry.shortName('Green Giant Sweet Corn 15.25 oz', 'Green Giant'), 'Sweet Corn'); assert.equal(pantry.shortName('', ''), '');
|
||||
// items: add, count, change by the same name, adjust, low levels
|
||||
assert.equal((await post('/item', { name: ' ' })).status, 400);
|
||||
let r = await j(await post('/item', { name: 'Corn', qty: 2, low: 1 })); assert.deepEqual(r.items.map(x => [x.name, x.qty, x.low, x.isLow, x.codes.length]), [['Corn', 2, 1, false, 0]]); const corn = r.items[0].id;
|
||||
r = await j(await post('/item', { name: 'corn', qty: 2, low: 1 })); assert.equal(r.items.length, 1, 'the same name updates the item'); assert.equal(r.items[0].name, 'Corn');
|
||||
r = await j(await post('/adjust', { id: corn, delta: -1 })); assert.equal(r.items[0].isLow, true); assert.deepEqual(fired, ['pantry.low:Corn'], 'going low starts the rules, once'); await post('/adjust', { id: corn, delta: -1 }); assert.equal(fired.length, 1, 'and not again while it stays low');
|
||||
assert.equal((await j(await post('/adjust', { id: corn, delta: -9 }))).items[0].qty, 0, 'never below none'); await post('/adjust', { id: corn, delta: 5 });
|
||||
// a barcode we have not seen: the product is looked up, and the items it might be are offered
|
||||
assert.equal((await get('/lookup?code=abc')).status, 400);
|
||||
r = await j(await get('/lookup?code=0016000275287')); assert.equal(r.linked, null); assert.equal(r.product.short, 'Sweet Corn'); assert.deepEqual(r.matches.map(m => m.name), ['Corn'], 'Corn is offered, since the product has the word corn');
|
||||
assert.equal((await post('/scan', { code: '0016000275287' })).status, 400, 'a new barcode must say which item it is');
|
||||
// scanning links it to the chosen item and adds one
|
||||
r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); assert.equal(r.item.name, 'Corn'); assert.equal(r.item.qty, 6); assert.equal(r.made, false);
|
||||
asked = []; r = await j(await get('/lookup?code=0016000275287')); assert.deepEqual(r.linked, { id: corn, name: 'Corn' }, 'next time it goes straight to Corn'); assert.deepEqual(asked, [], 'without asking the product database again');
|
||||
r = await j(await post('/scan', { code: '0016000275287' })); assert.equal(r.item.qty, 7);
|
||||
// a second brand is the same item, with one count
|
||||
r = await j(await post('/scan', { code: '0024000163275', itemId: corn })); assert.equal(r.items.length, 1, 'still one item'); assert.equal(r.items[0].qty, 8); assert.equal(r.items[0].codes.length, 2, 'with two barcodes');
|
||||
// use one
|
||||
r = await j(await post('/scan', { code: '0024000163275', mode: 'use' })); assert.equal(r.item.qty, 7);
|
||||
// a new item made from a scan; the same name later finds it; products the database does not know are named by hand
|
||||
r = await j(await post('/scan', { code: '0070470003344', name: 'Yogurt' })); assert.equal(r.made, true); assert.equal(r.item.qty, 1); r = await j(await get('/lookup?code=1234567890128')); assert.equal(r.product, null); assert.equal(r.lookedUp, true);
|
||||
r = await j(await post('/scan', { code: '1234567890128', name: 'yogurt' })); assert.equal(r.made, false, 'a name that exists links to it'); assert.equal(r.item.qty, 2);
|
||||
// using one that takes it to its low level fires the rules
|
||||
fired.length = 0; await post('/item', { id: corn, name: 'Corn', qty: 3, low: 2 }); await post('/scan', { code: '0016000275287', mode: 'use' }); assert.deepEqual(fired, ['pantry.low:Corn']);
|
||||
// the database being down is not an error for the person: they just name it
|
||||
down = true; r = await j(await get('/lookup?code=5000112637922')); assert.equal(r.lookedUp, false); assert.equal(r.product, null); down = false;
|
||||
// moving a barcode to another item takes it off the first; unlinking
|
||||
const yog = (await j(await get('/'))).items.find(x => x.name === 'Yogurt').id; r = await j(await post('/scan', { code: '0016000275287', itemId: yog })); assert.equal((await j(await get('/lookup?code=0016000275287'))).linked.name, 'Corn', 'a linked barcode stays with its item');
|
||||
r = await j(await post('/unlink', { id: corn, code: '0016000275287' })); assert.equal(r.items.find(x => x.id === corn).codes.length, 1);
|
||||
assert.equal((await post('/delete', { id: yog })).status, 200); assert.ok(!(await j(await get('/'))).items.some(x => x.name === 'Yogurt'));
|
||||
// the simple way: take one (children too), with undo, and a note of who took what
|
||||
r = await j(await post('/item', { name: 'Granola bars', qty: 2, low: 1 })); const gb = r.items.find(x => x.name === 'Granola bars').id; fired.length = 0;
|
||||
r = await j(await post('/use', { id: gb }, 'kid')); assert.equal(r.item.qty, 1); assert.equal(r.item.isLow, true); assert.deepEqual(fired, ['pantry.low:Granola bars'], 'taking the second-to-last one starts the low rule'); assert.equal(r.recent[0].who, 'Kit'); assert.equal(r.recent[0].item, 'Granola bars');
|
||||
r = await j(await post('/use', { id: gb, undo: true }, 'kid')); assert.equal(r.item.qty, 2, 'undo puts it back'); await post('/use', { id: gb }, 'kid'); await post('/use', { id: gb }, 'kid'); assert.equal((await post('/use', { id: gb }, 'kid')).status, 409, 'none left to take');
|
||||
const note = (await j(await post('/item', { name: 'Napkins' }))).items.find(x => x.name === 'Napkins').id; r = await j(await post('/use', { id: note })); assert.equal(r.item.qty, null, 'an item that is not counted is only noted');
|
||||
// a child can look and take, and nothing else
|
||||
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);
|
||||
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');
|
||||
// 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.deepEqual(pantry.inPantry('3 cups all-purpose flour', ['Flour', 'Salt']), ['Flour']);
|
||||
console.log('pantry test ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
automations.fireAll = realFireAll; hub.close(); off.close();
|
||||
});
|
||||
});
|
||||
@@ -62,16 +62,12 @@ 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: 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' });
|
||||
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');
|
||||
// the pantry check: a planned ingredient the pantry has ("3 cups flour" is covered by "flour"; butter is not in the pantry, and an empty one is not "had")
|
||||
await post('/plan/add', { date: '2027-05-03', slug: 'bread', name: 'Bread' }); const pf = path.join(process.env.DATA_DIR, 'pantry.json');
|
||||
fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 3, low: 1, codes: [] }, { id: 'a2', name: 'Salt', qty: null, low: null, codes: [] }]));
|
||||
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 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']);
|
||||
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');
|
||||
// a wrong token is explained
|
||||
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
||||
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||
|
||||
Reference in new issue
Block a user