openvibers/openvibe.food Commits

Food: add to your cupboard by barcode (Open Food Facts), typed or scanned (#53)

OP OpenVibers committed 29 days ago · 3b86c2be4f · 10 files, +346 −6

Changes

ddiff --git a/README.md b/README.md
iindex 2166fac..0b20a37 100644
---- a/README.md
++++ b/README.md
@@@ -23,9 +23,10 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Meal planner | [/plan](server/http/pages.js), [server/food/planner.js](server/food/planner.js) | One person, a couple or a group, 1–14 days: one item from each of the six USDA MyPlate food groups, cheapest per gram first, then the best calories a dollar, with a shopping list, the cheapest store per line and an estimated cost. Deterministic, and a budget is a hard cap |
| Food database | [/foods, /foods/:slug](server/http/pages.js), [server/food/foods.js](server/food/foods.js) | 42 items with nutrition per serving and per 100 g, the typical price band and the chain-by-chain comparison; server-rendered and in the sitemap |
| Pantry | [/pantry](server/http/pages.js), [server/food/pantry-match.js](server/food/pantry-match.js) | Signed in, keep what is in your cupboard and see which basic recipes you can make now, and what one more item would unlock. Deterministic; no model call |
++| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) |
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
--| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | The only outbound calls this service makes: paced, cached, identified, bounded |
++| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
| Platform | the skeleton | Sign-in with OpenVibe.Network, the OpenVibe Frame, limits, readiness, discovery, deploy files |
ddiff --git a/public/css/pantry.css b/public/css/pantry.css
nnew file mode 100644
iindex 0000000..ce0889b
---- /dev/null
++++ b/public/css/pantry.css
@@@ -0,0 +1,9 @@
++/* OpenVibe.Food — the cupboard's barcode entry and its confirm page only (/pantry, /pantry/barcode), so app.css stays inside its budget. */
++.pantry-barcode { margin: 12px 0 16px; max-width: 560px; }
++.pantry-barcode label { display: block; font-weight: 700; margin-bottom: 4px; }
++.pantry-barcode-row { display: flex; gap: 8px; flex-wrap: wrap; }
++.pantry-barcode-row input { flex: 1 1 200px; min-width: 0; font: inherit; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; }
++#bc-video { display: block; width: 100%; max-width: 420px; margin-top: 10px; border-radius: 12px; background: #000; }
++#bc-video[hidden] { display: none; }
++.pantry-confirm { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
++.pantry-confirm select { font: inherit; padding: 8px 10px; border-radius: 10px; max-width: 100%; }
ddiff --git a/public/js/barcode-scan.js b/public/js/barcode-scan.js
nnew file mode 100644
iindex 0000000..8a965a0
---- /dev/null
++++ b/public/js/barcode-scan.js
@@@ -0,0 +1,44 @@
++/* OpenVibe.Food — "Scan with camera" on /pantry: the browser's own barcode reader (BarcodeDetector), where it has one.
++ The camera picture never leaves the device: only the number read from it goes to /pantry/barcode, as if typed. */
++(function () {
++ 'use strict';
++ var btn = document.getElementById('bc-scan');
++ var video = document.getElementById('bc-video');
++ var input = document.getElementById('bc');
++ if (!btn || !video || !input || !('BarcodeDetector' in window) || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
++ btn.hidden = false;
++ var stream = null;
++ var timer = null;
++ function stop() {
++ if (timer) { clearInterval(timer); timer = null; }
++ if (stream) { stream.getTracks().forEach(function (t) { t.stop(); }); stream = null; }
++ video.hidden = true;
++ btn.textContent = 'Scan with camera';
++ }
++ btn.addEventListener('click', function () {
++ if (stream) { stop(); return; }
++ var detector = new window.BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a'] });
++ navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }).then(function (s) {
++ stream = s;
++ video.srcObject = s;
++ video.hidden = false;
++ btn.textContent = 'Stop the camera';
++ return video.play();
++ }).then(function () {
++ timer = setInterval(function () {
++ detector.detect(video).then(function (codes) {
++ var c = codes && codes[0] && codes[0].rawValue;
++ if (!c) return;
++ stop();
++ input.value = c;
++ input.form.submit();
++ }).catch(function () { /* the next frame */ });
++ }, 300);
++ }).catch(function () {
++ stop();
++ btn.textContent = 'Camera not available: type the number';
++ btn.disabled = true;
++ });
++ });
++ window.addEventListener('pagehide', stop);
++})();
ddiff --git a/server/app.js b/server/app.js
iindex f3ddbea..bcd3b5d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -62,6 +62,7 @@ async function createApp(opts = {}) {
// it from here, so every test points it at a stand-in through FOOD_NOMINATIM_URL / FOOD_OVERPASS_URL.
// Its own wall clock, not the store's: the pace between two calls must not depend on an injected test clock.
ctx.upstream = createUpstream({ config, s, fetchImpl, log });
++ ctx.barcodes = opts.barcodes || require('./food/barcode').createBarcodes({ config, fetchImpl });
// Drop expired geocode/area answers now and hourly; an expired row is ignored anyway, this only keeps the table
// small. The interval is unref'd and kept on ctx so the entry point clears it on shutdown (server/index.js).
ctx.geoPruneTimer = foodStore.startCachePrune(s, { log });
ddiff --git a/server/config.js b/server/config.js
iindex 6701dc9..5abacfc 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -41,6 +41,12 @@ function load(env = process.env) {
// The two OpenStreetMap services "food near you" stands on (server/food/places.js). Both are open data with
// published usage policies: Nominatim at most one request a second, Overpass small bounded area queries —
// server/food/upstream.js paces every call to one host at a time and caches every answer for geoCacheTtlMs.
++ // Pantry by barcode (server/food/barcode.js): Open Food Facts asks for an app name and a contact, and about 100
++ // product reads a minute; lookups are cached a day and capped at perMinute for the whole service.
++ barcode: {
++ userAgent: env.FOOD_BARCODE_USER_AGENT || `OpenVibeFood/0.1 (+${baseUrl}; [email protected])`,
++ perMinute: Math.max(1, int(env.FOOD_BARCODE_PER_MINUTE, 60)),
++ },
osm: {
nominatimUrl: trim(env.FOOD_NOMINATIM_URL || 'https://nominatim.openstreetmap.org'),
overpassUrl: trim(env.FOOD_OVERPASS_URL || 'https://overpass-api.de/api/interpreter'),
ddiff --git a/server/food/barcode.js b/server/food/barcode.js
nnew file mode 100644
iindex 0000000..1f3e87b
---- /dev/null
++++ b/server/food/barcode.js
@@@ -0,0 +1,131 @@
++'use strict';
++
++/**
++ * Pantry by barcode: a product's barcode (EAN-13, UPC-A, EAN-8, GTIN-14) is looked up on Open Food Facts
++ * (https://world.openfoodfacts.org, the open food product database: data under the Open Database License, so the page
++ * says where it came from), and the product is matched to the item on Food's own list it is (server/food/foods.js).
++ * The person confirms before anything is added: a wrong guess is one click to change, never a silent tick.
++ *
++ * normalize(code) digits only, 8 to 14 of them, with a valid check digit → the GTIN, else null
++ * matchFood(product) → a foods.js slug or null, from the product's name and Open Food Facts categories
++ * createBarcodes({ config, fetchImpl }).lookup(code) → { code, found, name, brand, quantity, match, url } | throws
++ *
++ * Open Food Facts asks for a User-Agent that names the app and a contact, and allows about 100 product reads a
++ * minute: lookups are cached for a day and the whole service makes at most FOOD_BARCODE_PER_MINUTE (60) a minute.
++ * Nothing about the person is sent: only the barcode.
++ */
++const foods = require('./foods');
++
++const API = 'https://world.openfoodfacts.org/api/v2/product';
++const FIELDS = 'product_name,product_name_en,generic_name,brands,quantity,categories_tags';
++
++function normalize(code) {
++ const d = String(code == null ? '' : code).replace(/[\s-]/g, '');
++ if (!/^\d{8}$|^\d{12,14}$/.test(d)) return null;
++ // GS1 check digit: from the right, the digits before the check digit weigh 3, 1, 3, 1, …
++ let sum = 0;
++ for (let i = d.length - 2, w = 3; i >= 0; i--, w = w === 3 ? 1 : 3) sum += Number(d[i]) * w;
++ return (10 - (sum % 10)) % 10 === Number(d[d.length - 1]) ? d : null;
++}
++
++// Produce, dairy and staples are matched only when the product is the thing itself, not something made from it.
++const MADE_FROM = /\b(juice|chips?|crisps|sauce|ketchup|jam|jelly|preserves|pie|cake|cookies?|candy|gummies|flavou?red|snack|bars?|soda|drink|smoothie|ice cream|dressing|seasoning|baby food|puree)\b/i;
++
++/** First match wins: the specific before the general (peanut butter before butter, canned chili before beans). */
++const RULES = [
++ ['canned-chili', /\bchili\b/i],
++ ['ramen-12pk', /\b(ramen|instant noodles?)\b/i],
++ ['canned-soup', /\bsoups?\b/i],
++ ['canned-tuna', /\btuna\b/i],
++ ['sardines', /\bsardines?\b/i],
++ ['peanut-butter', /peanut[\s-]*butter/i],
++ ['mac-cheese', /\bmac(aroni)?\s*(&|and|n'?)?\s*cheese\b/i],
++ ['granola-bars', /\b(granola|cereal)\s*bars?\b/i],
++ ['trail-mix', /\btrail\s*mix\b/i],
++ ['jiffy-mix', /\b(corn\s*)?muffin\s*mix\b|\bcornbread\s*mix\b/i],
++ ['instant-coffee', /\binstant\s*coffee\b/i],
++ ['powdered-milk', /\b(powdered|dry|nonfat dry)\s*milk\b|\bmilk\s*powder\b/i],
++ ['hot-dogs', /\bhot\s*dogs?\b|\bfranks\b|\bwieners?\b/i],
++ ['ground-beef', /\bground\s*beef\b|\bhamburger\s*meat\b/i],
++ ['chicken-breast', /\bchicken\s*breasts?\b/i],
++ ['canned-green-beans', /\bgreen\s*beans\b/i, true],
++ ['canned-beans', /\b(black|kidney|refried|cannellini|garbanzo|chick\s*peas?|chickpeas)\b.*\bbeans?\b|\bcanned\s*beans\b|\brefried\b|\bchickpeas\b/i],
++ ['dried-beans', /\b(pinto|navy|great northern)\s*beans\b|\bdried\s*beans\b|\bdry\s*beans\b/i],
++ ['whole-milk', /\b(whole|2%|1%|skim|reduced fat|fat free|lowfat|low fat)?\s*milk\b/i, true],
++ ['cheese-block', /\bcheddar\b|\bcheese\b/i, true],
++ ['yogurt', /\byog(h)?o?urt\b/i, true],
++ ['butter', /\bbutter\b/i, true],
++ ['cooking-oil', /\b(vegetable|canola|olive|sunflower|corn|soybean|cooking)\s*oil\b/i],
++ ['white-rice', /\brice\b/i, true],
++ ['spaghetti', /\bspaghetti\b|\bpasta\b|\bpenne\b|\bmacaroni\b|\blinguine\b|\brotini\b/i, true],
++ ['tortillas', /\btortillas?\b/i, true],
++ ['bread-white', /\bbread\b|\bsandwich loaf\b/i, true],
++ ['oatmeal', /\boats\b|\boatmeal\b|\brolled oats\b/i, true],
++ ['potatoes-5lb', /\bpotato(es)?\b/i, true],
++ ['canned-corn', /\b(whole kernel|sweet)?\s*corn\b/i, true],
++ ['baby-carrots', /\bcarrots?\b/i, true],
++ ['frozen-mixed-veg', /\bmixed\s*veg(etable)?s?\b/i, true],
++ ['onion-3lb', /\bonions?\b/i, true],
++ ['canned-tomatoes', /\b(diced|crushed|whole|stewed)\s*tomato(es)?\b|\btomatoes\b/i, true],
++ ['bananas', /\bbananas?\b/i, true],
++ ['apples-3lb', /\bapples?\b/i, true],
++ ['oranges-3lb', /\boranges?\b/i, true],
++ ['canned-fruit', /\bpeach(es)?\b|\bfruit\s*cocktail\b|\bpears?\b|\bcanned\s*fruit\b|\bpineapple\b|\bmandarin\b/i, true],
++ ['raisins', /\braisins?\b/i, true],
++ ['strawberries', /\bstrawberr(y|ies)\b/i, true],
++ ['avocado', /\bavocados?\b/i, true],
++ ['eggs-dozen', /\beggs?\b/i, true],
++];
++
++/** The foods.js slug this product is, or null. Categories are Open Food Facts tags ("en:peanut-butters"). */
++function matchFood(product) {
++ if (!product) return null;
++ const name = [product.product_name_en, product.product_name, product.generic_name].filter(Boolean).join(' ');
++ const cats = (Array.isArray(product.categories_tags) ? product.categories_tags : []).filter((c) => /^en:/.test(c)).map((c) => c.slice(3).replace(/-/g, ' ')).join(' ');
++ const text = `${name} ${cats}`.trim();
++ if (!text) return null;
++ for (const [slug, re, plain] of RULES) {
++ if (!re.test(text)) continue;
++ if (plain && MADE_FROM.test(name)) continue;
++ if (foods.getFood(slug)) return slug;
++ }
++ return null;
++}
++
++function createBarcodes({ config, fetchImpl = globalThis.fetch, now = () => Date.now() }) {
++ const cache = new Map(); // code → { at, value }
++ const DAY = 86400_000;
++ let window = { start: 0, n: 0 };
++
++ async function lookup(raw) {
++ const code = normalize(raw);
++ if (!code) { const e = new Error('That is not a product barcode (8, 12, 13 or 14 digits with a valid check digit).'); e.code = 'food.barcode.invalid'; throw e; }
++ const hit = cache.get(code);
++ if (hit && now() - hit.at < DAY) return hit.value;
++ if (now() - window.start > 60_000) window = { start: now(), n: 0 };
++ if (++window.n > config.barcode.perMinute) { const e = new Error('Too many barcode lookups right now. Try again in a minute.'); e.code = 'food.barcode.busy'; throw e; }
++ let res;
++ try {
++ res = await fetchImpl(`${API}/${code}.json?fields=${FIELDS}`, {
++ headers: { 'User-Agent': config.barcode.userAgent, Accept: 'application/json' },
++ signal: AbortSignal.timeout(8000), redirect: 'error',
++ });
++ } catch (err) {
++ const e = new Error('Open Food Facts did not answer. Try again in a moment.'); e.code = 'food.barcode.unreachable'; throw e;
++ }
++ if (res.status !== 200 && res.status !== 404) { const e = new Error(`Open Food Facts answered ${res.status}. Try again in a moment.`); e.code = 'food.barcode.unreachable'; throw e; }
++ const body = await res.json().catch(() => null);
++ const p = body && body.status === 1 && body.product ? body.product : null;
++ const value = p
++ ? { code, found: true, name: String(p.product_name_en || p.product_name || p.generic_name || '').trim().slice(0, 160) || null, brand: String(p.brands || '').split(',')[0].trim().slice(0, 80) || null,
++ quantity: String(p.quantity || '').trim().slice(0, 40) || null, match: matchFood(p), url: `https://world.openfoodfacts.org/product/${code}` }
++ : { code, found: false, name: null, brand: null, quantity: null, match: null, url: `https://world.openfoodfacts.org/product/${code}` };
++ cache.set(code, { at: now(), value });
++ if (cache.size > 2000) cache.delete(cache.keys().next().value);
++ return value;
++ }
++
++ return { lookup };
++}
++
++module.exports = { normalize, matchFood, createBarcodes, RULES };
ddiff --git a/server/food/store.js b/server/food/store.js
iindex 593714b..aec6631 100644
---- a/server/food/store.js
++++ b/server/food/store.js
@@@ -105,6 +105,8 @@ const planToWire = (row) => ({
// ── food_pantry ──────────────────────────────────────────────
const getPantry = async (s, owner) => (await s.db.many('SELECT item, added_at FROM food_pantry WHERE owner = $1 ORDER BY item', [owner])).map((r) => r.item);
const pantryRows = (s, owner) => s.db.many('SELECT item, added_at FROM food_pantry WHERE owner = $1 ORDER BY item', [owner]);
++/** Add one item to a person's pantry (pantry by barcode); already there is fine. */
++const addPantryItem = (s, owner, item) => s.db.query('INSERT INTO food_pantry (owner, item, added_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', [owner, item, s.iso()]);
/** Replace a person's pantry with exactly this list, in one transaction (what PUT means). */
async function setPantry(s, owner, items) {
const addedAt = s.iso();
@@@ -144,6 +146,6 @@ module.exports = {
insertPlan, planRow, listPlans, deletePlan, planToWire,
planForViewer, setShareHash, planByShareHash, listMembers, listSharedPlans, addMember, removeMember, listTicks, tick, untick,
MEMBERS_MAX, SHARED_PLANS_MAX,
-- getPantry, pantryRows, setPantry,
++ getPantry, pantryRows, setPantry, addPantryItem,
insertRecipeSuggestion, recipeSuggestionRow, listRecipeSuggestions, countRecipeSuggestionsSince, recipeSuggestionToWire,
};
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5cc34b5..3f53ca9 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -494,7 +494,7 @@ ${f.tags.length ? html`<p class="tags">${f.tags.map((t) => badge(t))}</p>` : ''}
});
// ── Your cupboard ────────────────────────────────────────
-- async function pantryPage(req, res, { saved = false } = {}) {
++ async function pantryPage(req, res, { saved = false, added = null } = {}) {
if (!signedIn(req)) {
return page(req, res, {
title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET],
@@@ -511,10 +511,19 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
</fieldset>`);
const recipeLine = (x) => html`<li><b>${x.name}</b> — serves ${x.serves}, about ${x.minutes} minutes${x.missing && x.missing.length ? html` · missing ${x.missing.join(', ')}` : ''}</li>`;
page(req, res, {
-- title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET],
++ title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js'],
body: html`<h1>Your cupboard</h1>
<p class="lede">Tick what you have. Nothing leaves your account; the matching is done here, not by a model.</p>
${saved ? notice('Cupboard saved.', 'ok') : ''}
++${added && foods.getFood(added) ? notice(html`Added <b>${foods.getFood(added).name}</b> to your cupboard.`, 'ok') : ''}
++<form method="get" action="/pantry/barcode" class="pantry-barcode" role="search">
++<label for="bc">Add by barcode</label>
++<div class="pantry-barcode-row"><input id="bc" name="code" inputmode="numeric" autocomplete="off" pattern="[0-9 \-]{8,18}" maxlength="18" placeholder="The numbers under the bars" required>
++<button class="sc-btn" type="submit">Look it up</button>
++<button class="sc-btn" type="button" id="bc-scan" hidden>Scan with camera</button></div>
++<video id="bc-video" playsinline muted hidden></video>
++<p class="muted small">Looks the product up on <a href="https://world.openfoodfacts.org/">Open Food Facts</a> and asks you before adding it. Only the barcode is sent.</p>
++</form>
<p><a href="/recipes">Get recipe ideas</a></p>
<form method="post" action="/pantry" class="pantry-form">
${groupsBlock}
@@@ -528,7 +537,44 @@ ${match.rest.length ? html`<h3>Further off</h3><ul>${match.rest.slice(0, 6).map(
});
}
-- r.get('/pantry', (req, res) => pantryPage(req, res, { saved: !!req.query.saved }));
++ r.get('/pantry', (req, res) => pantryPage(req, res, { saved: !!req.query.saved, added: typeof req.query.added === 'string' ? req.query.added : null }));
++
++ // ── Pantry by barcode (server/food/barcode.js) ───────────
++ // Look a barcode up and ask before adding: the match is preselected, any other item can be picked instead.
++ r.get('/pantry/barcode', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/pantry')}`);
++ let found = null;
++ let problem = null;
++ try { found = await ctx.barcodes.lookup(req.query.code); } catch (err) { problem = err.message; }
++ const pick = found && found.match ? foods.getFood(found.match) : null;
++ const options = foods.FOODS.map((f) => html`<option value="${f.slug}"${pick && pick.slug === f.slug ? raw(' selected') : ''}>${f.name}</option>`);
++ page(req, res, {
++ title: 'Add by barcode', crumbs: [{ label: 'Your cupboard', href: '/pantry' }, { label: 'Add by barcode' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'],
++ body: html`<h1>Add by barcode</h1>
++${problem ? notice(problem, 'warn') : ''}
++${found && found.found ? html`<p class="lede"><b>${found.name || 'A product with no name listed'}</b>${found.brand ? html` · ${found.brand}` : ''}${found.quantity ? html` · ${found.quantity}` : ''}</p>
++<p class="muted small">Barcode ${found.code}, from <a href="${found.url}" rel="noopener nofollow">Open Food Facts</a> (Open Database License).</p>
++${pick ? html`<p>That is <b>${pick.name}</b> on Food's list.</p>` : html`<p>It does not match an item on Food's list by itself. Pick the closest one, or go back.</p>`}`
++ : found ? html`<p class="lede">Open Food Facts does not know barcode ${found.code} yet.</p>
++<p class="muted small">You can <a href="${found.url}" rel="noopener nofollow">add it there</a> for everyone, or pick the item from the list below.</p>` : ''}
++${found ? html`<form method="post" action="/pantry/items" class="pantry-confirm">
++<label for="item">Add to your cupboard as</label>
++<select id="item" name="item">${pick ? '' : html`<option value="" selected disabled>Choose an item</option>`}${options}</select>
++<button class="sc-btn sc-primary" type="submit">Add to my cupboard</button></form>` : ''}
++<p><a href="/pantry">Back to your cupboard</a></p>`,
++ });
++ });
++
++ r.post('/pantry/items', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fpantry');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Add to a cupboard from openvibe.food itself.');
++ const item = foods.getFood(String((req.body && req.body.item) || ''));
++ if (!item) return res.redirect(303, '/pantry');
++ await store.addPantryItem(s, ownerOf(req), item.slug);
++ return res.redirect(303, `/pantry?added=${encodeURIComponent(item.slug)}`);
++ });
r.post('/pantry', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
ddiff --git a/test/barcode.test.js b/test/barcode.test.js
nnew file mode 100644
iindex 0000000..0ee3056
---- /dev/null
++++ b/test/barcode.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * Pantry by barcode (server/food/barcode.js, GET /pantry/barcode, POST /pantry/items):
++ * - only real product barcodes (8, 12, 13 or 14 digits with a valid GS1 check digit) are looked up;
++ * - Open Food Facts gets the barcode alone, with Food's own User-Agent, and an answer is cached;
++ * - a product is matched to the item on Food's list it is, not to what is made from it (peanut butter is not butter,
++ * apple juice is not apples), and an unknown one matches nothing;
++ * - the page asks before adding, with the match preselected; adding needs a signed-in person on Food's own page.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { normalize, matchFood, createBarcodes } = require('../server/food/barcode');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ await check('barcodes: lengths and the check digit', () => {
++ assert.strictEqual(normalize('0 41250 00237 3'), null, 'a wrong check digit');
++ assert.strictEqual(normalize('4006381333931'), '4006381333931', 'EAN-13');
++ assert.strictEqual(normalize('036000291452'), '036000291452', 'UPC-A');
++ assert.strictEqual(normalize('96385074'), '96385074', 'EAN-8');
++ for (const bad of ['', '123', 'abcdefghijkl', '12345678901', '4006381333932', null]) assert.strictEqual(normalize(bad), null, String(bad));
++ });
++
++ await check('a product matches the item it is, never what is made from it', () => {
++ const m = (name, cats = []) => matchFood({ product_name: name, categories_tags: cats });
++ assert.strictEqual(m('Creamy Peanut Butter'), 'peanut-butter');
++ assert.strictEqual(m('Salted Butter'), 'butter');
++ assert.strictEqual(m('Large Grade A Eggs'), 'eggs-dozen');
++ assert.strictEqual(m('Organic Whole Milk'), 'whole-milk');
++ assert.strictEqual(m('Chunk Light Tuna in Water'), 'canned-tuna');
++ assert.strictEqual(m('Original Ramen Noodle Soup', ['en:instant-noodles']), 'ramen-12pk', 'ramen sold as noodle soup is ramen');
++ assert.strictEqual(m('Chicken Noodle Soup'), 'canned-soup');
++ assert.strictEqual(m('Black Beans', ['en:canned-foods']), 'canned-beans');
++ assert.strictEqual(m('Cut Green Beans'), 'canned-green-beans');
++ assert.strictEqual(m('Apple Juice'), null, 'juice is not apples');
++ assert.strictEqual(m('Strawberry Jam'), null);
++ assert.strictEqual(m('Gala Apples'), 'apples-3lb');
++ assert.strictEqual(m('Long Grain Enriched Rice'), 'white-rice');
++ assert.strictEqual(m('Sparkling Water'), null);
++ assert.strictEqual(matchFood({ product_name: '', categories_tags: ['en:peanut-butters'] }), 'peanut-butter', 'from the categories alone');
++ });
++
++ await check('the lookup sends the barcode alone, with Food\'s User-Agent, and caches the answer', async () => {
++ const calls = [];
++ const fetchImpl = async (url, o) => {
++ calls.push({ url, ua: o.headers['User-Agent'] });
++ return { status: 200, json: async () => ({ status: 1, product: { product_name: 'Creamy Peanut Butter', brands: 'Acme, Acme Foods', quantity: '16 oz', categories_tags: ['en:spreads'] } }) };
++ };
++ const b = createBarcodes({ config: { barcode: { userAgent: 'OpenVibeFood/0.1 (+https://openvibe.food; [email protected])', perMinute: 60 } }, fetchImpl });
++ const r = await b.lookup('4006381333931');
++ assert.deepStrictEqual(r, { code: '4006381333931', found: true, name: 'Creamy Peanut Butter', brand: 'Acme', quantity: '16 oz', match: 'peanut-butter', url: 'https://world.openfoodfacts.org/product/4006381333931' });
++ await b.lookup('4006381333931');
++ assert.strictEqual(calls.length, 1, 'cached');
++ assert.match(calls[0].url, /^https:\/\/world\.openfoodfacts\.org\/api\/v2\/product\/4006381333931\.json\?fields=/);
++ assert.match(calls[0].ua, /^OpenVibeFood\/.*Contact@OpenVibe\.Network/);
++ await assert.rejects(b.lookup('12345'), (e) => e.code === 'food.barcode.invalid');
++ });
++
++ const products = {
++ '4006381333931': { status: 1, product: { product_name: 'Large Grade A Eggs', brands: 'Farm', quantity: '12' } },
++ '036000291452': { status: 0 },
++ };
++ const barcodes = createBarcodes({
++ config: { barcode: { userAgent: 'test', perMinute: 60 } },
++ fetchImpl: async (url) => { const code = url.match(/product\/(\d+)\.json/)[1]; return { status: products[code] && products[code].status ? 200 : 404, json: async () => products[code] || { status: 0 } }; },
++ });
++ const t = await boot({ barcodes });
++ const kim = t.network.addUser('kim');
++ try {
++ await check('the page asks before adding, with the match preselected; adding needs a signed-in person on Food', async () => {
++ const anon = await t.get('/pantry/barcode?code=4006381333931');
++ assert.strictEqual(anon.status, 303);
++ const page = await t.get('/pantry/barcode?code=4006381333931', { as: kim });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ assert.ok(page.text.includes('Large Grade A Eggs'));
++ assert.ok(page.text.includes('<option value="eggs-dozen" selected>'), 'the match is preselected');
++ assert.ok(page.text.includes('Open Food Facts'));
++ const unknown = await t.get('/pantry/barcode?code=036000291452', { as: kim });
++ assert.ok(unknown.text.includes('does not know barcode 036000291452'));
++ const bad = await t.get('/pantry/barcode?code=123', { as: kim });
++ assert.ok(bad.text.includes('not a product barcode'));
++
++ const cross = await t.get('/pantry/items', { form: { item: 'eggs-dozen' }, method: 'POST', as: kim, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 403);
++ const add = await t.get('/pantry/items', { form: { item: 'eggs-dozen' }, method: 'POST', as: kim, headers: SAME });
++ assert.strictEqual(add.status, 303);
++ assert.strictEqual(add.headers.get('location'), '/pantry?added=eggs-dozen');
++ assert.deepStrictEqual((await t.get('/api/v1/pantry', { as: kim })).json().items, ['eggs-dozen']);
++ await t.get('/pantry/items', { form: { item: 'eggs-dozen' }, method: 'POST', as: kim, headers: SAME });
++ assert.deepStrictEqual((await t.get('/api/v1/pantry', { as: kim })).json().items, ['eggs-dozen'], 'twice is once');
++ const nope = await t.get('/pantry/items', { form: { item: 'unicorn-steak' }, method: 'POST', as: kim, headers: SAME });
++ assert.strictEqual(nope.headers.get('location'), '/pantry');
++ const pantry = await t.get('/pantry?added=eggs-dozen', { as: kim });
++ assert.ok(pantry.text.includes('Added <b>Large Eggs (1 dozen)</b> to your cupboard.'));
++ assert.ok(pantry.text.includes('action="/pantry/barcode"') && pantry.text.includes('js/barcode-scan.js'));
++ });
++ } finally { await t.close(); }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 45afe86..4d8e3c2 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -48,7 +48,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, ai: opts.ai });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, ai: opts.ai, barcodes: opts.barcodes });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;