14 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 95ade02bf2 Offline edits that send when back online, books saved on the device, and keyboard access for dialogs and Bible verses; roadmap
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:36:32 -04:00
nmemmertandClaude Sonnet 5.5 275ef2009f Reading goals for e-books and the family; subtasks on iCloud lists; watch stats in Movies & TV
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:30:40 -04:00
nmemmertandClaude Sonnet 5.5 435f442761 Pantry as an inventory with low levels; automations for a low pantry item and new podcast episodes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:29:09 -04:00
nmemmertandClaude Sonnet 5.5 db58bfa982 Frontend load test in CI, health page checks Seafile, OnlyOffice, audiobooks, podcasts and Mealie; roadmap updated
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:33:20 -04:00
nmemmertandClaude Sonnet 5.5 94d13b82d7 Automations: when this happens, do that (daily time, task finished, new mail)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:29:52 -04:00
nmemmertandClaude Sonnet 5.5 f80b718bd4 Extensions: notes, recipes and music in the bridge, a permissions screen, and an Updated notice
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:27:19 -04:00
nmemmertandClaude Sonnet 5.5 808274b4fe Mail: unified inbox, snooze, templates, and saving attachments to Files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:25:48 -04:00
nmemmertandClaude Sonnet 5.5 54d18594a3 Accessibility settings, deferred scripts, and offline reading of tasks, notes, recipes, calendar and contacts
Keeps saved episodes and shares when the app updates (they were being deleted with the old shell cache).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:23:38 -04:00
nmemmertandClaude Sonnet 5.5 2766424bb2 Movies & TV: favorites, a family movie night list to vote on, and mixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:20:33 -04:00
nmemmertandClaude Sonnet 5.5 fe651a8c37 Tasks: a due time with a reminder, and Today and Upcoming views
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:19:05 -04:00
nmemmertandClaude Sonnet 5.5 d761020869 Family calendar: a colour per person, invitations that land on the invited person's calendar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:17:53 -04:00
nmemmertandClaude Sonnet 5.5 b2b772941b Small gaps: pantry flags in the meal plan, birthday and anniversary notifications, download notes and highlights
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:16:26 -04:00
nmemmertandClaude Sonnet 5.5 342c8af747 Devotion: keep the space where a footnote marker splits the verse text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:14:54 -04:00
nmemmertandClaude Sonnet 5.5 b6786881c3 Merge branch 'v2-backlog'
build-container / build (push) Successful in 2m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 09:50:54 -04:00
48 changed files with 926 additions and 154 deletions

No files matched your search

