diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 9d6712e..e22b797 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -30,6 +30,8 @@ jobs: - run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy - run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy - run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads + - run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export + - run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates - run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server - run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls diff --git a/abapi.js b/abapi.js index 4059ace..d13b053 100644 --- a/abapi.js +++ b/abapi.js @@ -46,7 +46,7 @@ async function direct(path, init = {}) { } // Listening positions kept by the hub (direct mode) -const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge; +const prog = require('./progress')('audiobook-progress.json', 'audiobook'), progLoad = prog.load, progMerge = prog.merge; async function upstream(me, path, init = {}, retry = true) { if (OA) return direct(path, init); diff --git a/backup.js b/backup.js index 67a5053..cbd5e3b 100644 --- a/backup.js +++ b/backup.js @@ -1,13 +1,61 @@ // Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz. // Treat the file like a password: it contains the session secret and encrypted sign-in tokens. -const { spawn } = require('child_process'); -const path = require('path'); +// Restore (admin): upload that .tar.gz; it is checked, unpacked beside the data, and each top-level item is swapped in with the old one kept in .pre-restore-; then the hub restarts. +// Export (anyone): your own data as a .tar.gz, without sign-in tokens. +const { spawn, execFile } = require('child_process'); +const fs = require('fs'), path = require('path'), { pipeline } = require('stream/promises'); +const users = require('./users'); const audit = require('./audit'); const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')); +const MAX_RESTORE = 4 * 1024 ** 3; +const send = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const run = (cmd, args) => new Promise((ok, no) => execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024 }, (e, out, err) => e ? no(new Error((String(err) || e.message).trim().slice(0, 300))) : ok(String(out)))); +const rm = p => fs.rmSync(p, { recursive: true, force: true }); +/** Refuse an archive that could write outside the data folder or plant links: every name must stay inside, and only plain files and folders are allowed. */ +async function checkArchive(file) { + const names = (await run('tar', ['-tzf', file])).split('\n').filter(Boolean), long = (await run('tar', ['-tvzf', file])).split('\n').filter(Boolean); + if (!names.length) throw new Error('That backup is empty'); + for (const n of names) if (path.isAbsolute(n) || n.split('/').includes('..')) throw new Error('That is not a backup this hub made (unsafe file names)'); + for (const l of long) if (!/^[-d]/.test(l)) throw new Error('That is not a backup this hub made (it contains links or special files)'); + return names; +} +async function restore(req, res, who) { + if (req.method !== 'POST') return send(res, 405, { error: 'Use POST' }); + const stage = path.join(DATA_DIR, '.restore'), up = stage + '.tar.gz', out = path.join(stage, 'files'); rm(stage); rm(up); + try { + if ((+req.headers['content-length'] || 0) > MAX_RESTORE) return send(res, 413, { error: 'That file is too large' }); + let n = 0; const cap = new (require('stream').Transform)({ transform(c, _e, cb) { n += c.length; cb(n > MAX_RESTORE ? Object.assign(new Error('That file is too large'), { status: 413 }) : null, c); } }); + await pipeline(req, cap, fs.createWriteStream(up, { mode: 0o600 })); + await checkArchive(up); fs.mkdirSync(out, { recursive: true, mode: 0o700 }); await run('tar', ['-xzf', up, '--no-same-owner', '--no-same-permissions', '-C', out]); + const items = fs.readdirSync(out); if (!items.includes('secret.json') && !items.includes('users')) throw new Error('That does not look like an NE Hub backup (no accounts or secret in it)'); + const keep = path.join(DATA_DIR, '.pre-restore-' + new Date().toISOString().replace(/[:.]/g, '-')); fs.mkdirSync(keep, { recursive: true, mode: 0o700 }); + for (const it of items) { const dst = path.join(DATA_DIR, it); if (fs.existsSync(dst)) fs.renameSync(dst, path.join(keep, it)); fs.renameSync(path.join(out, it), dst); } + audit.log('backup restored', { who: who || '', items: items.length, kept: path.basename(keep) }, req); + send(res, 200, { ok: true, items: items.length, kept: path.basename(keep), restarting: true }); + if (!process.env.NO_RESTART_AFTER_RESTORE) setTimeout(() => process.exit(0), 800); // the container restarts (restart: unless-stopped) and loads the restored data + } catch (e) { if (!res.headersSent) send(res, e.status || 400, { error: e.message }); } + finally { rm(stage); rm(up); } +} +/** Your own data as a .tar.gz: tasks, contacts, settings, Bible notes, your notes and so on. Sign-in tokens for mail, iCloud and Plex are left out. */ +async function exportMine(req, res) { + const me = users.current(); if (!me) return send(res, 401, { error: 'Sign in' }); + const tmp = fs.mkdtempSync(path.join(DATA_DIR, '.export-')), SECRET = new Set(['mail-accounts.json', 'mail.json', 'icloud.json', 'plex.json', 'push.json']); + try { + fs.cpSync(users.dirOf(me.id), path.join(tmp, 'me'), { recursive: true, filter: s => !SECRET.has(path.basename(s)) }); + try { const notes = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'notes.json'), 'utf8')), l = Array.isArray(notes) ? notes : notes.notes || []; fs.writeFileSync(path.join(tmp, 'me', 'my-notes.json'), JSON.stringify(l.filter(n => n.owner === me.id), null, 1)); } catch { /* no notes yet */ } + const name = 'nehub-' + String(me.name || 'me').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '-' + new Date().toISOString().slice(0, 10) + '.tar.gz'; + const tar = spawn('tar', ['-czf', '-', '--exclude=*.tmp', '-C', tmp, 'me'], { stdio: ['ignore', 'pipe', 'pipe'] }); + res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' }); + tar.stdout.pipe(res); res.on('close', () => { tar.kill(); rm(tmp); }); tar.on('error', () => res.destroy()); + audit.log('personal export', { who: me.name }, req); + } catch (e) { rm(tmp); if (!res.headersSent) send(res, 500, { error: 'Could not make the export: ' + e.message }); } +} function handle(req, res, who) { + if (new URL(req.url, 'http://x').pathname === '/api/backup/restore') return restore(req, res, who); + if (new URL(req.url, 'http://x').pathname === '/api/export') return exportMine(req, res); if (req.method !== 'GET') { res.writeHead(405); return res.end(); } const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz'; - const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] }); + const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=./.restore*', '--exclude=./.export-*', '--exclude=./.pre-restore-*', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] }); tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); }); tar.stderr.on('data', d => console.warn('[backup]', String(d).trim())); res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' }); @@ -15,4 +63,4 @@ function handle(req, res, who) { res.on('close', () => tar.kill()); audit.log('backup downloaded', { who: who || '' }, req); } -module.exports = { handle }; +module.exports = { handle, checkArchive }; diff --git a/bible.js b/bible.js index af898f5..a72d5c0 100644 --- a/bible.js +++ b/bible.js @@ -4,7 +4,7 @@ const fs = require('fs'), path = require('path'); const users = require('./users'); const file = () => path.join(users.dir(), 'bible.json'); -const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } }; +const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [], plan: o.plan || null }; } catch { return { pos: null, marks: [], plan: null }; } }; const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); }; const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n); @@ -23,9 +23,33 @@ function mergeMarks(have, sent) { } const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } }; + +// ---- Devotion: a one-year reading plan with a check-in per day (kept per person), and a family prayer list everyone can add to and mark answered. +const crypto = require('crypto'); +const PRAYERS = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'prayers.json'); +const loadPrayers = () => { try { const a = JSON.parse(fs.readFileSync(PRAYERS(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } }; +const savePrayers = a => { const f = PRAYERS(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(a), { mode: 0o600 }); fs.renameSync(tmp, f); }; +const prayerView = (x, me) => ({ id: x.id, text: x.text, by: x.by, byName: (users.get(x.by) || {}).name || 'Someone', at: x.at, answered: !!x.answered, answeredAt: x.answeredAt || 0, mine: x.by === me.id }); +const DAY = /^\d{4}-\d{2}-\d{2}$/; +async function devotion(req, res, p) { + const me = users.current(); if (!me) return json(res, 401, { error: 'Sign in' }); + if (p === '/prayers' && req.method === 'GET') return json(res, 200, loadPrayers().map(x => prayerView(x, me))); + if (req.method !== 'POST') return json(res, 404, { error: 'Not found' }); + const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); + if (p === '/plan') { const s = load(); if (b.stop) s.plan = null; else if (DAY.test(b.start || '')) s.plan = { start: b.start, done: [] }; else return json(res, 400, { error: 'Pick a start date' }); save(s); return json(res, 200, { plan: s.plan }); } + if (p === '/plan/check') { const s = load(), d = Math.round(+b.day); if (!s.plan || !(d >= 0 && d < 366)) return json(res, 400, { error: 'No plan' }); const set = new Set(s.plan.done); if (b.on) set.add(d); else set.delete(d); s.plan.done = [...set].sort((x, y) => x - y); save(s); return json(res, 200, { plan: s.plan }); } + const l = loadPrayers(); + if (p === '/prayers') { const text = clip(b.text, 500); if (!text) return json(res, 400, { error: 'Write the prayer request' }); if (l.length >= 500) return json(res, 400, { error: 'The prayer list is full' }); l.unshift({ id: crypto.randomBytes(6).toString('hex'), text, by: me.id, at: Date.now() }); savePrayers(l); return json(res, 200, l.map(x => prayerView(x, me))); } + const x = l.find(y => y.id === b.id); if (!x) return json(res, 404, { error: 'That request is gone' }); + if (p === '/prayers/answer') { x.answered = !!b.on; x.answeredAt = b.on ? Date.now() : 0; savePrayers(l); return json(res, 200, l.map(y => prayerView(y, me))); } + if (p === '/prayers/delete') { if (x.by !== me.id && me.role !== 'admin') return json(res, 403, { error: 'Only the person who wrote it can remove it' }); savePrayers(l.filter(y => y !== x)); return json(res, 200, l.filter(y => y !== x).map(y => prayerView(y, me))); } + return json(res, 404, { error: 'Not found' }); +} + async function handle(req, res) { const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/'; - if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); } + if (p === '/plan' || p.startsWith('/plan/') || p.startsWith('/prayers')) return devotion(req, res, p); + if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del), plan: s.plan }); } if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load(); if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() }; diff --git a/contacts.js b/contacts.js index 8f9d255..f4e89f0 100644 --- a/contacts.js +++ b/contacts.js @@ -28,7 +28,7 @@ function clean(b, old = {}) { const c = { id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120), emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300), - birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(), + birthday: cleanBirthday(b.birthday), anniversary: cleanBirthday(b.anniversary), gifts: str(b.gifts, 2000), notes: str(b.notes, 2000), updated: Date.now(), }; c.emails.forEach(e => { e.value = e.value.toLowerCase(); }); if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org; @@ -57,6 +57,7 @@ function parseVcf(text) { else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') }); else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', '); else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, ''); + else if (name === 'ANNIVERSARY' || name === 'X-ANNIVERSARY') cur.anniversary = val.replace(/T.*$/, ''); else if (name === 'NOTE') cur.notes = unesc(val); } return out.map(c => ({ ...c, name: c.name || c.n || '' })); @@ -66,19 +67,22 @@ function toVcf(l) { ...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`), ...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`), c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')), + c.anniversary && 'X-ANNIVERSARY:' + c.anniversary.replace(/-/g, '').replace(/^/, c.anniversary.startsWith('--') ? '--' : ''), c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : ''); } -/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */ +/** Birthdays and anniversaries as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Each repeats every year. */ function birthdayEvents(win, uid) { const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4); for (const c of load(uid)) { - if (!c.birthday) continue; - const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4); - for (let y = y0; y <= y1; y++) { - const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue; - const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10); - out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id }); + for (const [field, label, color] of [['birthday', "'s birthday", '#d9578a'], ['anniversary', "'s anniversary", '#c9a227']]) { + const v = c[field]; if (!v) continue; + const [mo, d] = v.startsWith('--') ? [v.slice(2, 4), v.slice(5, 7)] : [v.slice(5, 7), v.slice(8, 10)], born = v.startsWith('--') ? 0 : +v.slice(0, 4); + for (let y = y0; y <= y1; y++) { + const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue; + const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10); + out.push({ title: `${c.name}${label}${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: field + ':' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color, editable: false, contactId: c.id }); + } } } return out; diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index 9541dc3..624d66f 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -46,34 +46,34 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, - [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos) - [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.** - [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family -- [ ] **Meal planning (M):** a weekly plan from Recipes that builds the shopping list (and can flag what is already in the pantry) -- [ ] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list +- [~] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list. Not built: flagging what is already in the pantry.** +- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).** ## 3. Kids and safety - [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.** -- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves +- [x] **Approvals (S):** **Done: requests.js ("Can I have...").** a child asks to add a book, app or extension and a parent approves - [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js** -- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents +- [x] **Simple mode (S):** **Done: simple-mode.js.** a big-button layout with few tabs for young kids or grandparents ## 4. Everyday convenience - [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.** -- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home +- [x] **Continue row (S):** **Done: Home, today.js.** the last audiobook, episode, book, movie and song on Home - [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.** - [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views - [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files -- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas +- [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.** ## 5. Media - [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote -- [ ] **Podcast and audiobook stats (S):** listening history like Music has -- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights +- [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).** +- [~] **Reading goals (S):** **Done: a yearly goal for audiobooks finished, with a listening streak. Not built: a family-wide challenge, e-book goals, exporting notes and highlights.** - [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.** -- [ ] **Casting (M):** pick AirPlay or Cast targets for audio +- [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio ## 6. Reliability (the admin's side) - [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface - [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage -- [ ] **Backup and restore screen (S):** one-click restore, per-person export +- [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.** - [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels) - [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries) - [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion @@ -81,7 +81,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, ## 7. Platform (later) - [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates - [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping) -- [ ] **Themes (S):** accent colours and an AMOLED dark mode per person +- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.** ## Suggested order 1. Family Chat phase 1, Family Home, Shared lists and notes, Chores diff --git a/librivox.js b/librivox.js index 2c99200..c8d955a 100644 --- a/librivox.js +++ b/librivox.js @@ -2,7 +2,7 @@ // address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one. const fs = require('fs'), path = require('path'); const users = require('./users'), genres = require('./genres'); -const prog = require('./progress')('librivox-progress.json'); // keyed lv:: seconds into the whole book +const prog = require('./progress')('librivox-progress.json', 'audiobook'); // keyed lv:: seconds into the whole book const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, ''); const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname]; diff --git a/podcast.js b/podcast.js index a1e2996..7e51594 100644 --- a/podcast.js +++ b/podcast.js @@ -2,7 +2,7 @@ // Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub. const fs = require('fs'), path = require('path'); const users = require('./users'); -const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode +const prog = require('./progress')('podcast-progress.json', 'podcast'); // where each person got to in each episode const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, ''); const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : ''; diff --git a/progress.js b/progress.js index d668436..0b6096d 100644 --- a/progress.js +++ b/progress.js @@ -3,13 +3,32 @@ const fs = require('fs'), path = require('path'); const users = require('./users'); -module.exports = name => { +// With a `kind` ('podcast' or 'audiobook'), each report also adds the time actually listened to that person's listening log (listening.json): seconds per day per kind, and +// the items finished. A jump forward bigger than the time that passed (a seek) does not count. +const day = t => { const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; +const logFile = () => path.join(users.dir(), 'listening.json'); +const loadLog = () => { try { const o = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return { days: o.days || {}, finished: o.finished || {} }; } catch { return { days: {}, finished: {} }; } }; +const stats = () => { + const o = loadLog(), now = new Date(), today = day(now), tot = k => Object.values(o.days).reduce((n, d) => n + (d[k] || 0), 0), sum = (a, b) => Object.entries(o.days).filter(([k]) => k >= a && k <= b).reduce((n, [, d]) => n + (d.podcast || 0) + (d.audiobook || 0), 0); + const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60; + let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++; + const year = String(now.getFullYear()); + return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length }; +}; +module.exports = (name, kind) => { const file = () => path.join(users.dir(), name); const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } }; const merge = body => { - const mine = load(); - for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 }; + const mine = load(), log = kind ? loadLog() : null; let logged = false; + for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) { + const old = mine[k], n = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 }; + if (log && old && n.t > old.t) { const wall = (n.t - old.t) / 1000, gain = n.pos - old.pos; if (gain > 0 && gain <= wall * 2 + 5 && wall < 600) { const d = log.days[day(n.t)] = log.days[day(n.t)] || {}; d[kind] = Math.round(((d[kind] || 0) + Math.min(gain, wall + 5)) * 10) / 10; logged = true; } } + if (log && n.dur > 60 && n.pos >= n.dur - 30 && !log.finished[k]) { log.finished[k] = { t: n.t || Date.now(), kind }; logged = true; } + mine[k] = n; + } + if (logged) { for (const k of Object.keys(log.days).sort().slice(0, -400)) delete log.days[k]; const lf = logFile(), lt = lf + '.tmp'; fs.writeFileSync(lt, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(lt, lf); } const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine; }; return { load, merge }; }; +module.exports.stats = stats; diff --git a/public/app.js b/public/app.js index e93b3ed..fb8c6f9 100644 --- a/public/app.js +++ b/public/app.js @@ -367,6 +367,7 @@ function viewHome() { inboxWire(); if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc')); if (typeof homeResume === 'function') homeResume($('#resume')); + if (typeof homeListening === 'function') homeListening($('#resume')); renderNpCard(); // the live Now Playing card (it follows the song and has the controls) $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); diff --git a/public/bible-view.js b/public/bible-view.js index 9c1e50f..7134f41 100644 --- a/public/bible-view.js +++ b/public/bible-view.js @@ -68,13 +68,14 @@ async function viewBible(args = []) { if (!location.hash.startsWith('#/bible')) return; try { const s = await bbApi('/state'); bbSt = s; } catch {} if (args[0] === 'marks') return bbMarks(); + if (args[0] === 'today' && typeof bbToday === 'function') return bbToday(); let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0; if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); } const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set(); const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b); const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1]; v.innerHTML = `
-
+
${bbChapterHtml(book, c)}

