Files
hub/recipes.js
T
nmemmertandClaude Sonnet 5.5 176b1b8169
build-container / build (push) Successful in 7m21s
Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar
- Store: Open Library search, books fetched from the Internet Archive (free editions only)
- Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment
- Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks
- Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:55:32 -04:00

149 lines
13 KiB
JavaScript

// Recipes and the shopping list, from Mealie (https://mealie.io). The hub talks to Mealie's own API with a token that stays on the server
// (MEALIE_URL, MEALIE_TOKEN), so the browser only ever talks to the hub. Recipes are searched and read in the hub, a link can be imported as a new
// recipe, and a recipe's ingredients can be added to Mealie's shopping list, which Mealie merges (two onions and one onion become three). That list is
// also mirrored into a "Shopping" list in Tasks, so it is checked off from either place.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const BASE = () => (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '');
const TOKEN = () => process.env.MEALIE_TOKEN || '';
const enabled = () => !!TOKEN();
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 });
async function mealie(p, { method = 'GET', body, accept = 'application/json', timeout = 20000, raw } = {}) {
if (!TOKEN()) throw fail(503, 'Recipes are not set up on this server (MEALIE_TOKEN)');
let r;
try { r = await fetch(BASE() + p, { method, headers: { authorization: 'Bearer ' + TOKEN(), accept, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined, redirect: 'manual', signal: AbortSignal.timeout(timeout) }); }
catch (e) { throw fail(502, e.name === 'TimeoutError' ? 'Mealie did not answer in time' : 'Could not reach Mealie (' + ((e.cause && (e.cause.code || e.cause.message)) || e.message) + ')'); }
if (raw) return r;
if (r.status === 401 || r.status === 403) throw fail(502, 'Mealie refused the token (make a new one in Mealie: your profile, API tokens)');
if (!r.ok) { let m = ''; try { const j = await r.json(); m = typeof j.detail === 'string' ? j.detail : j.message || ''; } catch {} const e = fail(r.status === 404 ? 404 : 502, 'Mealie answered ' + r.status + (m ? ': ' + String(m).slice(0, 120) : '')); e.status = r.status; throw e; }
const t = await r.text(); try { return t ? JSON.parse(t) : {}; } catch { return t; }
}
const slugOk = s => /^[\w-]{1,200}$/.test(s || '');
const idOk = s => /^[0-9a-f-]{8,64}$/i.test(s || '');
// ---- recipes
const minutes = v => { // Mealie gives times as text ("1 hour 30 minutes", "PT45M" or "45 minutes")
const s = String(v || ''); if (!s) return 0; let m;
if ((m = /^PT(?:(\d+)H)?(?:(\d+)M)?/i.exec(s))) return (+m[1] || 0) * 60 + (+m[2] || 0);
return (/(\d+)\s*(?:h|hour)/i.exec(s) ? +RegExp.$1 * 60 : 0) + (/(\d+)\s*(?:m|min)/i.exec(s) ? +RegExp.$1 : 0) || (/^\d+$/.test(s) ? +s : 0);
};
const card = r => ({ id: r.id, slug: r.slug, name: r.name || r.slug, description: String(r.description || '').slice(0, 200), minutes: minutes(r.totalTime), servings: r.recipeServings || r.recipeYield || '', rating: r.rating || 0, image: !!r.image });
const ingredientText = i => String(i.display || i.originalText || [i.quantity ? +(+i.quantity).toFixed(2) : '', i.unit && (i.unit.name || i.unit), i.food && (i.food.name || i.food), i.note].filter(Boolean).join(' ') || '').trim();
function full(r) {
return { ...card(r), orgUrl: r.orgURL || '', prep: minutes(r.prepTime), cook: minutes(r.performTime || r.cookTime), total: minutes(r.totalTime), notes: (r.notes || []).map(n => ({ title: n.title || '', text: n.text || '' })),
ingredients: (r.recipeIngredient || []).map(i => ({ text: ingredientText(i), title: i.title || '' })).filter(i => i.text || i.title),
steps: (r.recipeInstructions || []).map(s => ({ title: s.title || '', text: s.text || '' })).filter(s => s.text || s.title),
categories: (r.recipeCategory || []).map(c => c.name).filter(Boolean), tags: (r.tags || []).map(c => c.name).filter(Boolean) };
}
// ---- the shopping list (Mealie moved it from /groups to /households; the one that answers is remembered)
let shopBase = '';
async function shop(p, opt) {
if (shopBase) return mealie(shopBase + p, opt);
let last; for (const b of ['/api/households/shopping', '/api/groups/shopping']) { try { const r = await mealie(b + p, opt); shopBase = b; return r; } catch (e) { last = e; if (e.status !== 404) throw e; } }
throw last;
}
const itemOut = i => ({ id: i.id, text: String(i.display || i.note || (i.food && i.food.name) || '').trim(), checked: !!i.checked, label: (i.label && i.label.name) || '', position: i.position || 0 });
async function mainList() { // the first shopping list (the one called "Shopping List" if there is more than one)
const l = await shop('/lists?perPage=50'), items = Array.isArray(l) ? l : l.items || [];
let pick = items.find(x => /shopping/i.test(x.name || '')) || items[0];
if (!pick) pick = await shop('/lists', { method: 'POST', body: { name: 'Shopping List' } });
return pick;
}
async function readList() { const l = await mainList(), d = await shop('/lists/' + l.id); return { id: l.id, name: d.name || l.name, items: (d.listItems || []).map(itemOut).sort((a, b) => a.checked - b.checked || a.position - b.position) }; }
// ---- mirror into Tasks: a "Shopping" list in the person's Tasks, one task per Mealie item
const tasksFile = () => path.join(users.dir(), 'tasks.json');
const loadTasks = () => { try { const d = JSON.parse(fs.readFileSync(tasksFile(), 'utf8')); if (d && Array.isArray(d.lists) && Array.isArray(d.tasks)) return d; } catch {} return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; };
/** Make Tasks and Mealie agree, in both directions: ticking, adding and deleting an item in either place shows up in the other. Each task remembers what Mealie last said
* (`mealieChecked`), so a change on one side is told apart from a change on the other. */
async function sync() {
const list = await readList(), d = loadTasks(), items = new Map(((await shop('/lists/' + list.id)).listItems || []).map(i => [i.id, i]));
let tl = d.lists.find(l => l.name === 'Shopping' && l.mealie);
if (!tl) { tl = { id: crypto.randomBytes(4).toString('hex'), name: 'Shopping', mealie: true, known: [] }; d.lists.push(tl); }
const known = new Set(tl.known || []), mine = d.tasks.filter(t => t.list === tl.id), byItem = new Map(mine.filter(t => t.mealieId).map(t => [t.mealieId, t]));
const out = { added: 0, toTasks: 0, toMealie: 0, sent: 0, removed: 0 }, gone = [];
for (const it of list.items) {
const t = byItem.get(it.id), raw = items.get(it.id);
if (!t) {
if (known.has(it.id)) { gone.push(it.id); continue; } // it was on the Tasks list and has been deleted there
d.tasks.push({ id: crypto.randomBytes(6).toString('hex'), list: tl.id, title: it.text, notes: '', due: '', done: it.checked, completedAt: it.checked ? Date.now() : null, mealieId: it.id, mealieChecked: it.checked, updated: Date.now() }); out.added++; continue;
}
const last = t.mealieChecked === undefined ? it.checked : t.mealieChecked;
if (!!t.done !== last) { await shop('/items/' + it.id, { method: 'PUT', body: { ...raw, checked: !!t.done } }); t.mealieChecked = !!t.done; out.toMealie++; } // (changed in Tasks)
else if (it.checked !== !!t.done) { t.done = it.checked; t.completedAt = it.checked ? Date.now() : null; t.mealieChecked = it.checked; t.updated = Date.now(); out.toTasks++; } // (changed in Mealie)
else t.mealieChecked = it.checked;
}
if (gone.length) { await shop('/items?' + gone.map(i => 'ids=' + i).join('&'), { method: 'DELETE' }); out.removed += gone.length; }
const ids = new Set(list.items.map(i => i.id));
for (const t of mine) {
if (t.mealieId && !ids.has(t.mealieId)) { d.tasks = d.tasks.filter(x => x !== t); out.removed++; continue; } // gone from Mealie
if (!t.mealieId && !t.done && t.title) { // typed straight into the Shopping list in Tasks
const n = await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: t.title, quantity: 1, checked: false } }), made = n && (n.createdItems ? n.createdItems[0] : n);
if (made && made.id) { t.mealieId = made.id; t.mealieChecked = false; ids.add(made.id); } out.sent++;
}
}
tl.known = [...ids].filter(id => !gone.includes(id));
atomicWrite(tasksFile(), JSON.stringify(d, null, 2), { mode: 0o600 });
return { ...out, list: tl.id };
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
try {
if (p === '/status') return json(res, 200, { enabled: enabled(), host: new URL(BASE()).host });
let m;
if (p === '/' && req.method === 'GET') {
const page = Math.max(1, Math.min(200, +q.get('page') || 1)), term = (q.get('q') || '').trim().slice(0, 100);
const j = await mealie(`/api/recipes?page=${page}&perPage=24&orderBy=name&orderDirection=asc${term ? '&search=' + encodeURIComponent(term) : ''}`);
return json(res, 200, { items: (j.items || []).map(card), more: page < (j.total_pages || j.totalPages || 1) });
}
if ((m = /^\/r\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, full(await mealie('/api/recipes/' + m[1])));
if ((m = /^\/image\/([0-9a-f-]+)$/i.exec(p)) && req.method === 'GET') { // the picture (small by default)
if (!idOk(m[1])) throw fail(400, 'Bad recipe');
for (const f of q.get('big') ? ['original.webp'] : ['min-original.webp', 'original.webp']) {
const r = await mealie(`/api/media/recipes/${m[1]}/images/${f}`, { accept: 'image/*', raw: true }).catch(() => null);
if (r && r.ok) { const type = (r.headers.get('content-type') || '').split(';')[0]; if (!/^image\//.test(type)) continue; res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=86400' }); return res.end(Buffer.from(await r.arrayBuffer())); }
}
return json(res, 404, { error: 'No picture' });
}
if (p === '/import' && req.method === 'POST') { // make a recipe from a web page's address
const b = JSON.parse(await readBody(req, 1e4) || '{}'), url = String(b.url || '').trim();
if (!/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'Paste the address of a recipe page (starting with http)');
let slug; try { slug = await mealie('/api/recipes/create/url', { method: 'POST', body: { url, includeTags: false }, timeout: 90000 }); } catch (e) { if (e.status !== 404 && e.status !== 405) throw e; slug = await mealie('/api/recipes/create-url', { method: 'POST', body: { url }, timeout: 90000 }); }
slug = typeof slug === 'string' ? slug : slug && slug.slug; if (!slugOk(slug)) throw fail(502, 'Mealie could not read a recipe from that page');
return json(res, 200, full(await mealie('/api/recipes/' + slug)));
}
if (p === '/shopping' && req.method === 'GET') return json(res, 200, await readList());
if (p === '/shopping/add-recipe' && req.method === 'POST') { // the recipe's ingredients go on the list; Mealie adds up what is already there
const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await mealie('/api/recipes/' + (slugOk(b.slug) ? b.slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +b.scale || 1));
try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name });
}
if (p === '/shopping/add' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy');
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 1, checked: false } }); await sync(); return json(res, 200, { ok: true });
}
if (p === '/shopping/check' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!idOk(b.id)) throw fail(400, 'Bad item');
const list = await mainList(), raw = ((await shop('/lists/' + list.id)).listItems || []).find(i => i.id === b.id); if (!raw) throw fail(404, 'That item is not on the list');
await shop('/items/' + b.id, { method: 'PUT', body: { ...raw, checked: !!b.checked } }); await sync(); return json(res, 200, { ok: true });
}
if (p === '/shopping/clear' && req.method === 'POST') { // take the ticked items off
const list = await readList(), ids = list.items.filter(i => i.checked).map(i => i.id);
if (ids.length) await shop('/items?' + ids.map(i => 'ids=' + i).join('&'), { method: 'DELETE' });
await sync(); return json(res, 200, { ok: true, removed: ids.length });
}
if (p === '/shopping/sync' && req.method === 'POST') return json(res, 200, await sync());
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, enabled, minutes, full, ingredientText };