// 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 }); } } /** Recipes in Mealie that match the words (none when Mealie is not set up or does not answer). */ async function search(q) { if (!enabled()) return []; try { const j = await mealie(`/api/recipes?page=1&perPage=6&orderBy=name&orderDirection=asc&search=${encodeURIComponent(q)}`, { timeout: 6000 }); return (j.items || []).map(card); } catch { return []; } } module.exports = { handle, enabled, minutes, full, ingredientText, search };