Berean Standard Bible (BSB), public domain · berean.bible

`; @@ -84,7 +85,7 @@ async function viewBible(args = []) { const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `

${esc(n)}

`); return; } const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root); }); - $('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root); + $('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbtd').onclick = () => { location.hash = '#/bible/today'; }; $('#bbaa').onclick = () => bbTextSheet(root); let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true }); root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } }); bbCur.next = next && href(next); bbCur.prev = prev && href(prev); diff --git a/public/boot.js b/public/boot.js index a24746c..e9983df 100644 --- a/public/boot.js +++ b/public/boot.js @@ -18,6 +18,7 @@ setInterval(checkForUpdate, 10 * 60e3); await loadMe(); await loadSettings(); + if (typeof themeStart === 'function') themeStart(); sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart(); addEventListener('hashchange', route); restorePlayer(); diff --git a/public/contacts-view.js b/public/contacts-view.js index 828320b..6d11057 100644 --- a/public/contacts-view.js +++ b/public/contacts-view.js @@ -51,7 +51,7 @@ function ctShow(c) { ${c.emails.map(e => line(e.label, mailOn ? `${esc(e.value)}` : esc(e.value))).join('')} ${c.phones.map(p => line(p.label, `${esc(p.value)}`)).join('')} ${line('address', c.address ? `${esc(c.address)}` : '')} - ${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '
'))} + ${line('birthday', esc(ctBday(c.birthday)))}${line('anniversary', esc(ctBday(c.anniversary)))}${line('gift ideas', esc(c.gifts || '').replace(/\n/g, '
'))}${line('notes', esc(c.notes).replace(/\n/g, '
'))}
${mailOn && c.emails[0] ? `` : ''}
`); el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); }); if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); }; @@ -63,12 +63,12 @@ function ctShow(c) { function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); } function ctEdit(c, seed) { - const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' }; + const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', anniversary: '', gifts: '', notes: '' }; const rows = (kind, items, ph) => `
${(items.length ? items : [{ label: '', value: '' }]).map(i => `
`).join('')}
Add another`; const el = openSheet(c ? 'Edit contact' : 'New contact', '', `
${rows('emails', x.emails, 'name@example.com')}${rows('phones', x.phones, '555 123 4567')} -
+
`); el.querySelector('#ctx').onclick = closeSheet; el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); }); @@ -76,7 +76,8 @@ function ctEdit(c, seed) { el.querySelector('#ctf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = ''; const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value; - const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value }; + const keepAnn = x.anniversary && x.anniversary.startsWith('--') && !el.querySelector('#ctan').value; + const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, anniversary: keepAnn ? x.anniversary : el.querySelector('#ctan').value, gifts: el.querySelector('#ctg').value, notes: el.querySelector('#ctt').value }; try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; } }; el.querySelector('#ctn').focus(); diff --git a/public/devotion-view.js b/public/devotion-view.js new file mode 100644 index 0000000..62293ea --- /dev/null +++ b/public/devotion-view.js @@ -0,0 +1,47 @@ +'use strict'; +// ---- Devotion (inside Bible): a verse for today, a read-through-the-Bible-in-a-year plan with a check-in each day, and a prayer list the whole family shares. +const DV_VERSES = ['JHN 3:16', 'PSA 23:1-3', 'PRO 3:5-6', 'ROM 8:28', 'PHP 4:6-7', 'ISA 41:10', 'JER 29:11', 'MAT 6:33', 'PSA 46:1', 'JOS 1:9', 'ROM 12:2', 'GAL 5:22-23', '1CO 13:4-7', 'EPH 2:8-9', 'PSA 119:105', 'MAT 11:28-30', 'COL 3:23', 'PSA 27:1', '2TI 1:7', 'HEB 11:1', 'PSA 121:1-2', 'MIC 6:8', 'JHN 14:27', 'ROM 5:8', '1JN 4:19', 'PSA 37:4-5', 'LAM 3:22-23', 'ISA 40:31', 'MAT 5:14-16', 'JAS 1:5', '1PE 5:7', 'PSA 34:8', 'DEU 31:6', 'PRO 22:6', 'EPH 6:1-3', '1TH 5:16-18', 'PSA 139:13-14', 'JHN 15:12-13', 'ROM 15:13', 'NUM 6:24-26', 'PSA 103:1-2', 'HEB 13:5-6', 'PRO 16:3', '2CO 12:9', 'JHN 1:1-5', 'PSA 100:4-5', 'MAT 28:19-20', 'ZEP 3:17', 'PSA 51:10', 'COL 3:12-14']; +const dvDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +const dvDiff = (a, b) => Math.round((new Date(b + 'T12:00:00') - new Date(a + 'T12:00:00')) / 864e5); +/** The text of verses v..ve in a chapter, footnote markers left out. */ +function dvText(book, c, v, ve) { + const out = []; for (const b of book.chapters[c - 1]) if (b[0] === 'v' && b[1] >= v && b[1] <= (ve || v)) out.push(b[2].map(p => typeof p === 'string' ? p : Array.isArray(p) ? p[1] : '').join('').replace(/\s+/g, ' ').trim()); + return out.join(' '); +} +function dvVerseOfDay() { + const now = new Date(), n = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 864e5), m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(DV_VERSES[n % DV_VERSES.length]), book = bbBook(m[1]); + return book ? { ref: bbRef(m[1], +m[2], +m[3], m[4] ? +m[4] : 0), text: dvText(book, +m[2], +m[3], m[4] ? +m[4] : 0), link: `#/bible/${m[1]}/${m[2]}/${m[3]}` } : null; +} +/** Every chapter of the Bible in order, split into 365 daily readings. */ +function dvReadings(day) { + const all = []; for (const b of bb.books) for (let c = 1; c <= b.chapters.length; c++) all.push([b.id, c]); + const from = Math.floor(day * all.length / 365), to = Math.floor((day + 1) * all.length / 365); return all.slice(from, to); +} +async function bbToday() { + const v = $('#view'); let plan = bbSt.plan || null, prayers = [], showAnswered = false; + try { prayers = await bbApi('/prayers'); } catch {} + const draw = () => { + const vod = dvVerseOfDay(), today = dvDay(), idx = plan ? dvDiff(plan.start, today) : -1, day = Math.min(364, Math.max(0, idx)), done = new Set(plan ? plan.done : []); + let streak = 0; if (plan) for (let d = Math.min(idx, 364) - (done.has(Math.min(idx, 364)) ? 0 : 1); d >= 0 && done.has(d); d--) streak++; + const open = prayers.filter(p => !p.answered), answered = prayers.filter(p => p.answered); + const prayRow = p => `
${esc(p.text)}${esc(p.byName)} · ${new Date(p.answered ? p.answeredAt : p.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}${p.answered ? ' · answered' : ''}${p.mine || (typeof ME !== 'undefined' && ME && ME.role === 'admin') ? `` : ''}
`; + v.innerHTML = `
+ ${vod ? `

Verse for today

“${esc(vod.text)}”

${esc(vod.ref)} · BSB
` : ''} +

Reading plan

${plan ? `
Day ${day + 1} of 365 · ${done.size} day${done.size === 1 ? '' : 's'} done${streak > 1 ? ` · ${streak} in a row` : ''} +
${idx < 0 ? `

Your plan starts ${esc(plan.start)}.

` : dvReadings(day).map(([b, c]) => `${esc(bbBook(b).name)} ${c}`).join('')}
+ ${idx >= 0 ? `
` : ''}
` + : `

Read the whole Bible in a year, a few chapters a day, and check in each day.

`} +

Family prayer list

+
${open.map(prayRow).join('') || '
Nothing yet. Add what the family is praying about.
'}
+ ${answered.length ? `
${showAnswered ? `
${answered.map(prayRow).join('')}
` : ''}` : ''}
`; + const save = async (fn) => { try { await fn(); draw(); } catch (e) { toast(e.message); } }; + const st = $('#dvstart'); if (st) st.onclick = () => save(async () => { plan = (await bbApi('/plan', bbOpt('POST', { start: dvDay() }))).plan; bbSt.plan = plan; }); + const sp = $('#dvstop'); if (sp) sp.onclick = () => confirmSheet('Stop the reading plan?', 'Your check-ins are cleared.', 'Stop plan', () => save(async () => { await bbApi('/plan', bbOpt('POST', { stop: true })); plan = bbSt.plan = null; })); + const ck = $('#dvck'); if (ck) ck.onclick = () => save(async () => { plan = (await bbApi('/plan/check', bbOpt('POST', { day, on: !done.has(day) }))).plan; bbSt.plan = plan; }); + $('#dvpf').onsubmit = e => { e.preventDefault(); const t = $('#dvpt').value.trim(); if (t) save(async () => { prayers = await bbApi('/prayers', bbOpt('POST', { text: t })); }); }; + v.querySelectorAll('[data-pa]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/answer', bbOpt('POST', { id: b.dataset.pa, on: !!b.dataset.on })); })); + v.querySelectorAll('[data-pd]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/delete', bbOpt('POST', { id: b.dataset.pd })); })); + const an = $('#dvans'); if (an) an.onclick = () => { showAnswered = !showAnswered; draw(); }; + }; + draw(); +} diff --git a/public/index.html b/public/index.html index 97c3bc5..5323735 100644 --- a/public/index.html +++ b/public/index.html @@ -82,6 +82,7 @@ + @@ -105,6 +106,7 @@ + diff --git a/public/recipes-view.js b/public/recipes-view.js index 14d7da2..08a57fc 100644 --- a/public/recipes-view.js +++ b/public/recipes-view.js @@ -4,7 +4,7 @@ const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts); const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; }; -const rcTabs = on => ``; +const rcTabs = on => ``; let rcQuery = '', rcPage = 1; async function viewRecipes(args = []) { @@ -13,6 +13,7 @@ async function viewRecipes(args = []) { try { if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1])); if (args[0] === 'shop') return await rcShop(); + if (args[0] === 'plan') return await rcPlan(args[1]); return await rcHome(); } catch (e) { if (!location.hash.startsWith('#/recipes')) return; @@ -59,7 +60,7 @@ async function rcOne(slug) {

${esc(r.name)}

${r.description ? `

${esc(r.description)}

` : ''} ${facts.length ? `

${facts.map(f => `${esc(f[0])} ${esc(f[1])}`).join(' · ')}

` : ''} ${r.orgUrl ? `

Original page

` : ''}
-
Shopping list${allowed('chat') ? '' : ''}
+
Shopping list${allowed('chat') ? '' : ''}

Ingredients

${r.ingredients.map((i, n) => i.title && !i.text ? `

${esc(i.title)}

` : ``).join('') || '

No ingredients.

'}

Steps

    ${r.steps.map((s, n) => `
  1. ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
    ')}
  2. `).join('') || '
  3. No steps.
  4. '}
@@ -77,6 +78,7 @@ async function rcOne(slug) { $('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); }; paint(); if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug)); + $('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id); $('#rcadd').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; } @@ -102,3 +104,34 @@ async function rcShop() { try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date) await draw(await rcApi('/shopping')); } + +// ---- Meal plan: a week at a time, shared by the family. Pick a recipe for a day, then add the week's ingredients to the shopping list in one tap. +const rcYmd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +const rcMonday = d => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x; }; +const RC_MEALS = [['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner']]; +async function rcPlan(start) { + const mon = start && /^\d{4}-\d{2}-\d{2}$/.test(start) ? rcMonday(new Date(start + 'T12:00:00')) : rcMonday(new Date()), days = [...Array(7)].map((_, i) => { const d = new Date(mon); d.setDate(d.getDate() + i); return d; }); + const from = rcYmd(days[0]), to = rcYmd(days[6]), plan = await rcApi(`/plan?from=${from}&to=${to}`); if (!location.hash.startsWith('#/recipes')) return; + const shift = n => { const d = new Date(mon); d.setDate(d.getDate() + n); return '#/recipes/plan/' + rcYmd(d); }, today = rcYmd(new Date()), count = Object.values(plan).reduce((n, l) => n + l.length, 0); + $('#view').innerHTML = `

Recipes

${rcTabs('plan')} +
${icon('back')}${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}${icon('chev')}
+
${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal)); + return `
${d.toLocaleDateString(undefined, { weekday: 'short' })}${d.getDate()} + ${es.map(e => `${esc(e.name)}${e.meal !== 'dinner' ? ` · ${esc(e.meal)}` : ''}`).join('') || 'Nothing planned'} +
`; }).join('')}
+
`; + document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add)); + document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } }); + $('#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). */ +function rcPlanPick(slug, name, rid, date) { + const el = openSheet(slug ? 'Plan ' + name : 'Add a meal', '', `
${slug ? '' : '
'} +
+
${slug ? '' : ''}
`); + const save = async (sl, nm, id) => { try { await rcApi('/plan/add', rcOpt('POST', { date: el.querySelector('#rcpd').value, slug: sl, name: nm, rid: id, meal: el.querySelector('#rcpm').value })); closeSheet(); toast('Added to the meal plan', { label: 'Open', run: () => { location.hash = '#/recipes/plan/' + el.querySelector('#rcpd').value; } }); if (location.hash.startsWith('#/recipes/plan')) rcPlan(el.querySelector('#rcpd').value); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } }; + el.querySelector('#rcpf').onsubmit = e => { e.preventDefault(); if (slug) save(slug, name, rid); }; + if (slug) return; + let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `${esc(x.name)}`).join('') || '
No recipes found
'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } }; + el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find(); +} diff --git a/public/settings.js b/public/settings.js index 8b257a8..349f5bb 100644 --- a/public/settings.js +++ b/public/settings.js @@ -86,7 +86,7 @@ function viewSettings(args) {

Signal alerts

How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.

-
${remindersHtml()}

Install

${installHtml()} +
${remindersHtml()}

Your data

A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.

Install

${installHtml()}

Connections

Seafile${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}
PlexChecking…
@@ -111,7 +111,8 @@ function viewSettings(args) {

Health

Checking…

Storage

Checking…

iPad and Screen Time

Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.

-

A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.

` : ''} +