+4
View File
@@ -18,6 +18,7 @@ jobs:
node-version: 22
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: node tools/frontend-test.js # every page script loads (no syntax errors, no names declared twice)
- run: node tools/smoke-test.js
- run: node tools/tasks-test.js # repeating tasks and subtasks
- run: node tools/push-test.js # reminders: when they are sent, never twice
@@ -32,6 +33,9 @@ jobs:
- 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/moviefam-test.js # Movies & TV: favorites and the family movie night vote
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
- 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
+1 -1
View File
@@ -160,7 +160,7 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`). A refused request rejects with an error. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`), `hub.notes.list() / add({ title, body })` (new notes are private), `hub.recipes.search(text) / get(slug)` and `hub.music.nowPlaying()` (what is playing, or `null`). A refused request rejects with an error. Each permission can be switched off again from the extension's **Permissions** button, and when an admin changes an extension (its version or file) the people who have it see "Updated" the next time they open it. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions up to 2 MB). Only an admin can add, change or delete items.
+89
View File
@@ -0,0 +1,89 @@
// Automations: "when this happens, do that" rules, one set per person. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to the Shopping
// list, or notify me. Rules only ever act on the person's own things, a rule runs at most 20 times a day, and what a rule does never starts another rule.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const WHEN = ['time', 'task.done', 'mail.new', 'pantry.low', 'podcast.new'], THEN = ['task', 'shopping', 'notify'], MAX_RULES = 30, DAILY_CAP = 20;
const file = () => path.join(users.dir(), 'automations.json');
const load = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
const save = l => atomicWrite(file(), JSON.stringify(l, null, 1), { mode: 0o600 });
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
/** A rule from what the browser sent, or an error that says what is missing. */
function clean(b, old) {
const wt = String(b.when && b.when.type), tt = String(b.then && b.then.type);
if (!WHEN.includes(wt)) throw fail(400, 'Choose when it should happen'); if (!THEN.includes(tt)) throw fail(400, 'Choose what it should do');
const when = { type: wt }; if (wt === 'time') { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(b.when.at || ''))) throw fail(400, 'Pick a time of day'); when.at = b.when.at; } else when.match = str(b.when.match, 80);
if (wt === 'mail.new' && !when.match) throw fail(400, 'Say what the sender or subject should contain (so it is not every email)');
if (wt === 'podcast.new' && !when.match) throw fail(400, 'Say which show (part of its name)');
const text = str(b.then.text, 200); if (!text) throw fail(400, tt === 'notify' ? 'Write the message' : 'Write the task');
return { id: old ? old.id : crypto.randomBytes(5).toString('hex'), name: str(b.name, 60) || (wt === 'time' ? 'Every day at ' + when.at : wt === 'task.done' ? 'When a task is finished' : wt === 'pantry.low' ? 'When the pantry runs low' : wt === 'podcast.new' ? 'When a new episode comes out' : 'When mail arrives'), on: b.on !== false, when, then: { type: tt, text }, runs: old ? old.runs || 0 : 0, last: old ? old.last || 0 : 0, day: old ? old.day : '', today: old ? old.today || 0 : 0 };
}
const fill = (text, ctx) => text.replace(/\{(title|from|subject|show)\}/g, (_, k) => str(ctx[k] || ctx.title || '', 120));
/** Do a rule's action for the person it belongs to (run inside their context). */
async function act(rule, ctx) {
const me = users.current(), text = fill(rule.then.text, ctx);
if (rule.then.type === 'task' || rule.then.type === 'shopping') { require('./tasks').addLocalTask(rule.then.type === 'shopping' ? 'Shopping' : '', text); return; }
require('./inbox').add(me.id, { kind: 'info', title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id });
try { await require('./push').notify(me.id, { title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id }); } catch { /* the Notifications list has it */ }
}
async function run(rule, ctx) {
const l = load(), r = l.find(x => x.id === rule.id); if (!r) return false;
if (r.day !== today()) { r.day = today(); r.today = 0; } if (r.today >= DAILY_CAP) return false;
r.today++; r.runs = (r.runs || 0) + 1; r.last = Date.now(); save(l);
try { await act(r, ctx); return true; } catch (e) { console.warn('[automations]', r.name, e.message); return false; }
}
/** Something happened for a person: run their matching rules. type: 'task.done' ({ title }) or 'mail.new' ({ from, subject }). */
async function fire(uid, type, ctx) {
const u = users.get(uid); if (!u || u.disabled) return;
await users.runAs(u, async () => {
for (const r of load()) {
if (!r.on || r.when.type !== type) continue; const m = r.when.match.toLowerCase(), hay = (type === 'mail.new' ? (ctx.from || '') + ' ' + (ctx.subject || '') : type === 'podcast.new' ? (ctx.show || '') + ' ' + (ctx.title || '') : ctx.title || '').toLowerCase();
if (m && !hay.includes(m)) continue; await run(r, ctx);
}
});
}
/** Something happened that concerns everyone (the pantry went low, a new episode): run every person's matching rules. */
async function fireAll(type, ctx) { for (const u of users.all()) await fire(u.id, type, ctx).catch(() => {}); }
/** Daily rules whose time has come, for one person; `lt` is their own { day, hm }. Run once a minute. */
async function tickUser(u, lt) {
await users.runAs(u, async () => {
for (const r of load()) if (r.on && r.when.type === 'time' && r.when.at <= lt.hm && r.doneDay !== lt.day) {
const l = load(), x = l.find(y => y.id === r.id); if (!x) continue; x.doneDay = lt.day; save(l); // (marked first, so a slow action is never run twice)
if (lt.hm >= r.when.at && minutes(lt.hm) - minutes(r.when.at) <= 180) await run(x, { title: x.name }); // (not for a time that passed hours ago, such as a rule made in the evening)
}
});
}
/** New podcast episodes since the last look (not the first look, which only notes what is already there): run the "new episode" rules. Checked every half hour by push.js. */
const SEEN = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'podcast-seen.json');
async function checkEpisodes(getRecent) {
let seen = null; try { seen = JSON.parse(fs.readFileSync(SEEN(), 'utf8')); } catch { /* the first look */ }
const eps = await getRecent(5); if (!Array.isArray(eps)) return 0;
const ids = new Set(Array.isArray(seen) ? seen : []), fresh = Array.isArray(seen) && seen.length ? eps.filter(e => e.id && !ids.has(e.id)) : [];
atomicWrite(SEEN(), JSON.stringify([...new Set([...eps.map(e => e.id), ...ids])].slice(-600)), { mode: 0o600 });
for (const e of fresh.slice(0, 10)) await fireAll('podcast.new', { title: e.title, show: e.show });
return fresh.length;
}
const minutes = hm => +hm.slice(0, 2) * 60 + +hm.slice(3);
async function handle(req, res) {
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/auto'.length) || '/';
try {
if (!me) throw fail(401, 'Sign in');
if (p === '/' && req.method === 'GET') return json(res, 200, { rules: load().map(({ day, doneDay, ...r }) => r) });
if (req.method !== 'POST') throw fail(404, 'Not found');
const b = JSON.parse(await readBody(req, 2e4) || '{}'), l = load();
if (p === '/') { const i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (b.id && i < 0) throw fail(404, 'That rule is gone'); if (i < 0 && l.length >= MAX_RULES) throw fail(400, 'That is the most rules (' + MAX_RULES + ')'); const r = clean(b, i >= 0 ? l[i] : null); if (i >= 0) l[i] = r; else l.push(r); save(l); return json(res, 200, { rules: l }); }
const i = l.findIndex(x => x.id === b.id); if (i < 0) throw fail(404, 'That rule is gone');
if (p === '/toggle') { l[i].on = !!b.on; save(l); return json(res, 200, { rules: l }); }
if (p === '/delete') { l.splice(i, 1); save(l); return json(res, 200, { rules: l }); }
if (p === '/test') { const ok = await run(l[i], { title: 'a test', from: 'Test', subject: 'a test' }); return json(res, 200, { ok }); }
throw fail(404, 'Not found');
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
}
module.exports = { handle, fire, fireAll, tickUser, checkEpisodes };
+2 -1
View File
@@ -116,7 +116,8 @@ async function handle(req, res) {
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
const st = state(), cur = st[id] || {};
if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() };
saveState(st); return json(res, 200, st[id]);
saveState(st); if (st[id] && st[id].pct >= 0.98) { try { require('./progress').markFinished('ebook:' + id, 'ebook'); } catch { /* the goal count is a nicety */ } }
return json(res, 200, st[id]);
}
if (part === 'notes') {
+20 -4
View File
@@ -231,6 +231,8 @@ const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b',
const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares
const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; };
const myId = () => users.current().id;
/** The colour of whoever added an event (the family calendar shows each person's own colour). */
const byCol = ev => ev && ev.by && users.get(ev.by) ? users.colorOf(users.get(ev.by)) : '';
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
function calsIn(dir) {
@@ -326,18 +328,31 @@ async function collectEvents(win) {
const mine = dirFor(myId()), evs = eventsIn(mine);
const add = (dir, evs, c, id, extra) => {
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: (extra.family && byCol(evs.find(x => x.uid === e.uid))) || over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
};
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName, family: sh.owner === FAM });
} catch (e) { errors.push('NE Hub: ' + e.message); }
try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts
return { events: all, errors };
}
/** An invitation inside the family: the event is put on each invited person's own calendar and they are told. (They can delete it if they cannot come.) */
function invite(ids, uid, fields, ok) {
if (!ok || !Array.isArray(ids) || !ids.length) return 0; const me = users.current(); let n = 0;
for (const id of new Set(ids.map(String))) {
const who = users.get(id); if (!who || who.disabled || who.id === me.id || !users.can(who, 'calendar')) continue;
const dir = dirFor(who.id), all = eventsIn(dir), cal = calsIn(dir)[0].id; if (all.some(x => x.uid === uid && evCal(x) === cal)) continue;
all.push({ uid, cal, fields, created: Date.now(), updated: Date.now(), by: me.id, byName: me.name, invitedBy: me.name }); saveEvents(dir, all); n++;
try { require('./inbox').add(who.id, { kind: 'info', title: `${me.name} invited you: ${String(fields.title || '').slice(0, 60)}`, body: String(fields.start || '').replace('T', ' ').slice(0, 16), url: '#/calendar', tag: 'invite:' + uid }); } catch { /* the event is on their calendar anyway */ }
}
return n;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x');
try {
if (u.pathname === '/api/cal/people') return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId() && users.can(x, 'calendar')).map(x => ({ id: x.id, name: x.name, color: users.colorOf(x) })));
if (u.pathname === '/api/cal/list') {
const over = colorOverrides();
return json(res, 200, [
@@ -438,7 +453,8 @@ async function handle(req, res) {
const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() };
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it)
saveEvents(r.dir, all);
return json(res, 200, { ok: true, uid });
const invited = invite(b.invite, uid, b.fields, r.dir === dirFor(myId()) || r.dir === dirFor(FAM));
return json(res, 200, { ok: true, uid, invited });
}
if (caldav.isCal(b.cal)) {
ics.validate(b.fields);
@@ -480,6 +496,6 @@ async function handle(req, res) {
/** The Family calendar's events in a window, for the family board (no signed-in person needed). */
function familyEvents(win) {
const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields })));
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: byCol(evs.find(x => x.uid === e.uid)) || FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
}
module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
+13 -13
View File
@@ -45,8 +45,8 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
- [x] **Family Home (M):** one shared start page: who is around, today's family calendar, shared shopping list and chores, what everyone is listening to or reading **Done: the Family page: calendar, chores, shopping, notes, chat and tasks on one start page.**
- [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):** **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] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand; they do not go down when you cook.**
- [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
@@ -59,28 +59,28 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
- [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.**
- [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
- [x] **Tasks 2.0 (M):** **Done: a due time with a notification at that time, Today and Upcoming views, and subtasks on iCloud lists too. Lists serve as projects.**
- [x] **Mail 2.0 (L):** **Done: an All inboxes view (newest of each account), snooze, templates, and saving an attachment to Files. Not tried against real Outlook, Gmail or iCloud accounts, only the tests.**
- [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
- [x] **Movies & TV 2.0 (L):** **Done: favorites, a shared Movie night list with votes, mixes (random, family night, short, classics) and Your stats (counts, hours, favourite genres from Plex's last-watched dates). The Plex-based ones are tested only against the counting code, not a real Plex.**
- [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.**
- [x] **Reading goals (S):** **Done: a yearly goal for books finished (audiobooks and e-books, an e-book counts at 98% read), a listening streak, a family challenge with one shared goal, and downloading 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.**
- [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
- [~] **Browser smoke tests in CI (M):** **Done: tools/frontend-test.js loads every page script in one shared scope and fails on a syntax error, a name declared twice or a script that throws as it loads. Not a real browser: nothing clicks through the screens.**
- [x] **Admin health page (S):** **Done: Settings > Admin checks Plex, Seafile, OnlyOffice, audiobooks, podcasts, Mealie, mail and storage.**
- [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
- [x] **Offline mode (L):** **Done: tasks, notes, recipes, the calendar, contacts and chores can be read with no signal (the last saved copy, kept per person); saved podcast episodes and albums already played; books can be saved on the device; adding a task, ticking one, adding to the shopping list and writing a note are kept and sent when the connection returns. Other changes still need a connection.**
- [x] **Performance pass (S):** **Done: the Bible text, the reader libraries and the video player were already loaded on demand; page scripts now load deferred. Text was already compressed and cached.**
- [x] **Accessibility (M):** **Done: text size, high contrast and reduce motion (Settings > Look and reading); focus outlines and the skip link already existed; dialogs now keep focus inside and give it back; Bible verses can be picked with the keyboard. The main screens were scanned for things only a mouse can click and that was the one gap found.**
## 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)
- [x] **Extensions 2.0 (L):** **Done: notes, recipes and what is playing in the bridge; a Permissions screen per extension; an Updated notice when an admin changes one.**
- [x] **Automations (L):** **Done: per-person rules (Settings > Automations): every day at a time, a task is finished, new mail arrives, a pantry item runs low or a new podcast episode comes out, then add a task, add to Shopping or notify.**
- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.**
## Suggested order
+2
View File
@@ -172,6 +172,7 @@ function todoLines(f, { completed = true } = {}) {
const L = ['SUMMARY:' + esc(String(f.title).trim())];
if (f.notes) L.push('DESCRIPTION:' + esc(f.notes));
if (f.due) L.push('DUE;VALUE=DATE:' + f.due.replace(/-/g, ''));
if (f.parent) L.push('RELATED-TO;RELTYPE=PARENT:' + esc(String(f.parent))); // a subtask points at its main task
if (f.done) { L.push('STATUS:COMPLETED', 'PERCENT-COMPLETE:100'); if (completed) L.push('COMPLETED:' + nowStamp()); }
else L.push('STATUS:NEEDS-ACTION');
return L;
@@ -199,6 +200,7 @@ function patchTodo(text, f) {
const name = l.split(/[;:]/)[0].toUpperCase();
if (name === 'COMPLETED') { if (f.done) { hadCompleted = true; out.push(l); } return; } // keep the original completion time
if (['SUMMARY', 'DESCRIPTION', 'DUE', 'STATUS', 'PERCENT-COMPLETE'].includes(name)) return;
if (name === 'RELATED-TO' && f.parent !== undefined && !/RELTYPE=(CHILD|SIBLING)/i.test(l)) return; // (replaced below when a parent is given; kept when none is mentioned)
if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; }
if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; }
out.push(l);
+37 -2
View File
@@ -269,6 +269,30 @@ async function overview(light) {
return { connected: shown.length > 0, accounts: shown, expired: expired && !shown.length, address: (shown[0] || {}).address, name: (shown[0] || {}).name, unread: shown.reduce((n, r) => n + (r.unread || 0), 0) };
}
// ---- Mail 2.0: snooze, templates and a unified inbox. Snoozed messages and templates are kept by the hub for each person (the mail providers know nothing of them).
// Snooze hides a message from the inbox lists until a time, then it is back and the person is told.
const snoozeFile = () => path.join(users.dir(), 'mail-snoozed.json'), tplFile = () => path.join(users.dir(), 'mail-templates.json');
const readArr = f => { try { const a = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
const snoozed = () => readArr(snoozeFile()).filter(x => x && typeof x.id === 'string' && x.until > Date.now());
const isInbox = f => /(^|~)inbox$/i.test(String(f || 'inbox'));
const hideSnoozed = (items, folder) => { if (!isInbox(folder)) return items; const hide = new Set(snoozed().map(x => x.id)); return hide.size ? items.filter(m => !hide.has(m.id)) : items; };
/** Snoozed messages whose time has come, for one person (and forgets them), so the caller can tell the person. */
function snoozeDue(uid) {
return users.runAs(users.get(uid), () => {
const all = readArr(snoozeFile()), now = Date.now(), due = all.filter(x => x.until <= now);
if (due.length) atomicWrite(snoozeFile(), JSON.stringify(all.filter(x => x.until > now)), { mode: 0o600 });
return due;
});
}
const tplClean = b => { const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the template a name'); return { name, subject: String(b.subject || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200), body: String(b.body || '').slice(0, 20000) }; };
/** The newest inbox messages of every connected account, together: one list, newest first (the first page of each account). */
async function unifiedInbox(search) {
const accs = accounts(), parts = await Promise.all(accs.map(async a => { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: search }), 12000); return r.items.map(m => ({ ...m, id: pack(a, m.id), account: a.name || a.address, accountId: a.id })); } catch { return []; } }));
return parts.flat().sort((x, y) => String(y.date || '').localeCompare(String(x.date || ''))).slice(0, 40);
}
/** An attachment's name and bytes (for saving into Files). `id` is the packed message id. */
async function attachmentOf(id, aid) { const [a, nid] = unpack(id), f = await providerOf(a).attachment(a, nid, aid); return { name: safeName(f.name), buffer: f.buffer }; }
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
try {
@@ -279,10 +303,13 @@ async function handle(req, res) {
const a = findAccount(q.get('acct') || undefined);
return json(res, 200, (await providerOf(a).folders(a)).map(f => ({ ...f, id: pack(a, f.id) })));
}
if (p === '/api/mail/messages' && q.get('folder') === 'unified') { const items = hideSnoozed(await unifiedInbox((q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100)), 'inbox'); return json(res, 200, { items, more: false, next: items.length }); }
if (p === '/api/mail/snoozed' && req.method === 'GET') return json(res, 200, snoozed());
if (p === '/api/mail/templates' && req.method === 'GET') return json(res, 200, readArr(tplFile()));
if (p === '/api/mail/messages') {
const [a, fid] = unpack(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0);
const r = await providerOf(a).list(a, { folder: fid || 'inbox', skip, q: search });
return json(res, 200, { ...r, items: r.items.map(m => ({ ...m, id: pack(a, m.id) })) });
return json(res, 200, { ...r, items: hideSnoozed(r.items.map(m => ({ ...m, id: pack(a, m.id) })), fid || 'inbox') });
}
if (p === '/api/mail/message') {
const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid);
@@ -295,6 +322,14 @@ async function handle(req, res) {
}
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const b = JSON.parse((await readBody(req)) || '{}'), me = users.current();
if (p === '/api/mail/snooze') { // hide a message from the inbox until a time (a message that cannot be found is refused)
const until = Math.round(+b.until); if (!(until > Date.now() && until < Date.now() + 366 * 864e5)) throw fail(400, 'Pick a time in the future (within a year)');
const [a, nid] = unpack(b.id), id = pack(a, nid), l = snoozed().filter(x => x.id !== id); l.push({ id, until, subject: String(b.subject || '').slice(0, 150), from: String(b.from || '').slice(0, 100) });
atomicWrite(snoozeFile(), JSON.stringify(l.slice(-500)), { mode: 0o600 }); return json(res, 200, { ok: true, until });
}
if (p === '/api/mail/unsnooze') { atomicWrite(snoozeFile(), JSON.stringify(snoozed().filter(x => x.id !== String(b.id))), { mode: 0o600 }); return json(res, 200, { ok: true }); }
if (p === '/api/mail/templates') { const t = tplClean(b), l = readArr(tplFile()), i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (i >= 0) l[i] = { ...l[i], ...t }; else { if (l.length >= 50) throw fail(400, 'That is the most templates (50)'); l.push({ id: crypto.randomBytes(4).toString('hex'), ...t }); } atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
if (p === '/api/mail/templates/delete') { const l = readArr(tplFile()).filter(x => x.id !== b.id); atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
if (p === '/api/mail/accounts') { // add a Gmail or iCloud account (an app password; checked against the server before it is saved)
const a = await imap.connectNew({ type: b.type, address: b.address, password: b.password });
const same = accounts().find(x => x.type === a.type && x.address.toLowerCase() === a.address.toLowerCase());
@@ -343,4 +378,4 @@ async function markRead(refs) {
async function health() {
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
}
module.exports = { markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
module.exports = { snoozeDue, attachmentOf, markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
+42
View File
@@ -0,0 +1,42 @@
// Movies & TV for the family: Favorites (each person's own hearted titles) and Movie night (one shared list everyone adds to and votes on, so the family picks together).
// Titles are kept as a small card (Plex id, title, year, poster path), so the lists draw without asking Plex. Favorites: users/<id>/video-favs.json; Movie night: DATA_DIR/movienight.json.
const fs = require('fs'), path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const NIGHT = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'movienight.json');
const FAVS = () => path.join(users.dir(), 'video-favs.json');
const rd = (f, d) => { try { const v = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(v) ? v : d; } catch { return d; } };
const wr = (f, v) => atomicWrite(f, JSON.stringify(v), { mode: 0o600 });
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, '').trim().slice(0, n);
/** A title card from what the browser sent: only the fields a card needs, and the poster path must be a Plex path of ours. */
function card(b) {
const id = str(b.id, 20); if (!/^\d{1,12}$/.test(id)) throw fail(400, 'Pick a movie or show');
const title = str(b.title, 160); if (!title) throw fail(400, 'That title has no name');
return { id, title, year: Math.round(+b.year) || 0, type: b.type === 'show' ? 'show' : 'movie', thumb: /^\/library\/metadata\/\d+\/thumb(\/\d+)?$/.test(str(b.thumb, 120)) ? str(b.thumb, 120) : '' };
}
const nameOf = id => (users.get(id) || {}).name || 'Someone';
const nightView = (l, me) => l.map(x => ({ ...x, byName: nameOf(x.by), votes: x.votes.length, voted: x.votes.includes(me.id), voters: x.votes.map(nameOf) })).sort((a, b) => b.votes - a.votes || a.added - b.added);
async function handle(req, res) {
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/moviefam'.length) || '/';
try {
if (!me) throw fail(401, 'Sign in');
if (p === '/night' && req.method === 'GET') return json(res, 200, { items: nightView(rd(NIGHT(), []), me) });
if (p === '/favs' && req.method === 'GET') return json(res, 200, { items: rd(FAVS(), []) });
if (req.method !== 'POST') throw fail(404, 'Not found');
const b = JSON.parse(await readBody(req, 1e4) || '{}');
if (p === '/fav') { const c = card(b), l = rd(FAVS(), []).filter(x => x.id !== c.id); if (b.on) l.unshift({ ...c, added: Date.now() }); wr(FAVS(), l.slice(0, 500)); return json(res, 200, { on: !!b.on }); }
const l = rd(NIGHT(), []);
if (p === '/night/add') { const c = card(b); if (l.some(x => x.id === c.id)) throw fail(400, c.title + ' is already on the list'); if (l.length >= 60) throw fail(400, 'The list is full; remove something first'); l.push({ ...c, by: me.id, added: Date.now(), votes: [me.id] }); wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
if (p === '/night/clear') { if (me.role !== 'admin') throw fail(403, 'Only an admin can clear the list'); wr(NIGHT(), []); return json(res, 200, { items: [] }); }
const x = l.find(y => y.id === str(b.id, 20)); if (!x) throw fail(404, 'That title is not on the list');
if (p === '/night/vote') { x.votes = x.votes.includes(me.id) ? x.votes.filter(v => v !== me.id) : [...x.votes, me.id]; wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
if (p === '/night/remove') { if (x.by !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who added it can remove it'); wr(NIGHT(), l.filter(y => y !== x)); return json(res, 200, { items: nightView(l.filter(y => y !== x), me) }); }
throw fail(404, 'Not found');
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
}
module.exports = { handle };
+11 -5
View File
@@ -13,7 +13,7 @@ const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const MAX_FILE = 1024 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_EXT = 2 * 1024 * 1024, MAX_STATE = 256 * 1024, MAX_FILES_PER_ITEM = 200;
const KINDS = ['book', 'music', 'app', 'extension', 'link'];
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write', 'notes.read', 'notes.write', 'recipes.read', 'music.read']; // what an extension may ask the person for
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw3: 'application/vnd.amazon.mobi8-ebook', azw: 'application/vnd.amazon.ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', html: 'text/html; charset=utf-8', apk: 'application/vnd.android.package-archive', zip: 'application/zip', dmg: 'application/x-apple-diskimage', exe: 'application/vnd.microsoft.portable-executable', pkg: 'application/octet-stream', txt: 'text/plain; charset=utf-8' };
@@ -37,7 +37,7 @@ const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.cre
// ---- the per-person bits: which extensions are installed, and each extension's saved settings
const stateFile = () => path.join(users.dir(), 'mystore.json');
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } };
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {}, seen: j.seen && typeof j.seen === 'object' ? j.seen : {} }; } catch { return { installed: [], data: {}, grants: {}, seen: {} }; } };
const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 });
/** Stream a stored file, with Range support so audio can be skipped through. */
@@ -84,14 +84,17 @@ function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[
window.hub={storage:{get:function(k){return call('storage.get',[k])},set:function(k,v){return call('storage.set',[k,v])},all:function(){return call('storage.all')},remove:function(k){return call('storage.remove',[k])}},toast:function(t){return call('toast',[String(t)])},user:function(){return call('user')},open:function(u){return call('open',[String(u)])},
tasks:{lists:function(){return call('tasks.lists')},list:function(l){return call('tasks.list',[{list:l}])},add:function(o){return call('tasks.add',[o])},complete:function(l,id){return call('tasks.complete',[{list:l,id:id}])}},
contacts:{list:function(){return call('contacts.list')},add:function(o){return call('contacts.add',[o])}},
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}}}})()</script>`;
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}},
notes:{list:function(){return call('notes.list')},add:function(o){return call('notes.add',[o])}},
recipes:{search:function(q){return call('recipes.search',[{q:q}])},get:function(slug){return call('recipes.get',[{slug:slug}])}},
music:{nowPlaying:function(){return call('music.nowPlaying')}}}})()</script>`;
const withBridge = html => /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, m => m + BRIDGE) : BRIDGE + html;
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/store/my'.length) || '/', me = users.current();
try {
let m;
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, admin: !!me && me.role === 'admin' });
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, seen: stateOf().seen, admin: !!me && me.role === 'admin' });
if (p === '/items' && req.method === 'POST') { // add or change an item's details (admin)
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems(), old = b.id ? l.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Item not found'); if (!old && !KINDS.includes(b.kind)) throw fail(400, 'Choose what kind of thing this is');
@@ -148,7 +151,10 @@ async function handle(req, res) {
}
if ((m = /^\/ext\/([0-9a-f]{12})\/(install|uninstall)$/.exec(p)) && req.method === 'POST') {
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found');
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') s.installed.push(it.id); if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') { s.installed.push(it.id); s.seen[it.id] = it.updated || 0; } if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; delete s.seen[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
}
if ((m = /^\/ext\/([0-9a-f]{12})\/seen$/.exec(p)) && req.method === 'POST') { // the person has looked at this version of the extension (so it stops saying it was updated)
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf(); if (s.installed.includes(it.id)) { s.seen[it.id] = it.updated || 0; saveState(s); } return json(res, 200, { seen: s.seen });
}
if ((m = /^\/ext\/([0-9a-f]{12})\/grants$/.exec(p))) { // what the person has allowed this extension to reach in the hub
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+13 -2
View File
@@ -13,7 +13,7 @@ const stats = () => {
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 };
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 => day(f.t).startsWith(year)).length, audiobooksYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, ebooksYear: Object.values(o.finished).filter(f => f.kind === 'ebook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length };
};
module.exports = (name, kind) => {
const file = () => path.join(users.dir(), name);
@@ -31,4 +31,15 @@ module.exports = (name, kind) => {
};
return { load, merge };
};
module.exports.stats = stats;
/** A book (or e-book) finished: counts toward the yearly goals. Once only. */
function markFinished(key, kind, t = Date.now()) { const log = loadLog(); if (log.finished[key]) return; log.finished[key] = { t, kind }; const lf = logFile(), tmp = lf + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(tmp, lf); }
// The family challenge: one yearly goal for everyone, counted across everybody's finished books.
const goalFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'readgoal.json');
function familyGoal() {
const year = new Date().getFullYear(); let goal = 0; try { const g = JSON.parse(fs.readFileSync(goalFile(), 'utf8')); if (g.year === year) goal = +g.goal || 0; } catch { /* none set */ }
let finished = 0; const people = [];
for (const u of users.all()) { if (u.disabled) continue; let log = { finished: {} }; try { log = JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'listening.json'), 'utf8')); } catch { /* nothing yet */ } const n = Object.values(log.finished || {}).filter(f => day(f.t).startsWith(String(year))).length; finished += n; if (n) people.push({ name: u.name, n }); }
return { year, goal, finished, people: people.sort((a, b) => b.n - a.n) };
}
function setFamilyGoal(goal) { const g = Math.max(0, Math.min(1000, Math.round(+goal) || 0)); fs.mkdirSync(path.dirname(goalFile()), { recursive: true }); fs.writeFileSync(goalFile(), JSON.stringify({ year: new Date().getFullYear(), goal: g })); return familyGoal(); }
module.exports.stats = stats; module.exports.markFinished = markFinished; module.exports.familyGoal = familyGoal; module.exports.setFamilyGoal = setFamilyGoal;
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// ---- Automations (Settings): "when this happens, do that" rules for you. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to
// the Shopping list, or send yourself a notification. {title}, {from} and {subject} in the text are filled in from what happened.
const auApi = (p, o) => jfetch('/api/auto' + p, o), auOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const AU_WHEN = [['time', 'Every day at a time'], ['task.done', 'A task is finished'], ['mail.new', 'New mail arrives'], ['pantry.low', 'A pantry item runs low'], ['podcast.new', 'A new podcast episode comes out']], AU_THEN = [['task', 'Add a task'], ['shopping', 'Add to the Shopping list'], ['notify', 'Notify me']];
const auSummary = r => `${r.when.type === 'time' ? 'Every day at ' + r.when.at : r.when.type === 'task.done' ? 'When a task' + (r.when.match ? ' with “' + r.when.match + '”' : '') + ' is finished' : r.when.type === 'pantry.low' ? 'When ' + (r.when.match ? '“' + r.when.match + '”' : 'a pantry item') + ' runs low' : r.when.type === 'podcast.new' ? 'When a new episode of “' + r.when.match + '” comes out' : 'When mail from or about “' + r.when.match + '” arrives'} → ${r.then.type === 'task' ? 'add the task' : r.then.type === 'shopping' ? 'add to Shopping' : 'notify me'} “${r.then.text}”`;
function autoSettings(el) {
if (!el) return;
const draw = rules => {
el.innerHTML = `<h2>Automations</h2><p class="sub">Little rules that run for you. Each can run up to 20 times a day.</p>
<div class="list">${rules.map(r => `<div class="row" style="align-items:flex-start"><label class="grow" style="cursor:pointer;white-space:normal"><b>${esc(r.name)}</b><small style="white-space:normal">${esc(auSummary(r))}${r.runs ? ' · ran ' + r.runs + ' time' + (r.runs === 1 ? '' : 's') : ''}</small></label>
<input type="checkbox" data-on="${esc(r.id)}" aria-label="Turn ${esc(r.name)} on or off"${r.on ? ' checked' : ''}><button class="ibtn" data-e="${esc(r.id)}" aria-label="Edit ${esc(r.name)}" title="Edit">${icon('edit')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>No automations yet.</small></span></div>'}</div>
<div class="bar"><button class="btn ghost" id="auadd">${icon('plus')}Add an automation</button></div>`;
el.querySelector('#auadd').onclick = () => edit(null);
el.querySelectorAll('[data-on]').forEach(c => c.onchange = async () => { try { draw((await auApi('/toggle', auOpt({ id: c.dataset.on, on: c.checked }))).rules); } catch (e) { toast(e.message); c.checked = !c.checked; } });
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => edit(rules.find(r => r.id === b.dataset.e)));
};
const edit = r => {
const w = r ? r.when : { type: 'time', at: '08:00', match: '' }, t = r ? r.then : { type: 'task', text: '' };
const sh = openSheet(r ? 'Edit automation' : 'New automation', '', `<form id="auf"><label for="aun">Name (optional)</label><input type="text" id="aun" value="${esc(r ? r.name : '')}" autocomplete="off">
<label for="auw">When</label><select id="auw">${AU_WHEN.map(([v, l]) => `<option value="${v}"${v === w.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
<div id="auwd"></div><label for="aut">Then</label><select id="aut">${AU_THEN.map(([v, l]) => `<option value="${v}"${v === t.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
<label for="aux" id="auxl"></label><input type="text" id="aux" value="${esc(t.text)}" autocomplete="off" maxlength="200"><p class="sub" style="margin:4px 0 0">You can use {title} (the task, item or episode), {from} and {show}.</p><div class="err" id="aue"></div>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button>${r ? '<button type="button" class="btn ghost" id="autest">Test</button><button type="button" class="btn danger" id="aude">Delete</button>' : ''}</div></form>`);
const label = () => { sh.querySelector('#auxl').textContent = sh.querySelector('#aut').value === 'notify' ? 'Message' : 'Task'; };
const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `<label for="aua">At</label><input type="time" id="aua" value="${esc(w.at || '08:00')}" required>` : `<label for="aum">${ty === 'mail.new' ? 'Sender or subject contains' : ty === 'podcast.new' ? 'Show name contains' : ty === 'pantry.low' ? 'Item name contains (blank: any item)' : 'Task title contains (blank: any task)'}</label><input type="text" id="aum" value="${esc(w.type === ty ? w.match || '' : '')}" autocomplete="off"${ty === 'mail.new' || ty === 'podcast.new' ? ' required' : ''}>`; label(); };
detail(); label(); sh.querySelector('#auw').onchange = detail; sh.querySelector('#aut').onchange = label;
const body = () => { const ty = sh.querySelector('#auw').value; return { id: r && r.id, name: sh.querySelector('#aun').value, on: r ? r.on : true, when: { type: ty, at: ty === 'time' ? sh.querySelector('#aua').value : undefined, match: ty === 'time' ? '' : sh.querySelector('#aum').value }, then: { type: sh.querySelector('#aut').value, text: sh.querySelector('#aux').value } }; };
sh.querySelector('#auf').onsubmit = async e => { e.preventDefault(); try { const l = (await auApi('/', auOpt(body()))).rules; closeSheet(); draw(l); toast('Saved'); } catch (er) { sh.querySelector('#aue').textContent = er.message; } };
if (r) {
sh.querySelector('#autest').onclick = async () => { try { const o = await auApi('/test', auOpt({ id: r.id })); toast(o.ok ? 'Ran it once' : 'Did not run (the daily limit?)'); } catch (e) { toast(e.message); } };
sh.querySelector('#aude').onclick = () => confirmSheet('Delete this automation?', r.name, 'Delete', async () => { try { draw((await auApi('/delete', auOpt({ id: r.id }))).rules); } catch (e) { toast(e.message); } });
}
};
auApi('/').then(r => draw(r.rules)).catch(() => {});
}
+5 -3
View File
@@ -46,8 +46,8 @@ function bbChapterHtml(book, c) {
else if (b[0] === 'p') flush();
else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; }
else if (b[0] === 'v') {
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
else para += `<span class="vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}" tabindex="0" role="button">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
else para += `<span class="vs" data-v="${b[1]}" tabindex="0" role="button">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
}
}
flush(); return html;
@@ -81,6 +81,7 @@ async function viewBible(args = []) {
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
const root = $('#bbtext'); bbPaint(root);
if (vs) { const el = root.querySelector(`.vs[data-v="${vs}"]`); if (el) { setTimeout(() => el.scrollIntoView({ block: 'center' }), 50); el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 2200); } }
root.addEventListener('keydown', e => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('.vs')) { e.preventDefault(); e.target.click(); } }); // (a verse is picked with the keyboard too)
root.addEventListener('click', e => {
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), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); 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);
@@ -155,8 +156,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 = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1></div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
$('#view').innerHTML = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1><span class="grow"></span>${list.length ? `<button class="btn ghost" id="bbexp">Download</button>` : ''}</div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
${m.type === 'note' ? `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>` : ''}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>` : '<p class="empty">Nothing yet. Tap verses while reading to highlight them or add a note.</p>'}</div>`;
const bx = $('#bbexp'); if (bx) bx.onclick = () => downloadText('Bible highlights and notes.md', '# Bible highlights and notes\n\n' + list.map(m => `**${bbRef(m.b, m.c, m.v)}**${m.type === 'note' ? ' (note)' : ''}\n${m.text ? '> ' + String(m.text).replace(/\n+/g, ' ') + '\n' : ''}${m.note ? '\n' + m.note + '\n' : ''}`).join('\n') + '\nBerean Standard Bible (BSB), public domain.\n');
document.querySelectorAll('.bbm').forEach(r => { const m = list.find(x => x.id === r.dataset.id);
r.querySelector('.bbmj').onclick = () => { location.hash = `#/bible/${m.b}/${m.c}/${m.v}`; };
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => bbNoteDialog(m);
+9 -1
View File
@@ -29,7 +29,15 @@
let lastBadge = Date.now(); badgePoll();
setInterval(() => { if (document.visibilityState === 'visible') { lastBadge = Date.now(); badgePoll(); } }, 2 * 60e3);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - lastBadge > 30e3) { lastBadge = Date.now(); badgePoll(); } });
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
// tell the service worker who is signed in, so what it saves for offline use is kept for that person only
const who = () => navigator.serviceWorker.ready.then(r => (r.active || navigator.serviceWorker.controller || {}).postMessage && (r.active || navigator.serviceWorker.controller).postMessage({ type: 'who', id: ME ? ME.id : '' })).catch(() => {});
who(); navigator.serviceWorker.addEventListener('controllerchange', who);
}
// offline: say so once, and that what is on screen is the last saved copy
{ const note = () => { const el = document.getElementById('offnote') || Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); el.textContent = 'You are offline. Showing what was last saved; changes need a connection.'; el.hidden = navigator.onLine; if (!el.isConnected) document.body.appendChild(el); if (typeof offqNote === 'function') offqNote(); };
addEventListener('offline', note); addEventListener('online', note); if (!navigator.onLine) note(); }
})();
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
+6 -2
View File
@@ -126,6 +126,8 @@ function openEvent(e) {
}
if (e.contactId) { // a birthday from Contacts
const el = openSheet(e.title, 'Birthdays', `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button>${allowed('contacts') ? '<a class="btn" id="bco" style="flex:1;justify-content:center">Open contact</a>' : ''}</div>`);
const inv = el.querySelector('#finv'), invOk = () => { const c = (o.cals || []).find(x => x.id === el.querySelector('#fc').value); return !!c && !c.icloud && !(c.shared && !String(c.id).startsWith('family~')); };
if (inv) jfetch('/api/cal/people').then(ps => { if (!ps.length || !el.isConnected) return; inv.innerHTML = `<label>Invite</label><div class="invlist">${ps.map(p => `<label class="chk"><input type="checkbox" data-inv="${esc(p.id)}"><i class="invdot" style="background:${esc(p.color)}"></i>${esc(p.name)}</label>`).join('')}</div><p class="sub" id="invnote" style="margin:4px 0 0">The event is added to their calendar and they are told.</p>`; const sync = () => { inv.hidden = !invOk(); }; sync(); el.querySelector('#fc').addEventListener('change', sync); }).catch(() => {});
el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; };
return;
}
@@ -169,6 +171,7 @@ function eventForm(o) {
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
${calPick}
${editing ? '' : '<div id="finv"></div>'}
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
@@ -200,9 +203,10 @@ function eventForm(o) {
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
const cal = editing ? o.calId : el.querySelector('#fc').value;
try {
await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
const invite = editing || (inv && inv.hidden) ? [] : [...el.querySelectorAll('[data-inv]:checked')].map(c => c.dataset.inv);
const sent = await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields, invite }) });
if (!editing) store.set('cal_default', cal);
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
closeSheet(); toast(editing ? 'Event saved' : sent && sent.invited ? `Event added, ${sent.invited} invited` : 'Event added'); viewCalendar();
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
};
const del = el.querySelector('#fd');
+1 -1
View File
@@ -5,7 +5,7 @@ const dvDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1)
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());
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, ' ').replace(/\s+([,.;:!?])/g, '$1').trim());
return out.join(' ');
}
function dvVerseOfDay() {
+59 -57
View File
@@ -52,62 +52,64 @@
<div id="main"><div id="navbar" hidden></div><div id="subnav" class="seg" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
</div>
<div id="toast" role="status"></div>
<script src="/webauthn.js"></script>
<script src="/links.js"></script>
<script src="/palette.js"></script>
<script src="/planner.js"></script>
<script src="/reminders.js"></script>
<script src="/file-history.js"></script>
<script src="/music-extras.js"></script>
<script src="/music-more.js"></script>
<script src="/screentime-view.js"></script>
<script src="/calendar-views.js"></script>
<script src="/app.js"></script>
<script src="/search-view.js"></script>
<script src="/today.js"></script>
<script src="/calendar.js"></script>
<script src="/tasks.js"></script>
<script src="/audiobooks.js"></script>
<script src="/music.js"></script>
<script src="/video.js"></script>
<script src="/office.js"></script>
<script src="/player.js"></script>
<script src="/offline.js"></script>
<script src="/podcast-view.js"></script>
<script src="/reader.js"></script>
<script src="/audiobooks-view.js"></script>
<script src="/librivox-view.js"></script>
<script src="/radio-view.js"></script>
<script src="/store-view.js"></script>
<script src="/recipes-view.js"></script>
<script src="/contacts-view.js"></script>
<script src="/bible-view.js"></script>
<script src="/devotion-view.js"></script>
<script src="/save-to-files.js"></script>
<script src="/vbv-view.js"></script>
<script src="/lyrics-view.js"></script>
<script src="/visualizer.js"></script>
<script src="/settings.js"></script>
<script src="/files.js"></script>
<script src="/files-extra.js"></script>
<script src="/sheets.js"></script>
<script src="/file-actions.js"></script>
<script src="/signal-view.js"></script>
<script src="/chat-view.js"></script>
<script src="/notes-view.js"></script>
<script src="/chores-view.js"></script>
<script src="/family-view.js"></script>
<script src="/mail.js"></script>
<script src="/share.js"></script>
<script src="/cast.js"></script>
<script src="/rows.js"></script>
<script src="/keys.js"></script>
<script src="/inbox-view.js"></script>
<script src="/device-ui.js"></script>
<script src="/settings-family.js"></script>
<script src="/simple-mode.js"></script>
<script src="/themes.js"></script>
<script src="/boot.js"></script>
<script src="/shell.js"></script>
<script defer src="/webauthn.js"></script>
<script defer src="/links.js"></script>
<script defer src="/palette.js"></script>
<script defer src="/planner.js"></script>
<script defer src="/reminders.js"></script>
<script defer src="/file-history.js"></script>
<script defer src="/music-extras.js"></script>
<script defer src="/music-more.js"></script>
<script defer src="/screentime-view.js"></script>
<script defer src="/calendar-views.js"></script>
<script defer src="/app.js"></script>
<script defer src="/search-view.js"></script>
<script defer src="/today.js"></script>
<script defer src="/calendar.js"></script>
<script defer src="/tasks.js"></script>
<script defer src="/audiobooks.js"></script>
<script defer src="/music.js"></script>
<script defer src="/offline-queue.js"></script>
<script defer src="/video.js"></script>
<script defer src="/office.js"></script>
<script defer src="/player.js"></script>
<script defer src="/offline.js"></script>
<script defer src="/podcast-view.js"></script>
<script defer src="/reader.js"></script>
<script defer src="/audiobooks-view.js"></script>
<script defer src="/librivox-view.js"></script>
<script defer src="/radio-view.js"></script>
<script defer src="/store-view.js"></script>
<script defer src="/recipes-view.js"></script>
<script defer src="/contacts-view.js"></script>
<script defer src="/bible-view.js"></script>
<script defer src="/devotion-view.js"></script>
<script defer src="/save-to-files.js"></script>
<script defer src="/vbv-view.js"></script>
<script defer src="/lyrics-view.js"></script>
<script defer src="/visualizer.js"></script>
<script defer src="/settings.js"></script>
<script defer src="/files.js"></script>
<script defer src="/files-extra.js"></script>
<script defer src="/sheets.js"></script>
<script defer src="/file-actions.js"></script>
<script defer src="/signal-view.js"></script>
<script defer src="/chat-view.js"></script>
<script defer src="/notes-view.js"></script>
<script defer src="/chores-view.js"></script>
<script defer src="/family-view.js"></script>
<script defer src="/mail.js"></script>
<script defer src="/share.js"></script>
<script defer src="/cast.js"></script>
<script defer src="/rows.js"></script>
<script defer src="/keys.js"></script>
<script defer src="/inbox-view.js"></script>
<script defer src="/device-ui.js"></script>
<script defer src="/settings-family.js"></script>
<script defer src="/simple-mode.js"></script>
<script defer src="/themes.js"></script>
<script defer src="/automations-view.js"></script>
<script defer src="/boot.js"></script>
<script defer src="/shell.js"></script>
</body>
</html>
+40 -10
View File
@@ -25,7 +25,7 @@ const mailWhen = iso => {
if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' });
};
const mailFolderName = id => (mail.folders.find(f => f.id === id) || {}).name || (/~inbox$/.test(id) || id === 'inbox' ? 'Inbox' : 'Folder');
const mailFolderName = id => id === 'unified' ? 'All inboxes' : (mail.folders.find(f => f.id === id) || {}).name || (/~inbox$/.test(id) || id === 'inbox' ? 'Inbox' : 'Folder');
/** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */
function mailDoc(html, images) {
@@ -176,9 +176,10 @@ async function viewMail(args = []) {
const [kind, key] = args;
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
const want = idAcct(kind === 'f' ? key : kind === 'm' ? key : '') || (mail.accts.some(a => a.id === mail.acct) ? mail.acct : mail.accts[0].id);
if (want !== mail.acct) { mail.acct = want; mail.folders = []; mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; }
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key;
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
const keepUni = mail.folder === 'unified' && kind === 'm'; // reading a message from the all-inboxes list keeps that list behind it
if (want !== mail.acct) { mail.acct = want; mail.folders = []; if (!keepUni) { mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; } }
if (kind === 'f' && key) mail.folder = key === 'unified' ? 'unified' : idAcct(key) ? key : mail.acct + '~' + key;
else if (mail.folder !== 'unified' && !String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
if (!mail.folders.length) { const f = mcGet('f:' + mail.acct); if (f) mail.folders = f.folders; }
mailShell(kind === 'm' && key ? 'read' : 'list');
const acct = mail.acct;
@@ -189,8 +190,8 @@ async function viewMail(args = []) {
mailPoll().then(() => { if (mail.st && location.hash.startsWith('#/mail')) mailRefreshFolders(); });
}
/** One button per account with its own unread count, so you can see which one has new mail without switching. */
const mailChips = () => `<div class="maccts" role="tablist" aria-label="Accounts">${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct}" class="${a.id === mail.acct ? 'on' : ''}${a.unread ? ' new' : ''}"><span class="mal">${esc(a.name || a.address)}</span><small>${esc(a.name ? a.address : '')}</small>${a.unread ? `<i class="nbadge" style="position:static">${a.unread > 99 ? '99+' : a.unread}</i>` : ''}</button>`).join('')}</div>`;
function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === mail.acct) return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
const mailChips = () => `<div class="maccts" role="tablist" aria-label="Accounts">${mail.accts.length > 1 ? `<button type="button" role="tab" data-a="unified" aria-selected="${mail.folder === 'unified'}" class="${mail.folder === 'unified' ? 'on' : ''}"><span class="mal">All inboxes</span><small>every account</small></button>` : ''}${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct && mail.folder !== 'unified'}" class="${a.id === mail.acct && mail.folder !== 'unified' ? 'on' : ''}${a.unread ? ' new' : ''}"><span class="mal">${esc(a.name || a.address)}</span><small>${esc(a.name ? a.address : '')}</small>${a.unread ? `<i class="nbadge" style="position:static">${a.unread > 99 ? '99+' : a.unread}</i>` : ''}</button>`).join('')}</div>`;
function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === 'unified') { mail.q = ''; location.hash = '#/mail/f/unified'; return; } if (b.dataset.a === mail.acct && mail.folder !== 'unified') return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
function mailDrawChips() { if (mail.accts.length < 2) return; document.querySelectorAll('.maccts').forEach(el => el.outerHTML = mailChips()); mailBindChips(); }
/** Redraw the folder pickers (names and unread counts) without touching the list or the open message. */
function mailRefreshFolders() {
@@ -426,7 +427,7 @@ document.addEventListener('touchstart', e => { const a = e.target.closest && e.t
// What this device has just done to a message (read, unread), kept for a minute: a list that was already on its way from the server
// (it started before the change) must not put the old state back.
const mailMine = new Map();
const mailInInbox = () => /(^|~)inbox$/.test(mail.folder);
const mailInInbox = () => /(^|~)inbox$|^unified$/.test(mail.folder);
function mailSetRead(id, read) {
mailMine.set(id, { read, t: Date.now() });
const it = mail.items.find(x => x.id === id); if (it) it.read = read;
@@ -467,14 +468,15 @@ function mailDrawReader() {
<p class="mto"><small>To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></p>
<div class="mact">
<button class="btn" data-a="reply">Reply</button><button class="btn ghost" data-a="replyAll">Reply all</button><button class="btn ghost" data-a="forward">Forward</button>
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="snooze">Snooze</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>${allowed('files') ? `<button class="btn ghost" data-att="${esc(a.id)}" aria-label="Save ${esc(a.name)} to Files" title="Save to Files">${icon('files')}</button>` : ''}`).join('')}</div>` : ''}
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
<iframe class="mbody" title="Message" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
mailFit(rd.querySelector('.mbody'));
if (typeof ctMailHook === 'function') ctMailHook(rd, m);
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
rd.querySelectorAll('[data-att]').forEach(b => b.onclick = () => { const at = m.attachments.find(x => x.id === b.dataset.att); if (at) saveToFiles(at.name, { kind: 'mail-attachment', id: m.id, aid: at.id }); });
}
async function mailAction(a) {
@@ -492,6 +494,7 @@ async function mailAction(a) {
try {
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
if (a === 'archive') return away('move', { to: 'archive' }, 'Archived');
if (a === 'snooze') return mailSnoozeSheet(m, async until => { await post('snooze', { until, subject: m.subject, from: mailFrom(m.from) }); toast('Snoozed until ' + new Date(until).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })); back(); });
if (a === 'unread') { await post('update', { read: false }); m.read = false; mailSetRead(m.id, false); toast('Marked unread'); if (mailInInbox()) { mailUnread++; renderNav(); } return back(true); }
if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); }
if (a === 'delete') {
@@ -510,11 +513,12 @@ function mailCompose(o = {}) {
${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `${mail.accts.length > 1 ? `<label for="mfrom">From</label><select id="mfrom">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`}
${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div>
<label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`}
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><div id="mtpl" class="bar" style="margin:0 0 6px;justify-content:flex-start"></div><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
${reply ? '' : `<div class="bar" style="margin:8px 0 0;justify-content:flex-start"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('clip')}Attach<input type="file" id="mfile" multiple hidden></label><small id="mfl" style="color:var(--muted)"></small></div>`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="msend" style="flex:1;justify-content:center">Send</button></div><div class="err" id="mer"></div></form>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
if (o.to && el.querySelector('#mto')) el.querySelector('#mto').value = o.to;
mailTemplates(el);
if (typeof ctSuggest === 'function') for (const id of ['#mto', '#mcc2', '#mbcc']) ctSuggest(el.querySelector(id));
const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; };
const fi = el.querySelector('#mfile');
@@ -534,3 +538,29 @@ function mailCompose(o = {}) {
};
setTimeout(() => { const f = el.querySelector('#mto') || el.querySelector('#mbd'); if (f) f.focus(); }, 60);
}
/** Pick when a message comes back to the inbox. */
function mailSnoozeSheet(m, go) {
const at = (days, h) => { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(h, 0, 0, 0); return d.getTime(); }, now = new Date(), sat = (6 - now.getDay() + 7) % 7 || 7, mon = (8 - now.getDay()) % 7 || 7;
const opts = [...(now.getHours() < 17 ? [['Later today', at(0, 17)]] : []), ['Tomorrow morning', at(1, 8)], ['This weekend', at(sat, 9)], ['Next week', at(mon, 8)]];
const el = openSheet('Snooze', m.subject, `<div class="list">${opts.map(([l, t], i) => `<a href="#" class="row" data-i="${i}"><span class="grow"><b>${l}</b><small>${new Date(t).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</small></span></a>`).join('')}</div>
<form id="msf" class="bar" style="flex-wrap:nowrap;margin-top:12px"><input type="datetime-local" id="mst" style="flex:1" aria-label="Pick a date and time"><button class="btn ghost">Pick</button></form><div class="err" id="mse"></div>`);
const run = async t => { try { closeSheet(); await go(t); } catch (e) { toast(e.message); } };
el.querySelectorAll('[data-i]').forEach(a => a.onclick = e => { e.preventDefault(); run(opts[+a.dataset.i][1]); });
el.querySelector('#msf').onsubmit = e => { e.preventDefault(); const t = Date.parse(el.querySelector('#mst').value); if (!(t > Date.now())) { el.querySelector('#mse').textContent = 'Pick a time in the future.'; return; } run(t); };
}
/** Templates in a new message: insert one, or save what is typed as a template. */
function mailTemplates(el) {
const bar = el.querySelector('#mtpl'); if (!bar) return;
const draw = list => {
bar.innerHTML = `<button type="button" class="btn ghost" id="mtp">Templates${list.length ? ' (' + list.length + ')' : ''}</button><button type="button" class="btn ghost" id="mts">Save as template</button>`;
bar.querySelector('#mtp').onclick = () => { if (!list.length) return toast('No templates yet. Write a message, then Save as template.');
const sh = openSheet('Templates', 'Tap one to use it', `<div class="list">${list.map(t => `<div class="row"><a href="#" class="grow" data-use="${esc(t.id)}"><b>${esc(t.name)}</b><small>${esc(t.subject || t.body.slice(0, 60))}</small></a><button class="ibtn" data-del="${esc(t.id)}" aria-label="Delete ${esc(t.name)}">${icon('trash')}</button></div>`).join('')}</div>`);
sh.querySelectorAll('[data-use]').forEach(a => a.onclick = e => { e.preventDefault(); const t = list.find(x => x.id === a.dataset.use); closeSheet(); const sb = el.querySelector('#msb'), bd = el.querySelector('#mbd'); if (sb && t.subject && !sb.value) sb.value = t.subject; bd.value = bd.value ? bd.value + '\n' + t.body : t.body; bd.focus(); });
sh.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { try { const l = await jfetch('/api/mail/templates/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.del }) }); closeSheet(); draw(l); toast('Template deleted'); } catch (e) { toast(e.message); } }); };
bar.querySelector('#mts').onclick = () => { const body = el.querySelector('#mbd').value.trim(); if (!body) return toast('Write the message first');
const sh = openSheet('Save as template', '', `<form id="mtf"><label for="mtn">Name</label><input type="text" id="mtn" autocomplete="off" required><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
sh.querySelector('#mtf').onsubmit = async e => { e.preventDefault(); try { const l = await jfetch('/api/mail/templates', { method: 'POST', body: JSON.stringify({ name: sh.querySelector('#mtn').value, subject: (el.querySelector('#msb') || {}).value || '', body }) }); closeSheet(); draw(l); toast('Template saved'); } catch (er) { toast(er.message); } }; };
};
jfetch('/api/mail/templates').then(draw).catch(() => {});
}
+2 -1
View File
@@ -1,7 +1,8 @@
'use strict';
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
const jfetch = async (path, opts) => {
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
let r; try { r = await fetch(path, opts); } catch (e) { const q = typeof offQueued === 'function' && offQueued(path, opts); if (q) return q; throw e; } // (a few kinds of change are kept for later when there is no signal)
const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
return j;
};
+39
View File
@@ -0,0 +1,39 @@
'use strict';
// ---- Changes made with no signal: adding a task, ticking one off, adding to the shopping list and writing a note are kept on this device and sent when the connection is back.
// Only these few safe changes are queued (nothing that could clash with what someone else did meanwhile); anything else says it needs a connection. Tasks show the waiting changes.
const OFFQ_RULES = [[/^\/api\/tasks$/, b => !b.id && String(b.list || '').startsWith('local:')], [/^\/api\/tasks\/toggle$/, b => String(b.list || '').startsWith('local:')], [/^\/api\/recipes\/shopping\/add$/, () => true], [/^\/api\/notes\/$/, b => !b.id]];
const offqKey = () => 'offq:' + (typeof ME !== 'undefined' && ME ? ME.id : '');
const offqRead = () => { try { const a = JSON.parse(localStorage.getItem(offqKey())); return Array.isArray(a) ? a : []; } catch { return []; } };
const offqWrite = l => { try { if (l.length) localStorage.setItem(offqKey(), JSON.stringify(l)); else localStorage.removeItem(offqKey()); } catch { /* storage full or blocked */ } offqNote(); };
const offPending = () => offqRead().length;
function offqNote() { const n = offPending(); let el = document.getElementById('offnote'); if (!el && n) { el = Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); document.body.appendChild(el); } if (!el) return; if (n || !navigator.onLine) { el.hidden = false; el.textContent = (navigator.onLine ? '' : 'You are offline. Showing what was last saved. ') + (n ? n + ' change' + (n === 1 ? '' : 's') + ' waiting to send.' : 'Changes need a connection.'); } else el.hidden = true; }
/** Called by jfetch when a request failed on the network: keep it for later if it is one of the safe kinds. Returns the answer to give, or null (the error stands). */
function offQueued(path, opts) {
const method = String((opts && opts.method) || 'GET').toUpperCase(); if (method !== 'POST' || typeof (opts && opts.body) !== 'string') return null;
let b; try { b = JSON.parse(opts.body); } catch { return null; } const rule = OFFQ_RULES.find(r => r[0].test(path)); if (!rule || !b || !rule[1](b)) return null;
const l = offqRead(); if (l.length >= 100) return null; l.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), path, body: opts.body, t: Date.now() }); offqWrite(l);
toast('Saved on this device. It sends when you are back online.'); return { ok: true, queued: true };
}
let offqBusy = false;
/** Send what is waiting, oldest first; stops at the first network failure, and drops a change the server refuses (saying so). */
async function offqFlush() {
if (offqBusy || !navigator.onLine) return; offqBusy = true; let sent = 0;
try {
for (;;) {
const l = offqRead(); if (!l.length) break; const it = l[0];
let r; try { r = await fetch(it.path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: it.body }); } catch { break; }
if (!r.ok && r.status >= 500) break; // (the server is having trouble: try again later)
offqWrite(offqRead().filter(x => x.id !== it.id)); if (r.ok) sent++; else { const j = await r.json().catch(() => ({})); toast('A change made offline was not accepted: ' + (j.error || r.status)); }
}
} finally { offqBusy = false; }
if (sent) { toast(sent + ' change' + (sent === 1 ? '' : 's') + ' sent'); if (/^#\/(tasks|planner)/.test(location.hash) && typeof route === 'function') route(); }
}
/** What the Tasks list should show while changes are waiting: queued ticks applied, and queued new tasks added (marked as waiting). */
function offApplyTasks(tasks) {
const q = offqRead(); if (!q.length) return tasks; const out = tasks.map(t => ({ ...t }));
for (const it of q) { let b; try { b = JSON.parse(it.body); } catch { continue; }
if (/toggle$/.test(it.path)) { const t = out.find(x => x.id === b.id); if (t) t.done = !!b.done; }
else if (it.path === '/api/tasks' && b.fields) out.push({ id: 'queued:' + it.id, list: b.list, title: b.fields.title, notes: b.fields.notes || '', due: b.fields.due || '', dueTime: b.fields.dueTime || '', done: !!b.fields.done, parent: '', repeat: 'none', _ln: 'waiting to send' }); }
return out;
}
addEventListener('online', () => { offqNote(); setTimeout(offqFlush, 800); }); addEventListener('offline', offqNote); setTimeout(offqFlush, 4000); setInterval(offqFlush, 60e3);
+18 -2
View File
@@ -77,11 +77,26 @@ async function rdLibrary() {
$('#rdhint').insertAdjacentHTML('beforeend', ' Press and hold, or right-click, a book for more.');
}
function rdBookMenu(b) {
const rdOffKey = id => `/api/read/book/${id}/file`;
const rdSaved = async id => { try { return !!(await (await caches.open('hub-offline')).match(rdOffKey(id))); } catch { return false; } };
/** Keep a book (and what the reader needs to open it) on this device, so it opens with no signal. */
async function rdSaveOffline(b) {
if (!('caches' in window)) return toast('This browser cannot save for offline');
try {
toast('Saving “' + b.title + '”…'); const c = await caches.open('hub-offline'), r = await fetch(rdOffKey(b.id) + '?inline=1'); if (!r.ok) throw new Error('Could not download the book (' + r.status + ')');
await c.put(rdOffKey(b.id), r); if (b.cover) { try { const cv = await fetch(`/api/read/book/${b.id}/cover`); if (cv.ok) await c.put(`/api/read/book/${b.id}/cover`, cv); } catch { /* the cover is a nicety */ } }
await rdLibs(rdKind(b.ext)).catch(() => {}); // (so the reader's own scripts are in the cache too)
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); toast('Saved on this device. It opens with no signal.');
} catch (e) { try { await (await caches.open('hub-offline')).delete(rdOffKey(b.id)); } catch {} toast(e.message || 'Could not save'); }
}
async function rdRemoveOffline(b) { try { const c = await caches.open('hub-offline'); await c.delete(rdOffKey(b.id)); await c.delete(`/api/read/book/${b.id}/cover`); toast('Removed from this device'); } catch { /* nothing saved */ } }
async function rdBookMenu(b) {
if (!b) return;
const saved = await rdSaved(b.id);
actionSheet(b.title, b.author || b.ext.toUpperCase(), [
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) },
saved ? { icon: 'dl', label: 'Remove from this device', run: () => rdRemoveOffline(b) } : { icon: 'dl', label: 'Save on this device (read offline)', run: () => rdSaveOffline(b) },
...(allowed('files') ? [{ icon: 'files', label: 'Save to Files', run: () => saveToFiles(b.title, { kind: 'book', id: b.id }) }] : []),
{ icon: 'dl', label: 'Download', run: () => { const a = document.createElement('a'); a.href = `/api/read/book/${b.id}/file`; a.download = b.title + '.' + b.ext; document.body.appendChild(a); a.click(); a.remove(); } },
{ icon: 'trash', label: 'Delete from library', danger: true, run: () => confirmSheet('Delete this book?', `"${b.title}" and your bookmarks, notes and highlights in it are removed.`, 'Delete', async () => {
@@ -362,11 +377,12 @@ class RdReader {
<b style="white-space:normal">${n.type === 'mark' ? icon('bookmark') + ' Bookmark' : `<span class="rddot" style="background:${RD_COLORS[n.color] || RD_COLORS.yellow}"></span>${n.type === 'note' ? 'Note' : 'Highlight'}`} <small style="display:inline">· ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}</small></b>
${n.text ? `<small style="white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden">${esc(n.text)}</small>` : ''}${n.note ? `<span style="white-space:pre-wrap;display:block;margin-top:2px">${esc(n.note)}</span>` : ''}</button>
${n.type === 'mark' ? '' : `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>`}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="rdexp">${icon('copy')}Copy as text</button></div>` : '<p class="empty">Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.</p>';
<div class="bar"><button class="btn ghost" id="rdexp">${icon('copy')}Copy as text</button><button class="btn ghost" id="rdexf">Download</button></div>` : '<p class="empty">Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.</p>';
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'));
+22 -1
View File
@@ -119,9 +119,11 @@ async function rcPlan(start) {
return `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button></div></div>`;
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button><button class="btn ghost" id="rcpantry">Pantry</button></div><div id="rcphave"></div></div>`;
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); } });
$('#rcpantry').onclick = () => rcPantry(() => rcPlan(rcYmd(mon)));
if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `<p class="sub" style="white-space:normal">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.</p>`; }).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). */
@@ -135,3 +137,22 @@ function rcPlanPick(slug, name, rid, date) {
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 => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; 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();
}
/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */
async function rcPantry(onClose) {
let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); }
const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', '<div id="pyl"></div>');
const draw = () => {
el.querySelector('#pyl').innerHTML = `<form id="pyf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pyn" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="pyq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="pyw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}</small></span>
${x.isLow ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen.</small></span></div>'}</div>
<div class="bar"><button class="btn" id="pydone" style="flex:1;justify-content:center">Done</button></div>`;
const go = async (p, o) => { try { items = (await rcApi(p, rcOpt('POST', o))).items; draw(); } catch (e) { toast(e.message); } };
el.querySelector('#pyf').onsubmit = e => { e.preventDefault(); go('/pantry/item', { name: el.querySelector('#pyn').value, qty: el.querySelector('#pyq').value, low: el.querySelector('#pyw').value }); };
el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/pantry/adjust', { id, delta: +d }); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/pantry/delete', { id: b.dataset.x }));
el.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
el.querySelector('#pydone').onclick = () => { closeSheet(); if (onClose) onClose(); };
};
draw();
}
+3 -1
View File
@@ -86,7 +86,7 @@ function viewSettings(args) {
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><div id="autoset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
@@ -154,6 +154,7 @@ function viewSettings(args) {
const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min';
$('#health').innerHTML =
(h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false))
+ (h.services || []).map(x => row(esc(x.name), x.ok ? 'Answering in ' + x.ms + ' ms' : esc(x.error || 'Not answering'), !x.ok)).join('')
+ (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false))
+ (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '')
+ row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30)
@@ -202,6 +203,7 @@ function viewSettings(args) {
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
if (typeof famSettings === 'function') famSettings($('#famset'));
if (typeof themeSettings === 'function') themeSettings($('#themeset'));
if (typeof autoSettings === 'function') autoSettings($('#autoset'));
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); } }); };
+15 -2
View File
@@ -10,11 +10,18 @@ function openSheet(title, sub, bodyHtml) {
el.querySelector('.scrim').onclick = dismissSheet; el.querySelector('.sheetx').onclick = dismissSheet;
swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') });
requestAnimationFrame(() => el.classList.add('on'));
// keyboard: focus moves into the dialog (not into a field, so a phone's keyboard does not pop up), Tab stays inside it, and closing gives focus back to what opened it
el._from = document.activeElement; const box = el.querySelector('.sheet'); box.tabIndex = -1; try { box.focus({ preventScroll: true }); } catch { /* old browser */ }
el.addEventListener('keydown', e => {
if (e.key !== 'Tab') return; const f = [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]):not([type=hidden]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(x => x.offsetParent !== null || x === document.activeElement); if (!f.length) return;
const first = f[0], last = f[f.length - 1]; if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
return el;
}
function closeSheet() { document.getElementById('sheet')?.remove(); }
const sheetGone = s => { const from = s && s._from; if (from && from.isConnected && from !== document.body && !document.getElementById('sheet')) { try { from.focus({ preventScroll: true }); } catch { /* it cannot take focus */ } } };
function closeSheet() { const s = document.getElementById('sheet'); if (s) { s.remove(); sheetGone(s); } }
/** Close with the slide-down (the close button, a tap outside); closeSheet() is the instant one other code calls before opening something else. */
function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) s.remove(); }, 230); }
function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) { s.remove(); sheetGone(s); } }, 230); }
/** Swipe down to dismiss: `el` follows the finger and `close` runs when it is let go far enough or fast enough. Touches starting in `handle` always count; elsewhere only when nothing under the finger is scrolled down, and never on sliders or text fields. On a phone-sized screen only, unless `always` (full-screen views). */
function swipeToDismiss(el, close, { handle = '', scrim = null, always = false } = {}) {
let y0 = 0, x0 = 0, t0 = 0, on = false, dead = true;
@@ -67,3 +74,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);
}
+20 -6
View File
@@ -8,7 +8,8 @@ const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2];
const stSize = b => fmtSize(+b || 0);
const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : '';
const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all';
let stItems = [], stInstalled = [], stSeen = {}, stAdmin = false, stFilter = 'all';
const stUpdated = it => it.kind === 'extension' && stInstalled.includes(it.id) && stSeen[it.id] !== undefined && (it.updated || 0) > stSeen[it.id]; // a new version of an extension you have (one installed before this was tracked is not flagged)
/** The sections this person may open (each needs its own tab's permission, except My Store). */
const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]);
@@ -28,7 +29,7 @@ async function viewStore(args = []) {
}
// ---------------------------------------------------------------- My Store
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; }
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
async function stMine(el) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const draw = () => {
@@ -154,15 +155,17 @@ async function stRunExt(el, id) {
const it = stItems.find(x => x.id === id && x.kind === 'extension');
if (!it) { el.innerHTML = '<p class="empty">That extension is not in the store any more.</p>'; return; }
if (!stInstalled.includes(id)) { el.innerHTML = `<p class="empty">Install “${esc(it.title)}” from My Store first.</p>`; return; }
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b></div>
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}${it.version ? ` <small style="color:var(--muted)">v${esc(it.version)}</small>` : ''}</b><button class="btn ghost" id="stperm">Permissions</button></div>
${stUpdated(it) ? `<div class="card" id="stupd" style="max-width:none;margin:0 0 8px"><b>Updated</b> ${esc(new Date(it.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }))}${it.version ? ' to version ' + esc(it.version) : ''}.${it.description ? ' ' + esc(it.description.split('\n')[0].slice(0, 160)) : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="stupdx">Got it</button></div></div>` : ''}
<iframe id="stframe" title="${esc(it.title)}" sandbox="allow-scripts allow-forms allow-popups allow-modals" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>`;
const frame = el.querySelector('#stframe'); let data = null, timer;
if (stUpdated(it)) { stApi(`/ext/${id}/seen`, stOpt('POST', {})).then(r => { stSeen = r.seen || stSeen; }).catch(() => {}); const x = el.querySelector('#stupdx'); if (x) x.onclick = () => el.querySelector('#stupd').remove(); }
// (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's)
fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `<p class="err">${esc(e.message)}</p>`; });
const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({})));
const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); };
// What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed.
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar' };
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar', 'notes.read': 'see your notes and lists', 'notes.write': 'add notes', 'recipes.read': 'search and read your recipes', 'music.read': 'see what music is playing' };
let grants = null; const refused = new Set();
const allowedTo = async perm => {
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
@@ -172,10 +175,16 @@ async function stRunExt(el, id) {
if (!yes) { refused.add(perm); return false; }
grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true;
};
// The permissions screen: everything this extension may reach, each one switched off or on (turning one off just means it is asked again next time it needs it)
el.querySelector('#stperm').onclick = async () => {
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
const sh = openSheet('Permissions', it.title, `<p class="sub" style="margin-top:0">What “${esc(it.title)}” may reach in the hub. Switch one off and it has to ask again.</p><div class="list">${Object.entries(WHY).map(([k, w]) => `<label class="row" style="cursor:pointer"><span class="grow"><b>${esc(w[0].toUpperCase() + w.slice(1))}</b><small>${esc(k)}</small></span><input type="checkbox" data-p="${esc(k)}"${grants.includes(k) ? ' checked' : ''}></label>`).join('')}</div>`);
sh.querySelectorAll('[data-p]').forEach(c => c.onchange = async () => { const next = c.checked ? [...new Set([...grants, c.dataset.p])] : grants.filter(x => x !== c.dataset.p); try { await stApi(`/ext/${id}/grants`, stOpt('POST', { grants: next })); grants = next; refused.delete(c.dataset.p); } catch (e) { c.checked = !c.checked; toast(e.message); } });
};
const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : '';
const hubCall = async (method, a = {}) => {
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method];
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write', 'notes.list': 'notes.read', 'notes.add': 'notes.write', 'recipes.search': 'recipes.read', 'recipes.get': 'recipes.read', 'music.nowPlaying': 'music.read' }[method];
if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {};
if (!(await allowedTo(need))) throw new Error('The person did not allow that');
const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; };
@@ -185,6 +194,11 @@ async function stRunExt(el, id) {
if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; }
if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday }));
if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; }
if (method === 'notes.list') return (await jf('/api/notes/')).notes.slice(0, 200).map(n => ({ id: n.id, type: n.type, title: n.title, body: n.type === 'note' ? txt(n.body, 5000) : '', items: n.type === 'list' ? (n.items || []).map(i => ({ text: i.text, done: !!i.done })) : [] }));
if (method === 'notes.add') { if (!txt(a.title, 120) && !txt(a.body, 10)) throw new Error('Give the note a title or some text'); await jf('/api/notes/', pj('POST', { type: 'note', title: txt(a.title, 120), body: txt(a.body, 20000), share: 'private' })); return { ok: true }; }
if (method === 'recipes.search') return (await jf('/api/recipes/?page=1&q=' + encodeURIComponent(txt(a.q, 100)))).items.slice(0, 24).map(r => ({ slug: r.slug, name: r.name, minutes: r.minutes || 0 }));
if (method === 'recipes.get') { const r = await jf('/api/recipes/r/' + encodeURIComponent(txt(a.slug, 200))); return { name: r.name, description: txt(r.description, 1000), servings: r.servings || '', ingredients: r.ingredients.map(i => i.text || i.title), steps: r.steps.map(x => x.text) }; }
if (method === 'music.nowPlaying') { const t = typeof cur === 'function' && cur(); return t ? { title: t.title || '', artist: t.artist || t.sub || '', album: t.album || '', playing: !pl.a.paused } : null; }
if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t });
return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); }
if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
@@ -202,7 +216,7 @@ async function stRunExt(el, id) {
else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; }
else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; }
else if (method === 'user') result = { name: ME ? ME.name : '' };
else if (method.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]);
else if (/^(tasks|contacts|calendar|notes|recipes|music)\./.test(method)) result = await hubCall(method, args[0]);
else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; }
else throw new Error('Unknown request');
} catch (x) { error = x.message; }
+5
View File
@@ -1101,3 +1101,8 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho
.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); }
.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; }
.vnrow { gap: 10px; } .vnart { width: 44px; flex: none; aspect-ratio: 2/3; border-radius: 6px; overflow: hidden; background: var(--panel2); display: grid; place-items: center; } .vnart img { width: 100%; height: 100%; object-fit: cover; } .vnvote { min-height: 36px; padding: 0 12px; flex: none; }
html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
+31 -2
View File
@@ -1,5 +1,6 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v102';
const CACHE = 'hub-v122';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
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'];
@@ -19,7 +20,7 @@ async function precache() {
}
self.addEventListener('install', e => { e.waitUntil(precache()); self.skipWaiting(); });
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE && !KEEP.test(k)).map(k => caches.delete(k))))); // (the app shell is replaced on a new version; saved episodes, shares and each person's saved data stay)
self.clients.claim();
});
// Podcast episodes saved on this device (see offline.js): answer from them, including the byte ranges an audio player asks for; otherwise use the network as normal.
@@ -32,6 +33,32 @@ async function offlineAudio(req) {
if (a > b || a >= size) return new Response(null, { status: 416, headers: { 'content-range': 'bytes */' + size } });
return new Response(blob.slice(a, b + 1), { status: 206, headers: { 'content-type': type, 'content-length': b - a + 1, 'content-range': `bytes ${a}-${b}/${size}`, 'accept-ranges': 'bytes' } });
}
// Saved data for offline use: the lists a person is likely to want with no signal (tasks, notes, recipes, calendar, contacts, chores). Network first; when the network fails the last
// copy is used. Kept per person (the page tells us who is signed in), so a shared tablet never shows one person's data to another. Reading only: changes need a connection.
const DATA_OK = /^\/api\/(read\/(list|book\/\w+\/notes)|tasks(\/(upcoming|summary|lists|due))?|notes|recipes(\/(r\/[\w-]+|shopping))?|cal\/(events|list)|contacts|chores(\/\w+)?|bible\/(state))\/?$/;
const whoNow = async () => { try { const r = await (await caches.open('hub-who')).match('/who'); return r ? await r.text() : ''; } catch { return ''; } };
self.addEventListener('message', e => {
const d = e.data || {}; if (d.type !== 'who') return;
const id = String(d.id || '').replace(/[^\w-]/g, '');
e.waitUntil((async () => { await (await caches.open('hub-who')).put('/who', new Response(id)); for (const k of await caches.keys()) if (k.startsWith('hub-data-') && k !== 'hub-data-' + id) await caches.delete(k); })());
});
async function dataFirstNetwork(req) {
const who = await whoNow();
try {
const r = await fetch(req);
if (r.ok && who) { const copy = r.clone(); caches.open('hub-data-' + who).then(c => c.put(req.url, copy)); }
return r;
} catch (err) {
const hit = who && await (await caches.open('hub-data-' + who)).match(req.url);
if (hit) return hit;
throw err;
}
}
// A book saved on this device (see reader.js): answered from the saved copy whatever the address's query, otherwise the network as normal.
async function offlineBook(req) {
const hit = await (await caches.open('hub-offline')).match(req.url, { ignoreSearch: true });
return hit || fetch(req);
}
// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
async function takeShare(request) {
try {
@@ -46,7 +73,9 @@ self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/read\/book\/\w+\/(file|cover)$/.test(u.pathname)) return e.respondWith(offlineBook(e.request)); // (books saved on this device)
if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device)
if (e.request.method === 'GET' && u.origin === location.origin && DATA_OK.test(u.pathname)) return e.respondWith(dataFirstNetwork(e.request));
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
+20 -18
View File
@@ -1,7 +1,7 @@
'use strict';
// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
const dueInfo = (d, tm) => { if (!d) return null; const at = tm ? ' · ' + new Date('2000-01-01T' + tm).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
return d < t ? { text: 'Overdue · ' + label + at, color: 'var(--danger)' } : d === t ? { text: 'Today' + at, color: 'var(--accent)' } : { text: label + at, color: 'var(--muted)' }; };
const taskStore = []; // tasks on screen, so a tap can open the right one
let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" opens with its own list's options
@@ -13,11 +13,11 @@ async function viewTasks() {
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
if (!location.hash.startsWith('#/tasks')) return;
taskLists = lists;
let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
const list = cur === 'all' ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
let cur = store.get('task_list'); if (!['all', 'today', 'upcoming'].includes(cur) && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
const list = ['all', 'today', 'upcoming'].includes(cur) ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
<div class="seg">${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${cur === 'all' ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div class="seg"><a href="#" data-l="today" class="${cur === 'today' ? 'on' : ''}">Today</a><a href="#" data-l="upcoming" class="${cur === 'upcoming' ? 'on' : ''}">Upcoming</a>${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${['all', 'today', 'upcoming'].includes(cur) ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div id="tl"><p class="empty">Loading…</p></div>
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
@@ -26,7 +26,7 @@ async function viewTasks() {
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
$('#qa').onsubmit = async e => {
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: cur === 'today' ? ymd(new Date()) : '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
catch (err) { toast(err.message); }
};
loadTaskList(cur, list);
@@ -36,27 +36,28 @@ async function viewTasks() {
async function loadTaskList(cur, list) {
const box = $('#tl'); if (!box) return;
try {
const all = cur === 'all', byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
const tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
const smart = cur === 'today' || cur === 'upcoming', all = cur === 'all' || smart, today = ymd(new Date()), byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
let tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
.flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
if (!$('#tl')) return;
if (typeof offApplyTasks === 'function') tasks = offApplyTasks(tasks); // (changes made offline and not sent yet)
taskStore.length = 0;
const ids = new Set(tasks.map(t => t.id)), byParent = {};
tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); });
const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title);
const open = tasks.filter(t => top(t) && !t.done).sort(byDue);
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || (a.dueTime || '').localeCompare(b.dueTime || '') || a.title.localeCompare(b.title);
const open = tasks.filter(t => top(t) && !t.done && (cur === 'today' ? t.due && t.due <= today : cur === 'upcoming' ? t.due && t.due > today : true)).sort(byDue);
const done = smart ? [] : tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due, t.dueTime), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
const extra = [all && t._ln ? t._ln : '', t.by && t.list === 'local:family' ? 'Added by ' + t.by : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
const showDone = store.get('task_show_done', false);
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : '<p class="empty">Nothing to do. Add a task above.</p>')
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : (cur === 'today' ? '<p class="empty">Nothing due today. Enjoy the day.</p>' : cur === 'upcoming' ? '<p class="empty">Nothing coming up with a due date.</p>' : '<p class="empty">Nothing to do. Add a task above.</p>'))
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : ''}` : '');
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
const t = taskStore[+b.dataset.t]; b.disabled = true;
const t = taskStore[+b.dataset.t]; if (String(t.id).startsWith('queued:')) return toast('Waiting to send; it can be ticked once it has been sent.'); b.disabled = true;
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: t.list, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
catch (err) { b.disabled = false; toast(err.message); }
});
@@ -68,18 +69,19 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
const redraw = () => loadTaskList(viewCur || cur, viewList || list);
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
${list.local ? `<label for="tdt">Remind me at</label><input type="time" id="tdt" value="${esc(t.dueTime || '')}"><p class="sub" style="margin:4px 0 0">Optional. Needs a due date; you get a notification at that time.</p>` : ''}
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
${list.local ? `<label for="trep">Repeat</label><select id="trep">${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `<option value="${v}" ${(t.repeat || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
<p class="sub" style="margin:4px 0 0">A repeating task moves to its next date when you complete it.</p>` : ''}
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
${list.local && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
${(list.local || list.icloud) && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
<div style="display:flex;gap:8px;margin-top:6px"><input type="text" id="tsub" placeholder="Add a subtask" autocomplete="off" style="flex:1"><button type="button" class="btn ghost" id="tsubadd">Add</button></div>` : ''}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div>
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; const tt = el.querySelector('#tdt'); if (tt) tt.value = ''; };
el.querySelector('#tf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, ...(el.querySelector('#tdt') ? { dueTime: el.querySelector('#tdt').value } : {}), done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
};
if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
+17 -2
View File
@@ -1,6 +1,7 @@
'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_PERSON = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d'];
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' },
@@ -14,6 +15,11 @@ function themeApply() {
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'); }
const hc = !!store.get('a11y_contrast', false), size = +store.get('a11y_text', 1) || 1;
const HC = dark ? { '--text': '#ffffff', '--muted': '#d3d8e2', '--line': '#7a8294' } : { '--text': '#000000', '--muted': '#33383f', '--line': '#6c7482' };
for (const k of Object.keys(HC)) { if (hc) root.style.setProperty(k, HC[k]); else if (mode === 'auto') root.style.removeProperty(k); else root.style.setProperty(k, THEME_VARS[mode][k]); }
root.classList.toggle('hc', hc); root.classList.toggle('rm', !!store.get('a11y_motion', false));
const view = document.getElementById('view'); if (view) view.style.zoom = size === 1 ? '' : String(size); // larger text and controls for the page itself
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);
}
@@ -22,9 +28,18 @@ function themeSettings(el) {
if (!el) return;
const draw = () => {
const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue');
el.innerHTML = `<h2>Look</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
el.innerHTML = `<h2>Look and reading</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
<div class="seg" role="radiogroup" aria-label="Brightness">${THEME_MODES.map(([k, l]) => `<a href="#" role="radio" aria-checked="${mode === k}" data-m="${k}" class="${mode === k ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>
<h3 class="sub" style="margin:14px 0 0">Reading</h3>
<div class="seg" role="radiogroup" aria-label="Text size">${[[1, 'Normal'], [1.15, 'Large'], [1.3, 'Extra large']].map(([v, l]) => `<a href="#" role="radio" aria-checked="${+store.get('a11y_text', 1) === v}" data-ts="${v}" class="${+store.get('a11y_text', 1) === v ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>High contrast</b><small>Stronger text and borders</small></span><input type="checkbox" id="a11yhc"${store.get('a11y_contrast', false) ? ' checked' : ''}></label>
<label class="row" style="cursor:pointer"><span class="grow"><b>Reduce motion</b><small>Fewer animations, whatever the device says</small></span><input type="checkbox" id="a11yrm"${store.get('a11y_motion', false) ? ' checked' : ''}></label></div>
<h3 class="sub" style="margin:14px 0 0">My colour on shared calendars</h3><div class="swatches" role="radiogroup" aria-label="My colour on shared calendars">${(typeof ME !== 'undefined' && ME ? THEME_PERSON : []).map(c => `<button type="button" role="radio" aria-checked="${ME.color === c}" aria-label="${c}" data-pc="${c}" class="${ME.color === c ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
el.querySelectorAll('[data-ts]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('a11y_text', +a.dataset.ts); themeApply(); draw(); });
el.querySelector('#a11yhc').onchange = e => { store.set('a11y_contrast', e.target.checked); themeApply(); };
el.querySelector('#a11yrm').onchange = e => { store.set('a11y_motion', e.target.checked); themeApply(); };
el.querySelectorAll('[data-pc]').forEach(b => b.onclick = async () => { try { const r = await jfetch('/api/me/color', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ color: b.dataset.pc }) }); ME.color = r.color; try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} draw(); } catch (e) { toast(e.message); } });
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(); });
};
+7 -3
View File
@@ -34,11 +34,15 @@ const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' mi
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 goal = +store.get('read_goal', 0) || 0; if (!after.isConnected) return;
const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening';
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>audiobooks finished this year</small></span></div>
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>books finished this year</small></span></div>
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}
<div id="lsfam" class="lsfam"></div><div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
after.insertAdjacentElement('afterend', card);
jfetch('/api/listening/family').then(f => { const box = card.querySelector('#lsfam'); if (!box) return; if (!f.goal && !f.finished) { box.innerHTML = `<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">Set a family reading challenge</button>`; } else box.innerHTML = `<div class="lsrow" style="margin-top:12px"><span><b>${f.finished}${f.goal ? ' of ' + f.goal : ''}</b><small>family books finished in ${f.year}${f.people.length ? ' · ' + esc(f.people.map(p => p.name + ' ' + p.n).join(', ')) : ''}</small></span></div>${f.goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(f.finished / f.goal * 100))}%"></i></div>` : ''}<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">${f.goal ? 'Change the family goal' : 'Set a family goal'}</button>`;
const b = box.querySelector('#lsfg'); if (b) b.onclick = () => { const el = openSheet('Family reading challenge', 'One goal for everyone: how many books the family finishes this year (audiobooks and e-books together).', `<form id="lsff"><label for="lsfn">Books</label><input type="number" id="lsfn" min="0" max="1000" value="${f.goal || ''}" placeholder="40" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsff').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/listening/family', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ goal: +el.querySelector('#lsfn').value || 0 }) }); closeSheet(); card.remove(); homeListening(after); } catch (er) { toast(er.message); } }; };
}).catch(() => {});
card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); 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); }; };
}
+55 -1
View File
@@ -73,6 +73,42 @@ async function vdRemove(m, quiet) {
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
}
// ---- Watch stats: what you watched lately, counted from the "last watched" dates Plex keeps for you
/** items: Plex movies or episodes with lastViewedAt (seconds) and duration (ms). Counts and minutes for the last 7, 30 and 365 days, a 14-day strip, and the top genres. */
function vdAgg(items, now = Date.now()) {
const day = 864e5, out = { week: { n: 0, min: 0 }, month: { n: 0, min: 0 }, year: { n: 0, min: 0 }, days: Array(14).fill(0), genres: [] }, g = {};
for (const m of items) {
const t = (m.lastViewedAt || 0) * 1000; if (!t || t > now + day) continue; const age = now - t, mins = Math.round((m.duration || 0) / 60000), add = b => { b.n++; b.min += mins; };
if (age < 7 * day) add(out.week); if (age < 30 * day) add(out.month); if (age < 365 * day) { add(out.year); for (const x of m.Genre || []) g[x.tag] = (g[x.tag] || 0) + 1; }
const di = Math.floor(age / day); if (di >= 0 && di < 14) out.days[13 - di]++;
}
out.genres = Object.entries(g).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5); return out;
}
async function vdStatsSheet(cur) {
const isMovie = cur.type === 'movie', el = openSheet('Your watching', cur.title, '<p class="empty">Counting…</p>');
try {
const r = await px.get(`/library/sections/${cur.key}/all?type=${isMovie ? 1 : 4}&sort=lastViewedAt:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=600`), a = vdAgg((r.Metadata || []).filter(m => m.lastViewedAt)), hrs = m => m < 60 ? m + ' min' : (m / 60).toFixed(m < 600 ? 1 : 0) + ' h', max = Math.max(1, ...a.days), what = isMovie ? 'movies' : 'episodes';
el.querySelector('.empty').outerHTML = `<div class="lsrow">${[['week', 'this week'], ['month', 'last 30 days'], ['year', 'this year']].map(([k, l]) => `<span><b>${a[k].n}</b><small>${what} ${l}${a[k].min ? ' · ' + hrs(a[k].min) : ''}</small></span>`).join('')}</div>
<div class="lsbars" role="img" aria-label="${what} watched each of the last 14 days">${a.days.map((n, i) => `<i style="height:${Math.max(3, Math.round(n / max * 100))}%" title="${n} ${what}"></i>`).join('')}</div>
${a.genres.length ? `<h3 class="sub" style="margin:12px 0 4px">Favourite genres this year</h3><div class="seg" style="flex-wrap:wrap">${a.genres.map(([t, n]) => `<a href="#" style="pointer-events:none">${esc(t)} · ${n}</a>`).join('')}</div>` : ''}
${a.year.n ? '' : '<p class="empty">Nothing watched in this library yet.</p>'}<p class="sub" style="margin-top:12px">Counts each title once, on the day you last watched it.</p>`;
} catch (e) { el.querySelector('.empty').textContent = e.message; }
}
// ---- Favorites (yours) and Movie night (the family's list to vote on)
const vfApi = (p, o) => jfetch('/api/moviefam' + p, o), vfOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const vfCard = x => ({ ratingKey: x.id, type: x.type, title: x.title, year: x.year || undefined, thumb: x.thumb || undefined });
function vdFamilyRows() {
const drawNight = items => {
const el = $('#vnight'); if (!el) return;
el.innerHTML = items.length ? `<h2>Movie night</h2><div class="list">${items.map((x, i) => `<div class="row vnrow"><a class="vnart" href="#/video/item/${esc(x.id)}">${x.thumb ? `<img loading="lazy" alt="" src="${vdArt(x.thumb, 90, 135)}" onerror="this.remove()">` : icon('film')}</a>
<a class="grow" href="#/video/item/${esc(x.id)}" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc([x.year || '', 'added by ' + x.byName].filter(Boolean).join(' · '))}${x.voters.length ? ' · ' + esc(x.voters.join(', ')) : ''}</small></a>
<button class="btn${x.voted ? '' : ' ghost'} vnvote" data-v="${esc(x.id)}" aria-pressed="${x.voted}" aria-label="${x.voted ? 'Take back your vote for ' : 'Vote for '}${esc(x.title)}">${i === 0 && x.votes > 1 ? '★ ' : ''}${x.votes} ${x.votes === 1 ? 'vote' : 'votes'}</button><button class="ibtn" data-r="${esc(x.id)}" aria-label="Remove ${esc(x.title)}" title="Remove">${icon('x')}</button></div>`).join('')}</div>` : '';
el.querySelectorAll('[data-v]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/vote', vfOpt({ id: b.dataset.v }))).items); } catch (e) { toast(e.message); } });
el.querySelectorAll('[data-r]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/remove', vfOpt({ id: b.dataset.r }))).items); } catch (e) { toast(e.message); } });
};
vfApi('/night').then(r => drawNight(r.items)).catch(() => {});
vfApi('/favs').then(r => { const el = $('#vfavr'); if (el) el.innerHTML = r.items.length ? `<h2>Favorites</h2><div class="vrow">${r.items.slice(0, 30).map(x => vdCard(vfCard(x), false)).join('')}</div>` : ''; }).catch(() => {});
}
async function videoHome(st, libKey) {
const v = $('#view');
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
@@ -81,7 +117,7 @@ async function videoHome(st, libKey) {
store.set('video_lib', cur.key);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
<div id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
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.
@@ -97,12 +133,21 @@ async function videoHome(st, libKey) {
}).catch(() => {});
const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
const type = cur.type === 'movie' ? 1 : 2, size = 120;
// Mixes: a handful of ways to pick something to watch, from what you have not seen yet
{ const MIX = [['shuffle', 'Surprise me', 'sort=random', x => true], ['family', 'Family night', 'sort=random&contentRating=' + vdEnc('G,PG,TV-Y,TV-G,TV-PG'), x => true], ...(type === 1 ? [['short', 'Under 95 minutes', 'sort=random', x => x.duration && x.duration <= 95 * 60000]] : []), ['classic', 'Classics', 'sort=random', x => x.year && x.year <= 1979]];
$('#vmix').innerHTML = MIX.map(m => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('') + '<a href="#" id="vstats">Your stats</a>';
$('#vstats').onclick = e => { e.preventDefault(); vdStatsSheet(cur); };
$('#vmix').querySelectorAll('[data-mix]').forEach(a => a.onclick = async e => { e.preventDefault(); const m = MIX.find(x => x[0] === a.dataset.mix), el = openSheet(m[1], 'Unwatched, picked at random. Tap the same mix again for a new set.', '<p class="empty">Looking…</p>');
try { const r = await px.get(`/library/sections/${cur.key}/all?type=${type}&unwatched=1&${m[2]}&X-Plex-Container-Start=0&X-Plex-Container-Size=${m[0] === 'shuffle' || m[0] === 'family' ? 24 : 200}`), items = (r.Metadata || []).filter(m[3]).slice(0, 24);
el.querySelector('.empty').outerHTML = items.length ? `<div class="vgrid">${items.map(x => vdCard(x, false)).join('')}</div>` : '<p class="empty">Nothing matches. Try another mix.</p>'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet()));
} catch (er) { el.querySelector('.empty').textContent = er.message; } }); }
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
}).catch(() => {});
}
vdFamilyRows();
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
// sort and filter the library below
@@ -189,6 +234,15 @@ async function videoItem(id) {
actionSheet('Trailers', m.title, [...list.slice(0, 8).map(x => ({ label: x.title || 'Trailer', icon: 'play', run: () => go(x) })), { label: 'Search YouTube instead', icon: 'search', run: yt }]);
};
});
if (m.type === 'movie' || m.type === 'show') { // Favorite, and Movie night (the family's shared list to vote on)
const mc = { id: String(id), title: m.title, year: m.year || 0, type: m.type, thumb: m.thumb || '' };
Promise.all([vfApi('/favs'), vfApi('/night')]).then(([f, n]) => {
const box = $('#vact'); if (!box || $('#vfav')) return; const fav = f.items.some(x => x.id === mc.id), night = n.items.some(x => x.id === mc.id);
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vfav">${fav ? '♥ Favorite' : '♡ Favorite'}</button><button class="btn ghost" id="vmn">${night ? '✓ On movie night' : '+ Movie night'}</button>`);
$('#vfav').onclick = async () => { try { await vfApi('/fav', vfOpt({ ...mc, on: !fav })); toast(fav ? 'Removed from Favorites' : 'Added to Favorites'); route(); } catch (e) { toast(e.message); } };
$('#vmn').onclick = async () => { try { if (night) { location.hash = '#/video'; return; } await vfApi('/night/add', vfOpt(mc)); toast('Added to movie night. Everyone can vote on it from Movies & TV.'); route(); } catch (e) { toast(e.message); } };
}).catch(() => {});
}
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
+25 -1
View File
@@ -56,16 +56,18 @@ async function goalReminder(u, dir, st, now) {
}
let lastEvents = 0;
let lastDigest = 0;
let lastDigest = 0, lastEps = 0;
/** One pass over everyone: runs every minute. */
async function tick(now = new Date(), force = false) {
if (now.getTime() - lastDigest >= 10 * 60e3) { lastDigest = now.getTime(); try { require('./activity').weeklyDigest(now); } catch (e) { console.warn('[push] weekly summary failed:', e.message); } } // (each child's week, to the parents, on Monday morning)
if (now.getTime() - lastEps >= 30 * 60e3) { lastEps = now.getTime(); try { await require('./automations').checkEpisodes(n => require('./podcast').recentEpisodes(n)); } catch (e) { console.warn('[push] new episodes check failed:', e.message); } }
const checkEvents = force || now.getTime() - lastEvents >= 4.5 * 60e3;
if (checkEvents) lastEvents = now.getTime();
for (const u of users.all()) {
if (u.disabled) continue;
const dir = users.dirOf(u.id), st = load(dir);
await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message));
await require('./automations').tickUser(u, localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now)).catch(e => console.warn('[push] automations failed:', e.message)); // (daily rules)
if (!st.subs.length) continue;
await users.runAs(u, async () => {
const lt = localNow(st.tz, now); let dirty = false;
@@ -79,6 +81,27 @@ async function tick(now = new Date(), force = false) {
}
} catch (e) { console.warn('[push] tasks check failed:', e.message); }
}
if (checkEvents && st.tasks && users.can(u, 'tasks')) { // a task with a due time: one reminder when that time comes (within two hours of it), once
try {
for (const t of (await tasks.allTasks()).filter(x => !x.done && x.due === lt.day && x.dueTime && x.dueTime <= lt.hm)) {
const mins = (+lt.hm.slice(0, 2) - +t.dueTime.slice(0, 2)) * 60 + (+lt.hm.slice(3) - +t.dueTime.slice(3)), key = `taskdue:${t.id}:${t.due}:${t.dueTime}`;
if (mins > 120 || st.sent.includes(key)) continue;
st.sent.push(key); st.sent = st.sent.slice(-300); dirty = true;
await send(dir, st, u.id, { title: t.title, body: 'Due now (' + t.dueTime + ')', url: '#/tasks', tag: key });
}
} catch (e) { console.warn('[push] task times failed:', e.message); }
}
if (checkEvents && users.can(u, 'mail')) { // snoozed mail whose time has come is back in the inbox: say so
try { const due = mail.snoozeDue(u.id); if (due.length) await send(dir, st, u.id, { title: due.length === 1 ? 'Back in your inbox: ' + (due[0].subject || 'a message') : due.length + ' snoozed messages are back', body: due.length === 1 ? due[0].from || '' : due.slice(0, 3).map(x => x.subject).filter(Boolean).join(', '), url: '#/mail', tag: 'snooze' }); }
catch (e) { console.warn('[push] snooze 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) {
@@ -97,6 +120,7 @@ async function tick(now = new Date(), force = false) {
const fresh = now2.filter(m => !seen.includes(m.id));
if (fresh.length) {
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
for (const m of fresh.slice(0, 10)) require('./automations').fire(u.id, 'mail.new', { from: m.from || '', subject: m.subject || '' }).catch(() => {});
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } });
}
}
+30
View File
@@ -104,6 +104,18 @@ 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. Each item has a name, how many are left (blank when you do not count it) and the level it counts as
// running low at (blank: never). The meal plan points out planned ingredients you have; an item going low can start an automation and goes on the shopping list in a tap.
const pantryFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pantry.json');
const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100);
const readPantry = () => { try { const a = JSON.parse(fs.readFileSync(pantryFile(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null } : x).filter(x => x && x.name); } catch { return []; } };
const writePantry = l => atomicWrite(pantryFile(), JSON.stringify(l), { mode: 0o600 });
const isLow = x => x.qty != null && x.low != null && x.qty <= x.low;
const pantryView = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, low: x.low, isLow: isLow(x) })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name));
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
const inPantry = (text, items) => { items = items.map(x => typeof x === 'string' ? x : x.name); 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)); }); };
/** An item has just gone low: tell the automations of everyone who asked to hear about it. */
function pantryLow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[recipes] pantry rules failed:', e.message); } }
/** 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 +164,24 @@ 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: pantryView(readPantry()) });
if (p === '/pantry/item' && req.method === 'POST') { // add an item, or change one
const b = JSON.parse(await readBody(req, 2e3) || '{}'), l = readPantry(), name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name');
const i = b.id ? l.findIndex(x => x.id === b.id) : l.findIndex(x => x.name.toLowerCase() === name.toLowerCase()); if (b.id && i < 0) throw fail(404, 'That item is gone'); if (i < 0 && l.length >= 400) throw fail(400, 'The pantry is full');
const before = i >= 0 ? isLow(l[i]) : false, it = { id: i >= 0 ? l[i].id : crypto.randomBytes(4).toString('hex'), name: i >= 0 && !b.id ? l[i].name : name, qty: num(b.qty), low: num(b.low) }; if (i >= 0) l[i] = it; else l.push(it); writePantry(l);
if (isLow(it) && !before) pantryLow(it);
return json(res, 200, { items: pantryView(l) });
}
if (p === '/pantry/adjust' && req.method === 'POST') { // one more or one less
const b = JSON.parse(await readBody(req, 1e3) || '{}'), l = readPantry(), it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone');
const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + (+b.delta || 0)) * 100) / 100); writePantry(l); if (isLow(it) && !before) pantryLow(it); return json(res, 200, { items: pantryView(l) });
}
if (p === '/pantry/delete' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e3) || '{}'); const l = readPantry().filter(x => x.id !== b.id); writePantry(l); return json(res, 200, { items: pantryView(l) }); }
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().filter(x => x.qty !== 0), 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;
+5 -1
View File
@@ -6,7 +6,7 @@ const settings = require('./settings'), users = require('./users'), books = requ
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const MAX_FILE = 500 * 1024 * 1024;
const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'librivox-book': 'audio', 'librivox-chapter': 'audio' };
const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'mail-attachment': 'mail', 'librivox-book': 'audio', 'librivox-chapter': 'audio' };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const safe = (s, max = 100) => String(s || '').replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').replace(/^[. ]+|[. ]+$/g, '').slice(0, max).trim() || 'Untitled';
@@ -44,6 +44,10 @@ async function plan(me, b) {
out.push({ folder: ['Books'], name: safe(f.author ? `${f.author} - ${f.title}` : f.title, 120) + '.' + f.ext, load: async () => fs.promises.readFile(f.path) });
return { label: f.title, out };
}
if (kind === 'mail-attachment') {
const f = await require('./mail').attachmentOf(String(b.id || ''), String(b.aid || '')); if (f.buffer.length > MAX_FILE) throw fail(413, 'That attachment is too large to save');
out.push({ folder: ['Mail attachments'], name: safe(f.name, 120), load: async () => f.buffer }); return { label: f.name, out };
}
if (kind === 'librivox-book' || kind === 'librivox-chapter') {
const bk = await librivox.book(String(b.id || '')), only = kind === 'librivox-chapter' ? +b.n : -1;
bk.chapters.forEach(c => { if (only >= 0 && c.n !== only) return; out.push({ folder: ['Audiobooks', safe(bk.title)], name: `${num(c.n, bk.chapters.length)} - ${safe(c.title, 80)}.mp3`, load: () => download(c.url, librivox.allowed, 'NE-Hub/1.0') }); });
+12
View File
@@ -48,6 +48,8 @@ const contacts = require('./contacts'); // each person's address book (mail su
const activity = require('./activity'), alertParents = activity.alertParents;
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
const automations = require('./automations'); // when this happens, do that (per person)
const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list
const chores = require('./chores'); // chores for the family, with points
const notes = require('./notes'); // shared and private notes and checklists
const chat = require('./chat'); // Family Chat: a messenger for the people with an account here
@@ -584,6 +586,7 @@ const ACCESS = [
[/^\/api\/bible\//, ['bible']],
[/^\/api\/read\//, ['read']],
[/^\/api\/radio\//, ['radio']],
[/^\/api\/moviefam(\/|$)/, ['video']],
[/^\/api\/chores(\/|$)/, ['chores']],
[/^\/api\/notes(\/|$)/, ['notes']],
[/^\/api\/chat(\/|$)/, ['chat']],
@@ -651,6 +654,7 @@ async function handleRequest(req, res) {
const rule = ACCESS.find(([re]) => re.test(pathname));
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
if (pathname === '/api/me/color' && req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); users.update(user.id, { color: b.color || null }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ color: users.colorOf(users.get(user.id)) })); }
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
@@ -660,6 +664,7 @@ 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/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); }
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');
@@ -675,6 +680,11 @@ async function handleRequest(req, res) {
const ps = plexLoad();
out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })();
try { out.mail = await mail.health(); } catch { out.mail = []; }
// the other services the hub leans on: reachable at all? (any answer under 500 counts, since most want a sign-in)
const probe = async (name, url) => { const t = Date.now(); try { const r = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(4000) }); return r.status < 500 ? { name, ok: true, ms: Date.now() - t } : { name, ok: false, error: 'Answered with an error (' + r.status + ')' }; } catch (e) { return { name, ok: false, error: e.name === 'TimeoutError' ? 'No answer within 4 seconds' : 'Cannot be reached' }; } };
const checks = [probe('Seafile (Files)', SEAFILE_URL + '/api2/ping/'), probe('OnlyOffice', ONLYOFFICE_URL + '/healthcheck'), probe('Audiobooks', (process.env.OPENAUDIBLE_URL || AUDIOBOOKS_URL).replace(/\/$/, '') + '/'), probe('Podcasts (Podgrab)', PODCAST_URL)];
if (recipes.enabled()) checks.push(probe('Recipes (Mealie)', (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '') + '/api/app/about'));
out.services = await Promise.all(checks);
out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status();
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
}
@@ -706,6 +716,8 @@ async function handleRequest(req, res) {
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
if (pathname === '/api/auto' || pathname.startsWith('/api/auto/')) return automations.handle(req, res);
if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res);
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res);
if (pathname.startsWith('/api/chat/')) return chat.handle(req, res);
+21 -8
View File
@@ -44,9 +44,10 @@ function parseTodos(text) {
else if (l.name === 'STATUS') cur.status = l.value.toUpperCase();
else if (l.name === 'PERCENT-COMPLETE') cur.percent = parseInt(l.value, 10);
else if (l.name === 'COMPLETED') cur.completed = true;
else if (l.name === 'RELATED-TO' && !/^(CHILD|SIBLING)$/i.test((l.params && l.params.RELTYPE) || '')) cur.parent = cal.unesc(l.value);
else if (l.name === 'DUE') { const d = cal.parseDate(l.value, l.params); if (d) cur.due = `${d.y}-${String(d.mo).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`; }
}
return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed }));
return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed, parent: t.parent || '' }));
}
// ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it.
@@ -81,6 +82,7 @@ function validParent(d, raw, parent, selfId) {
if (selfId && d.tasks.some(x => x.parent === selfId)) throw new Error('A task that has subtasks cannot become a subtask');
return parent;
}
const cleanTime = v => /^([01]\d|2[0-3]):[0-5]\d$/.test(String(v || '')) ? String(v) : ''; // a time of day on the due date, for a reminder at that time
const fieldsOf = t => ({ title: t.title, notes: t.notes, due: t.due, done: t.done });
async function handle(req, res) {
@@ -107,7 +109,7 @@ async function handle(req, res) {
const list = q.get('list');
if (isLocal(list)) {
const raw = list.slice(6);
return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' })));
return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' })));
}
if (caldav.todoLists().some(l => l.id === list)) {
const rows = await caldav.listTodoRaw(list), tasks = [];
@@ -125,27 +127,33 @@ async function handle(req, res) {
if (b.id) {
const t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
const wasDone = !!t.done, repeat = f.repeat === undefined ? t.repeat || 'none' : cleanRepeat(f.repeat);
Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', repeat, updated: Date.now() });
Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', dueTime: f.due ? cleanTime(f.dueTime) : '', repeat, updated: Date.now() });
if (f.parent !== undefined) t.parent = validParent(d, raw, f.parent, t.id) || undefined;
const rolled = finishLocal(t, !!f.done);
if (wasDone && f.done) { t.done = true; t.completedAt = t.completedAt || Date.now(); }
if (b.moveTo && isLocal(b.moveTo) && !t.parent) { const to = b.moveTo.slice(6); d.tasks.filter(x => x.parent === t.id).forEach(x => { x.list = to; }); t.list = to; }
save(d); return json(res, 200, { ok: true, rolled });
}
d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); // (who added it, so a shared list shows whose it was)
d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', dueTime: f.due ? cleanTime(f.dueTime) : '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); // (who added it, so a shared list shows whose it was)
save(d); return json(res, 200, { ok: true });
}
if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive
const cur = await caldav.getResource(b.list, b.href);
await caldav.putEvent(b.list, null, ics.patchTodo(cur.ics, f), { href: b.href, etag: cur.etag });
} else { const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }])); }
} else {
if (f.parent) { // a subtask goes under a main task of the same list (and a subtask cannot have subtasks)
const mine = []; for (const r of await caldav.listTodoRaw(b.list)) mine.push(...parseTodos(r.ics)); const par = mine.find(x => x.id === f.parent);
if (!par || par.parent) throw new Error('A subtask can only go under a main task in the same list'); }
const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }]));
}
return json(res, 200, { ok: true });
}
if (p === '/api/tasks/toggle' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
if (isLocal(b.list)) {
const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
const rolled = finishLocal(t, !!b.done); save(d); return json(res, 200, { ok: true, rolled });
const was = !!t.done, rolled = finishLocal(t, !!b.done); save(d); if (b.done && !was) require('./automations').fire(users.current().id, 'task.done', { title: t.title }).catch(() => {});
return json(res, 200, { ok: true, rolled });
}
const cur = await caldav.getResource(b.list, b.href);
const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task');
@@ -182,11 +190,16 @@ async function handle(req, res) {
/** Every task from every list (NE Hub and iCloud), with its list name. */
async function allTasks() {
const d = load(), out = [];
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
return out;
}
/** Open tasks on the shared Family list, soonest first, for the family board. */
const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 40).map(x => ({ id: x.id, title: x.title, due: x.due || '', by: x.by || '' })); };
module.exports = { familyOpen, handle, parseTodos, allTasks, nextDue, stepDate };
/** Add a task to one of the current person's own lists by name (the first list when none is given or found). Used by automations. */
function addLocalTask(listName, title) {
const d = load(), n = String(listName || '').toLowerCase(), l = (n && d.lists.find(x => x.id !== FAMILY && x.name.toLowerCase() === n)) || d.lists[0];
d.tasks.push({ id: crypto.randomUUID(), list: l.id, title: String(title).slice(0, 200), notes: 'From an automation', due: '', done: false, completedAt: null, repeat: 'none', created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); save(d);
}
module.exports = { addLocalTask, familyOpen, handle, parseTodos, allTasks, nextDue, stepDate };
+42
View File
@@ -0,0 +1,42 @@
// Automations: rules are checked when saved, run when their trigger happens (and only then), keep to a daily cap, and belong to one person.
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-auto-'));
const users = require('../users'), auto = require('../automations');
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['tasks'], handle: 'ZP' });
const tasksOf = u => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'tasks.json'), 'utf8')).tasks; } catch { return []; } };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => auto.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/auto', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = who => fetch(base + '/', { headers: who ? { 'x-user': who } : {} });
try {
// validation
assert.equal((await post('/', { when: { type: 'nope' }, then: { type: 'task', text: 'x' } })).status, 400); assert.equal((await post('/', { when: { type: 'time', at: '25:00' }, then: { type: 'task', text: 'x' } })).status, 400, 'a real time of day');
assert.equal((await post('/', { when: { type: 'mail.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'mail rules need something to match, so it is not every email');
assert.equal((await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: ' ' } })).status, 400, 'and something to do');
// a daily rule runs once a day, at its time
let r = await j(await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: 'Water the plants' } })); assert.equal(r.rules.length, 1); const daily = r.rules[0];
await auto.tickUser(ann, { day: '2026-11-02', hm: '07:59' }); assert.equal(tasksOf(ann).length, 0, 'not before its time');
await auto.tickUser(ann, { day: '2026-11-02', hm: '08:00' }); assert.deepEqual(tasksOf(ann).map(t => t.title), ['Water the plants']);
await auto.tickUser(ann, { day: '2026-11-02', hm: '08:01' }); assert.equal(tasksOf(ann).length, 1, 'once a day'); await auto.tickUser(ann, { day: '2026-11-03', hm: '08:00' }); assert.equal(tasksOf(ann).length, 2, 'and again tomorrow');
await auto.tickUser(ann, { day: '2026-11-04', hm: '15:00' }); assert.equal(tasksOf(ann).length, 2, 'a time that passed hours ago is not caught up');
// a finished task starts matching rules, with the title filled in; the shopping action goes to the Shopping list
await post('/', { when: { type: 'task.done', match: 'milk' }, then: { type: 'task', text: 'Buy more after {title}' } }); await post('/', { when: { type: 'task.done', match: '' }, then: { type: 'notify', text: 'Nice, {title} is done' }, name: 'Cheer' });
await auto.fire(ann.id, 'task.done', { title: 'Buy bread' }); assert.ok(!tasksOf(ann).some(t => /Buy more/.test(t.title)), 'a rule that does not match does nothing');
await auto.fire(ann.id, 'task.done', { title: 'Buy milk' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy more after Buy milk'));
const inbox = require('../inbox').load(ann.id).filter(x => x.title === 'Cheer'); assert.ok(inbox.length >= 1 && /Nice, Buy (bread|milk) is done/.test(inbox[0].body), 'notify lands in the Notifications list');
// mail
await post('/', { when: { type: 'mail.new', match: 'invoice' }, then: { type: 'shopping', text: 'Pay {subject}' } }); await auto.fire(ann.id, 'mail.new', { from: 'Bob', subject: 'Lunch?' }); assert.ok(!tasksOf(ann).some(t => /^Pay /.test(t.title)));
await auto.fire(ann.id, 'mail.new', { from: 'Acme', subject: 'Your invoice 42' }); assert.ok(tasksOf(ann).some(t => t.title === 'Pay Your invoice 42'));
// off, private, capped
r = await j(await get()); assert.equal(r.rules.length, 4); assert.equal((await j(await get('bo'))).rules.length, 0, 'rules are personal'); assert.equal((await post('/delete', { id: daily.id }, 'bo')).status, 404, 'and cannot be touched by someone else');
const cheer = r.rules.find(x => x.name === 'Cheer'); await post('/toggle', { id: cheer.id, on: false }); const n0 = tasksOf(ann).length; await auto.fire(ann.id, 'task.done', { title: 'x' }); assert.equal(tasksOf(ann).length, n0, 'a rule that is off does not run');
await post('/', { when: { type: 'task.done', match: 'spam' }, then: { type: 'task', text: 'Again' } }); for (let i = 0; i < 30; i++) await auto.fire(ann.id, 'task.done', { title: 'spam' }); assert.equal(tasksOf(ann).filter(t => t.title === 'Again').length, 20, 'a rule runs at most 20 times a day');
// the pantry going low, and new podcast episodes (for everyone's rules; the first look only notes what is there)
assert.equal((await post('/', { when: { type: 'podcast.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'a show must be named');
await post('/', { when: { type: 'pantry.low', match: 'flour' }, then: { type: 'shopping', text: 'Buy {title}' } }); await post('/', { when: { type: 'podcast.new', match: 'daily' }, then: { type: 'task', text: 'Listen: {title} ({show})' } });
const n1 = tasksOf(ann).length; await auto.fireAll('pantry.low', { title: 'Milk' }); assert.equal(tasksOf(ann).length, n1, 'another item does not match'); await auto.fireAll('pantry.low', { title: 'Flour' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy Flour'), 'a low item starts the rule');
let eps = [{ id: '1', title: 'Old', show: 'The Daily' }]; const recent = async () => eps; assert.equal(await auto.checkEpisodes(recent), 0, 'the first look only notes what is there'); assert.ok(!tasksOf(ann).some(t => /^Listen/.test(t.title)));
eps = [{ id: '2', title: 'Big news', show: 'The Daily' }, { id: '3', title: 'Other', show: 'Cooking Hour' }, ...eps]; assert.equal(await auto.checkEpisodes(recent), 2); assert.deepEqual(tasksOf(ann).filter(t => /^Listen/.test(t.title)).map(t => t.title), ['Listen: Big news (The Daily)'], 'only the show asked for'); assert.equal(await auto.checkEpisodes(recent), 0, 'and not again');
assert.equal((await j(await post('/delete', { id: daily.id }))).rules.some(x => x.id === daily.id), false);
console.log('automations test ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close();
});
+8
View File
@@ -102,5 +102,13 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233');
r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400);
// invitations and each person's colour
r = await call(ann, '/api/cal/people'); ok('the calendar lists who can be invited (not yourself)', r.status === 200 && r.json.map(x => x.name).sort().join() === 'Bob,Cy' && /^#[0-9a-f]{6}$/.test(r.json[0].color), r.json);
r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night', '2026-11-05'), invite: [bob.id, ann.id, 'nobody'] }); ok('inviting adds the event for the invited people only', r.status === 200 && r.json.invited === 1, r.json);
ok('and it is on their calendar', (await titles(bob)).includes('Movie night') && !(await titles(cy)).includes('Movie night'));
r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night 2', '2026-11-05'), invite: [bob.id] }); r = await call(bob, '/api/cal/event', { cal: 'local', fields: F('Bob only', '2026-11-05'), invite: [] }); ok('no invite list, nobody invited', r.json.invited === 0);
{ const fe = await call(bob, '/api/cal/event', { cal: 'family~local', fields: F('Family dinner', '2026-11-04') }); const ev = (await call(cy, win)).json.events.find(e => e.title === 'Family dinner'); ok('a family event shows in the colour of whoever added it', ev && ev.color === users.colorOf(users.get(bob.id)), ev); }
r = await call(bob, '/api/cal/people'); ok('and nobody sees a disabled or calendar-less person', !r.json.some(x => x.id === bob.id));
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
})();
+47
View File
@@ -0,0 +1,47 @@
// The browser side, without a browser: every script the page loads is run in order in one shared scope (as the browser does), against a stand-in for the page.
// This catches what only showed up in the interface before: a syntax error, a name declared twice by two files (which stops the second file and everything after it),
// a script that throws as it loads, a missing file, and a script listed in index.html twice or not at all.
const fs = require('fs'), vm = require('vm'), path = require('path'), assert = require('assert');
const PUB = path.join(__dirname, '..', 'public');
const html = fs.readFileSync(path.join(PUB, 'index.html'), 'utf8');
const listed = [...html.matchAll(/<script[^>]*\ssrc="(\/[\w.-]+\.js)"/g)].map(m => m[1]);
assert.ok(listed.length > 40, 'index.html lists the scripts');
assert.equal(new Set(listed).size, listed.length, 'no script is listed twice');
for (const f of listed) assert.ok(fs.existsSync(path.join(PUB, f)), f + ' is listed but missing');
const unlisted = fs.readdirSync(PUB).filter(f => f.endsWith('.js') && !listed.includes('/' + f) && !/^(sw|login|board|device-ui|webauthn)\.js$/.test(f)); // (the service worker and the other pages have their own)
assert.deepEqual(unlisted, [], 'every script in public/ is loaded by the page (or is one of the known others)');
// a stand-in for the browser that answers anything: any property is another stand-in, calling it gives one, awaiting it gives it back
const stub = () => { const f = function () {}; const p = new Proxy(f, { get: (t, k) => k === Symbol.toPrimitive ? () => '' : k === 'then' ? undefined : k === 'length' ? 0 : k === Symbol.iterator ? function* () {} : p, apply: () => p, construct: () => p, set: () => true, has: () => true }); return p; };
const store = {}, win = {};
const ctx = Object.assign(win, {
console: { log() {}, warn() {}, error() {}, info() {}, debug() {} }, document: stub(), navigator: Object.assign(stub(), { onLine: true, userAgent: 'test' }), location: { hash: '', href: 'http://x/', origin: 'http://x', pathname: '/', search: '', reload() {}, replace() {} }, history: stub(),
localStorage: { getItem: k => store[k] ?? null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }, sessionStorage: stub(), matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
fetch: async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '', headers: { get: () => '' } }), addEventListener() {}, removeEventListener() {}, setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, requestAnimationFrame: () => 0,
Audio: function () { return stub(); }, Image: function () { return stub(); }, MutationObserver: function () { return { observe() {}, disconnect() {} }; }, IntersectionObserver: function () { return { observe() {}, disconnect() {}, unobserve() {} }; }, ResizeObserver: function () { return { observe() {}, disconnect() {} }; },
URL, URLSearchParams, Blob, TextEncoder, TextDecoder, AbortController, Intl, Date, JSON, Math, Promise, Set, Map, WeakMap, Array, Object, String, Number, RegExp, Error, Symbol, Uint8Array, ArrayBuffer, DataView, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN, structuredClone, atob, btoa,
screen: {}, innerWidth: 1000, innerHeight: 800, devicePixelRatio: 1, performance: { now: () => 0 }, crypto: stub(), indexedDB: undefined, caches: stub(), Notification: stub(), alert() {}, confirm: () => false, prompt: () => null, getComputedStyle: () => stub(), CSS: stub(),
});
ctx.window = ctx; ctx.self = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
process.on('unhandledRejection', () => {}); // (boot.js starts the app against the stand-in; what it does after loading is not under test)
const failures = [];
for (const f of listed) {
const file = path.join(PUB, f), src = fs.readFileSync(file, 'utf8');
try { new vm.Script(src, { filename: f }); } catch (e) { failures.push(`${f}: syntax error: ${e.message}`); continue; }
try { vm.runInContext(src, ctx, { filename: f }); } catch (e) { failures.push(`${f}: ${e.name}: ${e.message}`); }
}
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
// the pieces the app calls by name across files are there
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
// the watch-stats counting (a pure function)
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
assert.deepEqual([a.week.n, a.week.min, a.month.n, a.year.n], [2, 150, 3, 4], 'week, month and year counts and minutes'); assert.deepEqual(a.genres[0], ['Drama', 3]); assert.equal(a.days[12], 1); assert.equal(a.days[10], 1); assert.equal(a.days.reduce((x, y) => x + y, 0), 2, 'the 14-day strip'); }
// changes made offline: only the safe kinds are queued, in order, and tasks show them while they wait
{ const run = c => vm.runInContext(c, ctx); run("ME = { id: 'u1' }"); const body = o => JSON.stringify(o);
assert.equal(run("offQueued('/api/tasks', { method: 'GET' })"), null, 'a read is not queued'); assert.equal(run(`offQueued('/api/chat/send', { method: 'POST', body: ${JSON.stringify(body({ text: 'hi' }))} })`), null, 'a chat message is not one of the safe kinds');
assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 'x', fields: { title: 'edit' } }))} })`), null, 'editing an existing task is not queued (it could clash)');
assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', fields: { title: 'Buy milk', due: '' } }))} }).queued`), true); run(`offQueued('/api/tasks/toggle', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 't1', done: true }))} })`);
assert.equal(run('offPending()'), 2); const shown = run("offApplyTasks([{ id: 't1', title: 'Old', done: false, list: 'local:inbox' }])"); assert.deepEqual(shown.map(t => [t.title, t.done]), [['Old', true], ['Buy milk', false]], 'a queued tick and a queued new task show right away');
assert.equal(run("offqRead().length"), 2); run("localStorage.removeItem('offq:u1')"); assert.equal(run('offPending()'), 0); }
console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0);
+4
View File
@@ -11,5 +11,9 @@ users.runAs(me, () => {
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');
// e-books count too, and the family challenge adds everyone up
progress.markFinished('ebook:abc', 'ebook'); progress.markFinished('ebook:abc', 'ebook'); s = progress.stats(); assert.equal(s.ebooksYear, 1, 'an e-book counts once'); assert.equal(s.audiobooksYear, 1); assert.equal(s.finishedYear, 2, 'audiobooks and e-books together');
assert.equal(progress.familyGoal().finished, 2); assert.equal(progress.familyGoal().goal, 0); assert.equal(progress.setFamilyGoal(40).goal, 40); assert.equal(progress.setFamilyGoal(-5).goal, 0, 'no negative goals');
const bo = users.create({ name: 'Bo', role: 'user', handle: 'ZP' }); users.runAs(bo, () => progress.markFinished('ebook:zzz', 'ebook')); progress.setFamilyGoal(10); const fg = progress.familyGoal(); assert.equal(fg.finished, 3, 'everybody\'s books are counted'); assert.deepEqual(fg.people.map(p => p.name + p.n), ['Ann2', 'Bo1']);
console.log('listening test ok');
});
+13
View File
@@ -109,6 +109,19 @@ const SECRET = 'mail-test-secret-'.repeat(3);
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && /~M2$/.test(r.json.items[0].id), r.json);
r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status);
// unified inbox, snooze and templates
r = await call('unate', '/api/mail/messages?folder=unified'); ok('the unified inbox lists the newest of every account together', r.status === 200 && r.json.items.length === 2 && r.json.items.every(m => m.account && m.accountId) && r.json.more === false, r.json);
const sid = r.json.items[0].id; r = await post('unate', '/api/mail/snooze', { id: sid, until: Date.now() - 1000 }); ok('snoozing needs a future time', r.status === 400, r.status);
r = await post('unate', '/api/mail/snooze', { id: sid, until: Date.now() + 3600e3, subject: 'Hello', from: 'Ann' }); ok('a message can be snoozed', r.status === 200, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('a snoozed message leaves the inbox', r.json.items.length === 1 && !r.json.items.some(m => m.id === sid), r.json);
r = await call('unate', '/api/mail/messages?folder=unified'); ok('and the unified inbox', r.json.items.length === 1, r.json);
r = await call('unate', '/api/mail/snoozed'); ok('the snoozed list says what and until when', r.json.length === 1 && r.json[0].id === sid && r.json[0].subject === 'Hello', r.json);
r = await post('unate', '/api/mail/unsnooze', { id: sid }); r = await call('unate', '/api/mail/messages?folder=inbox'); ok('unsnoozing brings it back', r.json.items.length === 2, r.json);
r = await post('unate', '/api/mail/templates', { name: 'Thanks', subject: 'Thank you', body: 'Thanks so much!\nNate' }); ok('a template can be saved', r.status === 200 && r.json.length === 1 && r.json[0].name === 'Thanks' && r.json[0].id, r.json);
const tid = r.json[0].id; r = await post('unate', '/api/mail/templates', { id: tid, name: 'Thanks!', subject: 'Thank you', body: 'Hi' }); ok('and changed', r.json.length === 1 && r.json[0].name === 'Thanks!' && r.json[0].body === 'Hi', r.json);
r = await post('unate', '/api/mail/templates', { name: ' ' }); ok('a template needs a name', r.status === 400, r.status);
r = await call('uann', '/api/mail/templates'); ok('templates are private to a person', r.status === 200 || r.status === 409 ? !(r.json || []).length : false, r);
r = await post('unate', '/api/mail/templates/delete', { id: tid }); ok('and deleted', r.json.length === 0, r.json);
r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json;
ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html);
+26
View File
@@ -0,0 +1,26 @@
// Movies & TV for the family: favorites are per person; Movie night is one shared list with votes.
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mf-'));
const users = require('../users'), mf = require('../moviefam');
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['video'], handle: 'ZP' });
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => mf.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/moviefam', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = (p, who) => fetch(base + p, { headers: who ? { 'x-user': who } : {} });
const up = { id: '101', title: 'Up', year: 2009, type: 'movie', thumb: '/library/metadata/101/thumb/17' };
try {
// favorites
await post('/fav', { ...up, on: true }); assert.deepEqual((await j(await get('/favs'))).items.map(x => x.title), ['Up']); assert.equal((await j(await get('/favs', 'bo'))).items.length, 0, 'favorites are personal');
await post('/fav', { ...up, on: false }); assert.equal((await j(await get('/favs'))).items.length, 0);
assert.equal((await post('/fav', { id: '../x', title: 'x', on: true })).status, 400, 'a bad id is refused');
// a poster path that is not ours is dropped
await post('/fav', { ...up, thumb: 'https://evil.example/x.jpg', on: true }); assert.equal((await j(await get('/favs'))).items[0].thumb, '');
// movie night
let r = await j(await post('/night/add', up)); assert.equal(r.items[0].votes, 1, 'the person who adds it votes for it'); assert.equal((await post('/night/add', up)).status, 400, 'once only');
await post('/night/add', { id: '102', title: 'Ratatouille', year: 2007 }, 'bo'); r = await j(await post('/night/vote', { id: '102' }));
assert.equal(r.items[0].title, 'Ratatouille', 'the one with more votes comes first'); assert.deepEqual(r.items[0].voters.sort(), ['Ann', 'Bo']);
r = await j(await post('/night/vote', { id: '102' })); assert.equal(r.items.find(x => x.id === '102').votes, 1, 'voting again takes the vote back');
assert.equal((await post('/night/remove', { id: '101' }, 'bo')).status, 403, 'only the adder or an admin removes'); r = await j(await post('/night/remove', { id: '102' })); assert.equal(r.items.length, 1, 'an admin can remove anything');
assert.equal((await post('/night/clear', {}, 'bo')).status, 403); assert.equal((await j(await post('/night/clear', {}))).items.length, 0);
console.log('moviefam test ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close();
});
+5
View File
@@ -47,6 +47,11 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json(), { grants: [] }); assert.equal((await post(`/ext/${ext.id}/install`, {}, bob)).status, 200);
assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['tasks.read', 'tasks.read', 'root.everything'] }, bob)).json()).grants, ['tasks.read'], 'only known permissions are kept');
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`)).json(), { grants: [] }, 'they are per person'); await post(`/ext/${ext.id}/uninstall`, {}, bob); assert.deepEqual((await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json()).grants, [], 'removing the extension takes them back');
// the newer permissions, and "updated since you last looked"
await post(`/ext/${ext.id}/install`, {}, bob); assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['notes.read', 'notes.write', 'recipes.read', 'music.read', 'tasks.read', 'notes.delete'] }, bob)).json()).grants.sort(), ['music.read', 'notes.read', 'notes.write', 'recipes.read', 'tasks.read'], 'notes, recipes and music can be granted; made-up ones cannot');
let li = await (await fetch(`${B}/items`, { headers: bob })).json(); assert.equal(li.seen[ext.id], li.items.find(x => x.id === ext.id).updated, 'installing marks this version as seen');
await new Promise(r => setTimeout(r, 15)); await post('/items', { id: ext.id, title: ext.title, version: '2.0' }); li = await (await fetch(`${B}/items`, { headers: bob })).json(); assert.ok(li.items.find(x => x.id === ext.id).updated > li.seen[ext.id], 'a new version is newer than the one you saw');
li = await (await post(`/ext/${ext.id}/seen`, {}, bob)).json(); assert.equal(li.seen[ext.id], (await (await fetch(`${B}/items`, { headers: bob })).json()).items.find(x => x.id === ext.id).updated, 'and looking at it clears that'); await post(`/ext/${ext.id}/uninstall`, {}, bob);
// a site (a link, optionally shown inside the hub)
assert.equal((await post('/items', { kind: 'link', title: 'Site' })).status, 400, 'a site needs an address'); let site = await (await post('/items', { kind: 'link', title: 'Docs', url: 'https://example.com/', embed: true, icon: 'D' })).json(); assert.equal(site.embed, true); assert.equal(site.icon, 'D');
assert.equal((await put(`/items/${site.id}/files?name=a.html`, 'x')).status, 400, 'a site has no files');
+16
View File
@@ -58,6 +58,22 @@ 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 task with a due time gets a reminder when that time comes, once
{ const l = users.create({ name: 'Lee', role: 'user', tabs: ['tasks'] }), ld = users.dirOf(l.id); sent.length = 0;
fs.writeFileSync(path.join(ld, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 'a', list: 'inbox', title: 'Call the vet', due: day, dueTime: '08:30', done: false }, { id: 'b', list: 'inbox', title: 'Later today', due: day, dueTime: '17:00', done: false }, { id: 'c', list: 'inbox', title: 'Done already', due: day, dueTime: '08:00', done: true }] }));
fs.writeFileSync(path.join(ld, 'push.json'), JSON.stringify({ tz: TZ, tasksAt: '23:00', tasks: true, events: false, subs: [{ sub: { endpoint: 'https://push.example/lee', keys: { p256dh: 'x', auth: 'y' } } }] }));
await push.tick(now, true); const lee = () => sent.filter(x => x.sub.endpoint.endsWith('/lee'));
ok('a task whose time has come is reminded, with its time', lee().length === 1 && lee()[0].title === 'Call the vet' && /08:30/.test(lee()[0].body), lee());
await push.tick(new Date(now.getTime() + 6 * 60e3), true); ok('once only; later and finished tasks are left alone', lee().length === 1, lee()); }
// 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());
+10
View File
@@ -62,6 +62,16 @@ 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: items with a count and a low level; the plan flags planned ingredients you 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' });
assert.equal((await post('/pantry/item', { name: ' ' })).status, 400, 'an item needs a name');
j = await (await post('/pantry/item', { name: 'Flour', qty: 3, low: 1 })).json(); assert.deepEqual(j.items.map(x => [x.name, x.qty, x.low, x.isLow]), [['Flour', 3, 1, false]]); const fid = j.items[0].id;
j = await (await post('/pantry/item', { name: 'salt', qty: '', low: '' })).json(); assert.equal(j.items.find(x => x.name === 'salt').qty, null, 'an item need not be counted'); assert.equal(j.items.find(x => x.name === 'salt').isLow, false);
j = await (await post('/pantry/item', { name: 'FLOUR', qty: 3, low: 1 })).json(); assert.equal(j.items.filter(x => x.name.toLowerCase() === 'flour').length, 1, 'the same name updates the item');
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');
j = await (await post('/pantry/adjust', { id: fid, delta: -2 })).json(); assert.equal(j.items[0].qty, 1); assert.equal(j.items[0].isLow, true, 'at the low level counts as running low'); assert.equal(j.items[0].name, 'Flour', 'low ones come first');
j = await (await post('/pantry/adjust', { id: fid, delta: -5 })).json(); assert.equal(j.items[0].qty, 0, 'never below none'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
await post('/pantry/delete', { id: fid }); j = await (await get('/pantry')).json(); assert.deepEqual(j.items.map(x => x.name), ['salt']);
// 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);
+11
View File
@@ -45,6 +45,17 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
r = await call(`/api/tasks/due?from=${plus(0)}&to=${plus(7)}`);
ok('the calendar gets open tasks due in the range (not done ones, subtasks or other dates)', r.status === 200 && r.json.length === 1 && r.json[0].title === 'Due soon' && r.json[0].repeat === 'weekly' && r.json[0].listName, r.json);
r = await call('/api/tasks/due?from=bad&to=worse'); ok('a bad date range is refused', r.status === 400, r.status);
// a due time rides along with the due date (and only with one)
await call('/api/tasks', { list: L, fields: { title: 'At noon', notes: '', due: plus(1), dueTime: '12:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Time but no date', notes: '', due: '', dueTime: '09:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Bad time', notes: '', due: plus(1), dueTime: '25:99', done: false } });
ts = await list(); ok('a due time is kept with its date', ts.find(t => t.title === 'At noon').dueTime === '12:00', ts.find(t => t.title === 'At noon')); ok('a time with no date is dropped', ts.find(t => t.title === 'Time but no date').dueTime === ''); ok('and a time that is not a time', ts.find(t => t.title === 'Bad time').dueTime === '');
// subtasks on iCloud lists: a VTODO that points at its parent (RELATED-TO), kept through edits and ticking
{ const ics = require('../ics'), { parseTodos } = require('../tasks');
const main = ics.buildTodoCalendar([{ uid: 'p1@x', fields: { title: 'Trip', notes: '', due: '', done: false } }, { uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]);
let ts = parseTodos(main); ok('a subtask is written with its parent and read back with it', ts.length === 2 && ts[0].parent === '' && ts[1].parent === 'p1@x', ts);
const edited = ics.patchTodo(ics.buildTodoCalendar([{ uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]), { title: 'Book flights now', notes: '', due: '', done: true });
ok('ticking or editing it keeps its parent', parseTodos(edited)[0].parent === 'p1@x' && parseTodos(edited)[0].done === true, parseTodos(edited));
const moved = ics.patchTodo(edited, { title: 'x', notes: '', due: '', done: false, parent: '' }); ok('and an empty parent makes it a main task again', parseTodos(moved)[0].parent === '', parseTodos(moved));
const foreign = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:k@x\r\nSUMMARY:Kid\r\nRELATED-TO;RELTYPE=CHILD:z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; ok('a link that points down (CHILD) is not a parent', parseTodos(foreign)[0].parent === ''); }
ok('the server is still running', child.exitCode === null);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);
+6 -2
View File
@@ -63,6 +63,7 @@ function update(id, patch) {
if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; }
if (patch.board !== undefined) { if (patch.board === false) u.board = false; else delete u.board; } // whether they are on the family board and in family chore progress
if (patch.simple !== undefined) { if (patch.simple) u.simple = true; else delete u.simple; } /* simple mode: a big-button layout with few choices */
if (patch.color !== undefined) { if (/^#[0-9a-f]{6}$/i.test(patch.color)) u.color = patch.color.toLowerCase(); else delete u.color; } // the person's own colour on shared calendars
if (patch.signOutEverywhere) u.epoch++;
save(); return u;
}
@@ -76,7 +77,10 @@ function remove(id) {
const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab));
const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))];
const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple });
/** The colour a person's events and name show in on shared calendars: their own pick, or one of a set by the order accounts were made. */
const PERSON_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d'];
const colorOf = u => !u ? PERSON_COLORS[0] : u.color || PERSON_COLORS[Math.max(0, all().findIndex(x => x.id === u.id)) % PERSON_COLORS.length];
const publicView = u => ({ id: u.id, color: colorOf(u), name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple });
// ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings,
// iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.)
@@ -93,4 +97,4 @@ function migrateLegacy() {
}
migrateLegacy();
module.exports = { TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };
module.exports = { PERSON_COLORS, colorOf, TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };