diff --git a/public/bible-view.js b/public/bible-view.js index 7134f41..7da82cc 100644 --- a/public/bible-view.js +++ b/public/bible-view.js @@ -155,8 +155,9 @@ function bbSearch() { } async function bbMarks() { const order = id => bb.books.findIndex(b => b.id === id), list = bbSt.marks.filter(m => !m.del).sort((a, b) => order(a.b) - order(b.b) || a.c - b.c || a.v - b.v); - $('#view').innerHTML = `
Nothing yet. Tap verses while reading to highlight them or add a note.
'}Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.
'; + ` : 'Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.
'; box.querySelectorAll('.rdnote').forEach(r => { const n = this.notes.find(x => x.id === r.dataset.id); r.querySelector('.rdnj').onclick = () => { closeSheet(); this.A.goto(n.loc, null, n); }; const e = r.querySelector('[data-e]'); if (e) e.onclick = () => { closeSheet(); this.noteDialog(n); }; r.querySelector('[data-d]').onclick = async () => { await this.deleteNote(n); draw(); }; }); + const exf = box.querySelector('#rdexf'); if (exf) exf.onclick = () => downloadText((this.book.title || 'book').replace(/[^\w .-]+/g, '').trim().slice(0, 60) + ' - notes.md', `# ${this.book.title}${this.book.author ? '\n\n' + this.book.author : ''}\n\n` + l.map(n => n.type === 'mark' ? `- Bookmark, ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}` : `> ${String(n.text || '').replace(/\n+/g, ' ')}\n${n.note ? '\n' + n.note + '\n' : ''}\n*${n.type === 'note' ? 'Note' : 'Highlight'}, ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}*`).join('\n\n') + '\n'); const ex = box.querySelector('#rdexp'); if (ex) ex.onclick = () => { const txt = `${this.book.title}${this.book.author ? ' - ' + this.book.author : ''}\n\n` + l.map(n => (n.type === 'mark' ? '[Bookmark] ' + (n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%') : `"${n.text}"${n.note ? '\n Note: ' + n.note : ''}`)).join('\n\n'); navigator.clipboard?.writeText(txt).then(() => toast('Copied'), () => toast('Could not copy')); diff --git a/public/recipes-view.js b/public/recipes-view.js index 08a57fc..a3ca1c3 100644 --- a/public/recipes-view.js +++ b/public/recipes-view.js @@ -119,9 +119,11 @@ async function rcPlan(start) { return `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.
`; }).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; }; } /** Choose the day and meal for a recipe (from a recipe page), or the recipe for a day (from the plan). */ diff --git a/public/sheets.js b/public/sheets.js index 17ca0af..fde0ea0 100644 --- a/public/sheets.js +++ b/public/sheets.js @@ -67,3 +67,9 @@ function confirmSheet(title, sub, okLabel, onOk) { el.querySelector('#sx').onclick = closeSheet; el.querySelector('#so').onclick = () => { closeSheet(); onOk(); }; } + +/** Save text as a file on this device (a Markdown export of notes and highlights, say). */ +function downloadText(name, text, type = 'text/markdown') { + const a = document.createElement('a'), url = URL.createObjectURL(new Blob([text], { type: type + ';charset=utf-8' })); + a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000); +} diff --git a/public/sw.js b/public/sw.js index 377f5c1..a705722 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v103'; +const CACHE = 'hub-v104'; 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']; diff --git a/push.js b/push.js index c1b1c84..79c1661 100644 --- a/push.js +++ b/push.js @@ -79,6 +79,13 @@ async function tick(now = new Date(), force = false) { } } catch (e) { console.warn('[push] tasks check failed:', e.message); } } + if (st.tasks && users.can(u, 'contacts') && st.datesDay !== lt.day && lt.hm >= st.tasksAt) { // birthdays and anniversaries from Contacts, with the gift ideas, with the morning summary + st.datesDay = lt.day; dirty = true; + try { + const todays = require('./contacts').birthdayEvents({ fd: lt.day, td: lt.day }, u.id); + if (todays.length) await send(dir, st, u.id, { title: todays.length === 1 ? todays[0].title : todays.length + ' birthdays and anniversaries today', body: todays.length === 1 ? 'Open Contacts for gift ideas' : todays.map(e => e.title).join(', '), url: '#/contacts/c/' + todays[0].contactId, tag: 'dates:' + lt.day }); + } catch (e) { console.warn('[push] dates check failed:', e.message); } + } if (u.role === 'admin' && checkEvents) { // the Microsoft client secret (for Mail) is about to expire const ss = mail.secretStatus(); if (ss && ss.days <= 30) { diff --git a/recipes.js b/recipes.js index 36aed8e..4b429b9 100644 --- a/recipes.js +++ b/recipes.js @@ -104,6 +104,11 @@ 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). The plan flags planned ingredients that match, so they can be left off the shop. +const pantryFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pantry.json'); +const readPantry = () => { try { const a = JSON.parse(fs.readFileSync(pantryFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } }; +const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, ''); +const inPantry = (text, items) => { 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)); }); }; /** 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)); @@ -152,6 +157,13 @@ 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: readPantry() }); + if (p === '/pantry' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e4) || '{}'), items = [...new Set((Array.isArray(b.items) ? b.items : String(b.items || '').split(/[\n,]/)).map(x => String(x).replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60)).filter(Boolean))].slice(0, 300); atomicWrite(pantryFile(), JSON.stringify(items), { mode: 0o600 }); return json(res, 200, { items }); } + 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(), 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; } } + 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) const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.from || '') || !DAY.test(b.to || '')) throw fail(400, 'Bad dates'); const count = {}; for (const es of Object.values(planBetween(b.from, b.to))) for (const e of es) count[e.slug] = (count[e.slug] || 0) + 1; diff --git a/tools/push-test.js b/tools/push-test.js index 612668f..69e9f98 100644 --- a/tools/push-test.js +++ b/tools/push-test.js @@ -58,6 +58,14 @@ const users = require('../users'), push = require('../push'); ok('and only once for that stage', sent.length === n1, sent.length); ok('an ordinary person is not told about it', !sent.some(s => /Mail sign-in/.test(s.title) && !s.sub.endpoint.endsWith('/admin')), sent); + // birthdays and anniversaries from Contacts come with the morning summary, once a day + { const k = users.create({ name: 'Kim', role: 'user', tabs: ['contacts'] }), kd = users.dirOf(k.id); sent.length = 0; + fs.writeFileSync(path.join(kd, 'contacts.json'), JSON.stringify([{ id: 'c1', name: 'Jo', birthday: '--10-22', anniversary: '2010-10-22', emails: [], phones: [] }, { id: 'c2', name: 'Al', birthday: '--03-01', emails: [], phones: [] }])); + fs.writeFileSync(path.join(kd, 'push.json'), JSON.stringify({ tz: TZ, tasksAt: '08:00', tasks: true, events: false, subs: [{ sub: { endpoint: 'https://push.example/kim', keys: { p256dh: 'x', auth: 'y' } } }] })); + await push.tick(now); const mine = sent.filter(x => x.sub.endpoint.endsWith('/kim')); + ok('a birthday and an anniversary today make one notification', mine.length === 1 && /2 birthdays and anniversaries/.test(mine[0].title) && /Jo's birthday/.test(mine[0].body) && /anniversary \(16\)/.test(mine[0].body), mine); + await push.tick(now); ok('and not again the same day', sent.filter(x => x.sub.endpoint.endsWith('/kim')).length === 1); } + // a subscription the push service says is gone is dropped dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] })); await push.tick(new Date()); diff --git a/tools/recipes-test.js b/tools/recipes-test.js index e361690..554c54f 100644 --- a/tools/recipes-test.js +++ b/tools/recipes-test.js @@ -62,6 +62,11 @@ 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 flags planned ingredients you already 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' }); + j = await (await post('/pantry', { items: 'Flour\n \nflour, Salt' })).json(); assert.deepEqual(j.items, ['Flour', 'flour', 'Salt'].filter((x, i, a) => a.indexOf(x) === i), 'trimmed and de-duplicated by exact text'); + 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'); + await post('/pantry', { items: [] }); 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);