A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. To restore, choose a backup file below.

+

Replaces the hub's accounts and data with the backup's. The current data is kept in a .pre-restore folder, and the hub restarts.

` : ''} ${isAdmin ? `

Activity

Loading…
` : ''}
`; remindersInit(); @@ -200,6 +201,10 @@ function viewSettings(args) { el.innerHTML = events.length ? events.slice(0, 40).map(e => `
${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}
`).join('') : '
Nothing yet
'; }).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '
Could not load
'; }); if (typeof famSettings === 'function') famSettings($('#famset')); + if (typeof themeSettings === 'function') themeSettings($('#themeset')); + const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => { + const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…'; + try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); }; $('#invpk').onclick = async () => { try { const { code, minutes } = await authPost('/api/auth/invite'); diff --git a/public/style.css b/public/style.css index 43b1761..1803365 100644 --- a/public/style.css +++ b/public/style.css @@ -773,7 +773,7 @@ body.nav-mini .ngrp span { display: none; } .vdeckc { position: relative; flex: none; } .vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; } .vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); } -.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; } +.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { width: auto; flex: 0 1 auto; min-height: 40px; max-width: 48vw; } .vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; } .vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); } .vdeckc .vcard.wide { width: 250px; } @@ -1095,3 +1095,9 @@ body.simple #shell { flex-direction: column; } body.simple #view { padding-bottom: 110px; } /* while a sheet, the lyrics or the visualizer covers the screen, the floating Home and Ask buttons step aside so they never sit on its controls */ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simplehome, body:has(#lyr) #kidreq, body.vizopen #simplehome, body.vizopen #kidreq { display: none !important; } + +.swatches { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0; } .swatches button { width: 40px; height: 40px; min-height: 0; padding: 0; border-radius: 50%; border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--line); } .swatches button.on { box-shadow: 0 0 0 3px var(--text); } +.dvverse { display: block; max-width: none; } .dvverse p { font-family: Georgia, 'Times New Roman', serif; font-size: 19px; line-height: 1.5; margin: 0 0 8px; } .dvverse small { color: var(--muted); } .dvread { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; } +.lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); } +.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; } +.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); } diff --git a/public/sw.js b/public/sw.js index 1f18220..270b1b3 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-v95'; +const CACHE = 'hub-v102'; 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/public/themes.js b/public/themes.js new file mode 100644 index 0000000..e6d096b --- /dev/null +++ b/public/themes.js @@ -0,0 +1,32 @@ +'use strict'; +// ---- Themes: each person picks an accent colour and Auto, Light, Dark or AMOLED black (true black, easy on phone batteries). Kept in their synced settings, so it follows them between devices. +const THEME_ACCENTS = [['blue', 'Blue', '#3d6bf0', '#6a8cff'], ['teal', 'Teal', '#0f8f8a', '#3cc7c0'], ['green', 'Green', '#2f9e6e', '#4cc98d'], ['orange', 'Orange', '#d9741a', '#f09a45'], ['red', 'Red', '#d4392f', '#ff6b60'], ['pink', 'Pink', '#c9407d', '#f26aa4'], ['purple', 'Purple', '#7b4fe0', '#a688ff']]; +const THEME_MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark'], ['amoled', 'AMOLED']]; +const THEME_VARS = { + light: { '--bg': '#f5f6f8', '--panel': '#fff', '--panel2': '#eef0f4', '--text': '#14171c', '--muted': '#6a7280', '--line': '#e0e3e9' }, + dark: { '--bg': '#0f1115', '--panel': '#181b21', '--panel2': '#20242c', '--text': '#eceef2', '--muted': '#8b93a3', '--line': '#272c35' }, + amoled: { '--bg': '#000', '--panel': '#0b0b0d', '--panel2': '#16171b', '--text': '#eceef2', '--muted': '#8b93a3', '--line': '#1f2127' }, +}; +function themeApply() { + const root = document.documentElement, mode = store.get('theme_mode', 'auto'), acc = THEME_ACCENTS.find(a => a[0] === store.get('theme_accent', 'blue')) || THEME_ACCENTS[0]; + const dark = mode === 'dark' || mode === 'amoled' || (mode === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches); + for (const k of Object.keys(THEME_VARS.light)) { if (mode === 'auto') root.style.removeProperty(k); else root.style.setProperty(k, THEME_VARS[mode][k]); } + const hex = dark ? acc[3] : acc[2]; + if (acc[0] === 'blue' && mode === 'auto') { root.style.removeProperty('--accent'); root.style.removeProperty('--accent-t'); } + else { const n = parseInt(hex.slice(1), 16), lum = (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; root.style.setProperty('--accent', hex); root.style.setProperty('--accent-t', lum > .6 ? '#0b1020' : '#fff'); } + root.style.colorScheme = mode === 'auto' ? '' : dark ? 'dark' : 'light'; + const m = document.querySelector('meta[name=theme-color]'); if (m) m.content = dark ? (mode === 'amoled' ? '#000000' : '#0f1115') : (mode === 'light' ? '#f5f6f8' : m.content); +} +function themeStart() { themeApply(); try { matchMedia('(prefers-color-scheme: dark)').addEventListener('change', themeApply); } catch {} } +function themeSettings(el) { + if (!el) return; + const draw = () => { + const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue'); + el.innerHTML = `

Look

Saved for you and used on every device you sign in on.

+
${THEME_MODES.map(([k, l]) => `${l}`).join('')}
+
${THEME_ACCENTS.map(([k, l, c]) => ``).join('')}
`; + el.querySelectorAll('[data-m]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('theme_mode', a.dataset.m); themeApply(); draw(); }); + el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { store.set('theme_accent', b.dataset.a); themeApply(); draw(); }); + }; + draw(); +} diff --git a/public/today.js b/public/today.js index 3959272..ab1269c 100644 --- a/public/today.js +++ b/public/today.js @@ -28,3 +28,17 @@ async function homeResume(el) { el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); }); } const today = { books: [] }; + +// Home: how much you listened this week (podcasts and audiobooks), the day streak, and a yearly goal for audiobooks finished. The goal is kept in your synced settings. +const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : ''); }; +async function homeListening(after) { + if (!after || !(allowed('podcast') || allowed('audio'))) return; + let s; try { s = await jfetch('/api/listening/stats'); } catch { return; } + const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected || (!s.week && !goal && !s.finishedYear)) return; + const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening'; + card.innerHTML = `

Listening

${lsTime(s.week)}this week${s.streak}day${s.streak === 1 ? '' : 's'} in a row${s.finishedYear}${goal ? ' / ' + goal : ''}audiobooks finished this year
+ + ${goal ? `
` : ''}
`; + after.insertAdjacentElement('afterend', card); + card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `
`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; }; +} diff --git a/public/video.js b/public/video.js index 20bdba9..705f566 100644 --- a/public/video.js +++ b/public/video.js @@ -82,7 +82,7 @@ async function videoHome(st, libKey) { v.innerHTML = `

Movies & TV

${secs.length > 1 ? `
${secs.map(s => `${esc(s.title)}`).join('')}
` : ''}

${esc(cur.title)}

-

Loading…

`; +

Loading…

`; plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person // Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once. const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k; @@ -107,21 +107,21 @@ async function videoHome(st, libKey) { px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `

Collections

${items.slice(0, 30).map(m => vdCard(m, false)).join('')}
` : ''; }).catch(() => {}); // sort and filter the library below const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = ''; - $('#vsort').value = fs; $('#vunwc').checked = fu; + $('#vsort').value = fs; $('#vwatch').value = fu ? '1' : ''; px.get(`/library/sections/${cur.key}/decade`).then(mc => { const g = $('#vdec'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).sort((a, b) => b.key - a.key).map(d => ``).join('')); }).catch(() => {}); px.get(`/library/sections/${cur.key}/contentRating`).then(mc => { const g = $('#vcr'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => ``).join('')); }).catch(() => {}); px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => ``).join('')); }).catch(() => {}); let start = 0, total = 0, all = [], gen = 0; const load = async () => { - const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked || $('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`); + const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`); if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items); $('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.remove()); $('#vmore').innerHTML = start < total ? `
` : ''; if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message)); - if (!all.length) { $('#vgrid').innerHTML = `
${icon('film')}

