Files
hub/pantry.js
T

99 lines
10 KiB
JavaScript

// 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 };