Nothing matches.

${$('#vunwc').checked || genre ? '' : ''}
`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vunwc').checked = false; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; } + if (!all.length) { $('#vgrid').innerHTML = `
${icon('film')}

Nothing matches.

${$('#vwatch').value || genre ? '' : ''}
`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vwatch').value = ''; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; } }; const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = ''.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `

${esc(e.message)}

`; }); }; - $('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr', '#vwatch']) $(id).onchange = () => reload(); + $('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vwatch').onchange = () => { store.set('video_unw', $('#vwatch').value === '1' ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr']) $(id).onchange = () => reload(); reload(); // press and hold (or right-click) a card: quick actions const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up) diff --git a/recipes.js b/recipes.js index 7c0584d..36aed8e 100644 --- a/recipes.js +++ b/recipes.js @@ -99,6 +99,19 @@ async function sync() { return { ...out, list: tl.id }; } +// ---- Meal plan: which recipe is on which day (shared by the whole family), kept in DATA_DIR/mealplan.json +const DAY = /^\d{4}-\d{2}-\d{2}$/, MEALS = ['breakfast', 'lunch', 'dinner'], planFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'mealplan.json'); +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; }; +/** 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)); + 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) + return r; +} + async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams; try { @@ -127,11 +140,25 @@ async function handle(req, res) { } 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 b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await addRecipe(b.slug, +b.scale || 1); const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name }); } + if (p === '/plan' && req.method === 'GET') { const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); return json(res, 200, planBetween(from, to)); } + if (p === '/plan/add' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.date || '') || !slugOk(b.slug)) throw fail(400, 'Pick a day and a recipe'); + const d = readPlan(), id = crypto.randomBytes(5).toString('hex'), cut = new Date(Date.now() - 90 * 864e5).toISOString().slice(0, 10); + for (const k of Object.keys(d.days)) if (k < cut) delete d.days[k]; + (d.days[b.date] = d.days[b.date] || []).push({ id, slug: b.slug, name: String(b.name || b.slug).slice(0, 160), rid: idOk(b.rid) ? b.rid : '', meal: MEALS.includes(b.meal) ? b.meal : 'dinner', by: (users.current() || {}).id || '' }); + 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 === '/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; + const slugs = Object.keys(count); if (!slugs.length) throw fail(400, 'Nothing is planned in those days'); let added = 0; + for (const sl of slugs) { try { await addRecipe(sl, count[sl]); added++; } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } } + const s = await sync(); return json(res, 200, { ok: true, ...s, recipes: added }); + } 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: 0, checked: false } }); rememberWho(text, users.current().name); await sync(); return json(res, 200, { ok: true }); diff --git a/server.js b/server.js index 36a22f3..15f1920 100644 --- a/server.js +++ b/server.js @@ -660,7 +660,9 @@ async function handleRequest(req, res) { podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); } if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); - if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); + if (pathname === '/api/listening/stats') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').stats())); } + if (pathname === '/api/export') return backup.handle(req, res, user.name); + if (pathname === '/api/backup' || pathname === '/api/backup/restore') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); if (pathname === '/api/kidsearch' || pathname.startsWith('/api/kidsearch/')) return require('./kidsearch').handle(req, res); if (pathname === '/api/ddg') { if (user.kid && user.kid.on && user.role !== 'admin') return forbidden(res, 'Web search is not available on a child account'); return ddgAnswer(req, res); } if (pathname.startsWith('/api/push/')) return push.handle(req, res); diff --git a/tools/backup-test.js b/tools/backup-test.js new file mode 100644 index 0000000..41060b0 --- /dev/null +++ b/tools/backup-test.js @@ -0,0 +1,27 @@ +// Backup restore and personal export: a good backup is swapped in (the old data kept aside), unsafe archives are refused, and an export holds only your own data without sign-in tokens. +const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http'), { execFileSync } = require('child_process'); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-backup-')); process.env.DATA_DIR = dir; process.env.NO_RESTART_AFTER_RESTORE = '1'; +const users = require('../users'), backup = require('../backup'); +const a = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b = users.create({ name: 'Bo', role: 'user', handle: 'ZP' }); +fs.writeFileSync(path.join(users.dirOf(a.id), 'tasks.json'), '{"mine":1}'); fs.writeFileSync(path.join(users.dirOf(a.id), 'mail.json'), '{"token":"secret"}'); fs.writeFileSync(path.join(users.dirOf(b.id), 'tasks.json'), '{"bo":1}'); +const hub = http.createServer((req, res) => users.runAs(a, () => backup.handle(req, res, 'Ann'))).listen(0, async () => { + const base = 'http://127.0.0.1:' + hub.address().port; + const mk = (name, fn) => { const w = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-bk-src-')); fn(w); const f = path.join(os.tmpdir(), name); execFileSync('tar', ['-czf', f, '-C', w, '.']); return fs.readFileSync(f); }; + try { + // export: only my data, no tokens + const ex = Buffer.from(await (await fetch(base + '/api/export')).arrayBuffer()), ef = path.join(os.tmpdir(), 'nehub-ex.tar.gz'); fs.writeFileSync(ef, ex); + const list = execFileSync('tar', ['-tzf', ef]).toString(); assert.match(list, /me\/tasks\.json/); assert.ok(!/mail\.json/.test(list), 'sign-in tokens are left out'); assert.ok(!list.includes(b.id), 'nobody else\'s data'); + // a good backup is swapped in; the old data is kept + const good = mk('good.tar.gz', w => { fs.writeFileSync(path.join(w, 'secret.json'), '{"restored":true}'); fs.mkdirSync(path.join(w, 'users')); }); + let r = await fetch(base + '/api/backup/restore', { method: 'POST', body: good }); let j = await r.json(); assert.equal(r.status, 200, JSON.stringify(j)); assert.ok(j.restarting); + assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'secret.json'), 'utf8')).restored, true); assert.ok(fs.existsSync(path.join(dir, j.kept, 'users')), 'the old accounts are kept aside'); + assert.ok(!fs.existsSync(path.join(dir, '.restore')) && !fs.existsSync(path.join(dir, '.restore.tar.gz')), 'the staging files are cleaned up'); + // unsafe or foreign archives are refused and change nothing + const evil = mk('evil.tar.gz', w => { fs.writeFileSync(path.join(w, 'secret.json'), '{"evil":true}'); fs.symlinkSync('/etc/passwd', path.join(w, 'users')); }); + r = await fetch(base + '/api/backup/restore', { method: 'POST', body: evil }); assert.equal(r.status, 400); assert.match((await r.json()).error, /links/); assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'secret.json'), 'utf8')).restored, true, 'nothing changed'); + const foreign = mk('foreign.tar.gz', w => fs.writeFileSync(path.join(w, 'readme.txt'), 'hi')); r = await fetch(base + '/api/backup/restore', { method: 'POST', body: foreign }); assert.equal(r.status, 400); assert.match((await r.json()).error, /NE Hub backup/); + r = await fetch(base + '/api/backup/restore', { method: 'POST', body: Buffer.from('not a tar') }); assert.equal(r.status, 400); + console.log('backup test ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); +}); diff --git a/tools/bible-test.js b/tools/bible-test.js index 846fdf0..1329c88 100644 --- a/tools/bible-test.js +++ b/tools/bible-test.js @@ -6,15 +6,26 @@ const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => { const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h }); try { - assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [] }); + assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [], plan: null }); await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins'); assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3); r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1); r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }])); assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3); r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted'); - assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [] }, "each person's place and marks are their own"); + assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [], plan: null }, "each person's place and marks are their own"); assert.equal((await fetch(base + '/nope')).status, 404); + // devotion: a reading plan with check-ins, and the shared prayer list + r = await j(await post('/plan', { start: '2026-01-01' })); assert.equal(r.plan.start, '2026-01-01'); assert.equal((await post('/plan', { start: 'nope' })).status, 400); + r = await j(await post('/plan/check', { day: 3, on: true })); await post('/plan/check', { day: 1, on: true }); r = await j(await post('/plan/check', { day: 3, on: true })); assert.deepEqual(r.plan.done, [1, 3], 'check-ins are kept in order, once each'); + assert.equal((await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } }))).plan, null, 'each person has their own plan'); + r = await j(await post('/prayers', { text: 'For Grandma' })); assert.equal(r.length, 1); const pid = r[0].id; assert.equal((await post('/prayers', { text: ' ' })).status, 400); + assert.equal((await j(await fetch(base + '/prayers', { headers: { 'x-user': 'b' } }))).length, 1, 'the whole family sees the list'); + r = await j(await post('/prayers/answer', { id: pid, on: true }, { 'x-user': 'b' })); assert.equal(r[0].answered, true); + assert.equal((await post('/prayers/delete', { id: pid }, { 'x-user': 'b' })).status, 403, 'only the author or an admin removes a request'); assert.equal((await j(await post('/prayers/delete', { id: pid }))).length, 0); + // every verse of the day exists in the text + { const code = fs.readFileSync(path.join(__dirname, '..', 'public', 'devotion-view.js'), 'utf8'), list = JSON.parse(/const DV_VERSES = (\[.*?\]);/.exec(code)[1].replace(/'/g, '"')), dd = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); + for (const ref of list) { const m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(ref), bk = dd.books.find(b => b.id === m[1]); assert.ok(bk && bk.chapters[+m[2] - 1] && bk.chapters[+m[2] - 1].some(x => x[0] === 'v' && x[1] === +m[3]), ref + ' is in the Bible text'); } } // the bundled text const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189); const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there'); diff --git a/tools/contacts-test.js b/tools/contacts-test.js index f9060c5..8e7e25a 100644 --- a/tools/contacts-test.js +++ b/tools/contacts-test.js @@ -22,6 +22,9 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] == const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back'); // calendar birthdays const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday"); + // anniversaries and gift ideas + { const r = await post('/', { name: 'Ann Iversary', anniversary: '2010-08-12', gifts: 'Scarf\nBook' }); const a = await r.json(); assert.equal(a.anniversary, '2010-08-12'); assert.equal(a.gifts, 'Scarf\nBook'); + const ev = contacts.birthdayEvents({ fd: '2026-08-01', td: '2026-08-31' }, people.a.id).filter(e => e.contactId === a.id); assert.equal(ev.length, 1); assert.equal(ev[0].title, "Ann Iversary's anniversary (16)"); assert.equal(ev[0].start, '2026-08-12'); } const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known'); assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0); // each person has their own book diff --git a/tools/listening-test.js b/tools/listening-test.js new file mode 100644 index 0000000..b70867d --- /dev/null +++ b/tools/listening-test.js @@ -0,0 +1,15 @@ +// Listening stats: time listened is counted from progress reports (seeks are not), per day and kind; finished items and the day streak are worked out. +const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-listen-')); +const users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), progress = require('../progress'); +const pod = progress('podcast-progress.json', 'podcast'), ab = progress('audiobook-progress.json', 'audiobook'); +users.runAs(me, () => { + const t0 = Date.now() - 60e3; + pod.merge({ e1: { pos: 100, dur: 3000, t: t0 } }); pod.merge({ e1: { pos: 130, dur: 3000, t: t0 + 30e3 } }); // 30 s listened in 30 s + pod.merge({ e1: { pos: 1500, dur: 3000, t: t0 + 40e3 } }); // a seek: not counted + ab.merge({ b1: { pos: 7000, dur: 7100, t: t0 } }); ab.merge({ b1: { pos: 7090, dur: 7100, t: t0 + 50e3 } }); // 50 s counted at most, and the book is finished + let s = progress.stats(); assert.equal(Math.round(s.podcast), 30, 'only real listening counts'); assert.ok(s.audiobook > 0 && s.audiobook <= 55); assert.equal(s.finishedYear, 1); assert.equal(s.finishedAll, 1); + pod.merge({ e1: { pos: 1530, dur: 3000, t: t0 + 70e3 } }); s = progress.stats(); assert.equal(s.streak, 1, 'a minute or more makes a streak day'); + const old = progress('podcast-progress.json', 'podcast'); old.merge({ e1: { pos: 5, dur: 3000, t: t0 - 1e3 } }); assert.equal(progress.stats().podcast, s.podcast, 'an older report changes nothing'); + console.log('listening test ok'); +}); diff --git a/tools/recipes-test.js b/tools/recipes-test.js index c624fc4..e361690 100644 --- a/tools/recipes-test.js +++ b/tools/recipes-test.js @@ -55,6 +55,13 @@ const fake = http.createServer((req, res) => { j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too'); // an older Mealie that only knows the list form of "add recipe" newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe')); + // meal plan: shared, by day; the week's ingredients go on the shopping list + assert.equal((await post('/plan/add', { date: 'soon', slug: 'bread' })).status, 400, 'a real day is needed'); + await post('/plan/add', { date: '2027-03-02', slug: 'bread', name: 'Bread', meal: 'dinner' }); await post('/plan/add', { date: '2027-03-04', slug: 'bread', name: 'Bread', meal: 'lunch' }); await post('/plan/add', { date: '2027-03-20', slug: 'bread', name: 'Bread' }); + j = await (await get('/plan?from=2027-03-02&to=2027-03-08')).json(); assert.deepEqual(Object.keys(j), ['2027-03-02', '2027-03-04'], 'only the days asked for'); assert.equal(j['2027-03-04'][0].meal, 'lunch'); + 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(), {}); // 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);