Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s

- Chores tab with repeats, points and a week/month board
- Family page: calendar, chores, shopping, notes, chat and tasks on one start page
- Genre search in the Store, Free Books and LibriVox
- Child accounts: safe content, daily limit and bedtime, ask a parent
- Search now covers notes, chat, contacts, chores, recipes and your books
- Notifications center: bell with a per-person list of chat, reminders and announcements
- Roadmap, README and tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 11:48:55 -04:00
1 parent cf292e3bc7
commit 0383da87f5
38 files changed
+720 -79

No files matched your search

+12
View File
@@ -184,5 +184,17 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions
**Notes** (turn it on per person): notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone it is shared with can edit it. Checklist items change one at a time (tick, add, edit, remove), so two people ticking the same list never overwrite each other, ticked items say who ticked them, and an open list follows other people's changes every few seconds. Text edits are checked against a revision, so a stale edit is refused instead of overwriting someone else's. Pin, colour, archive, search and filters (lists, notes, mine, shared, archived), ready-made lists (grocery, packing, weekend to-do), *Share to Chat*, and *Send the open items to Tasks*. Only the person who made a note (or an admin) changes who it is shared with or deletes it.
**Chores** (turn it on per person): jobs that repeat daily, on weekdays, on chosen days of the week, weekly, monthly or happen once, given to one or more people or to anyone. Each person ticks off their own (an admin can tick for someone), finished chores earn points, and the board adds up the week and month. Only an admin adds or changes chores. Kept in `data/chores.json`.
**Family** (the page every account has next to Home): today and tomorrow's calendar, your chores and everyone's progress, the shopping list, shared notes and lists, recent chats and the tasks due this week. Each card only shows if the person has that part of the hub.
**Child accounts** (admin screen, per person): the Store, Free Books and podcasts show only child-safe books and non-explicit shows, LibriVox opens on children's books, and an optional daily time limit and bedtime are enforced by the server. A child who runs out of time sees a lock screen and can ask a parent, which sends a message in Chat; an admin can give extra minutes.
**Genre search**: in the Store, Free Books and LibriVox you can browse and search books and audiobooks by genre (Fiction, Mystery, History, Children's, Christian and more). LibriVox genres use an index the server builds in the background the first time.
**Search** covers mail, tasks, events, notes, chat messages, contacts, chores, recipes, your books, files, music, video, podcasts and the web, and only shows what the person has access to.
**Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`.
On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon.
+2 -2
View File
@@ -6,7 +6,7 @@ const audit = require('./audit');
const { readBody } = require('./body');
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const row = u => ({ ...users.publicView(u), tabsChosen: u.tabs || [], passkeys: auth.passkeyCount(u.id) });
const row = u => ({ ...users.publicView(u), kid: u.kid || null, tabsChosen: u.tabs || [], passkeys: auth.passkeyCount(u.id) });
async function handle(req, res, me) {
try {
@@ -26,7 +26,7 @@ async function handle(req, res, me) {
const b = JSON.parse(await readBody(req) || '{}');
if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' });
audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req);
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere });
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere, kid: b.kid });
return json(res, 200, { user: row(users.get(id)) });
}
if (!action && req.method === 'DELETE') {
+19 -2
View File
@@ -5,6 +5,7 @@ const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users');
const inbox = require('./inbox');
const push = require('./push');
const cal = require('./cal'); // (its public-address check, for link previews)
const { readBody } = require('./body');
@@ -88,6 +89,7 @@ function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) {
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id });
if (!sys) for (const uid of c.members) if (uid !== me.id && !online(uid) && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid))) && !quietNow(uid)) push.notify(uid, { title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id }).catch(() => {});
if (!sys) { const url = /https:\/\/[^\s<>"']+/.exec(text || ''); if (url) previewOf(url[0].replace(/[.,;:!?)\]]+$/, '')).then(p => { if (p && msgs(c.id).includes(m) && !m.deleted) { m.link = p; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: m.id }); } }).catch(() => {}); }
return msgView(m, me.id, c, stsOf(c));
@@ -183,7 +185,7 @@ async function handle(req, res) {
if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it
if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim();
if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); }
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family' }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
}
if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet });
if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); }
@@ -222,4 +224,19 @@ async function handle(req, res) {
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, totalUnread, purge, parsePreview };
/** A child asks the parents (the admins) for something: a message in a direct chat with each of them. Returns how many were told. */
function askParents(me, text) {
let n = 0;
for (const a of users.all().filter(u => u.role === 'admin' && !u.disabled && u.id !== me.id)) {
const id = 'd:' + [me.id, a.id].sort().join(':'); let c = convs().find(x => x.id === id); if (!c) { c = { id, type: 'direct', members: [me.id, a.id].sort(), by: me.id, created: Date.now() }; convs().push(c); saveConvs(); }
addMsg(me, c, { text: str(text, 300) }); n++;
}
return n;
}
/** Messages in this person's chats that mention the words. */
function searchFor(me, q) {
const needle = q.toLowerCase(), out = [], st = stateOf(me.id);
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8);
}
module.exports = { handle, totalUnread, purge, parsePreview, askParents, searchFor };
+100
View File
@@ -0,0 +1,100 @@
// Chores: jobs that repeat (daily, weekdays, on chosen days of the week, weekly, monthly) or happen once, given to one or more people or to "anyone". Each person ticks off their
// own; an admin can tick for someone. Finished chores earn points, which the family board adds up for the week and month. Kept in DATA_DIR/chores.json.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'chores.json');
const REPEATS = ['daily', 'weekdays', 'weekly', 'monthly', 'once'], MAX_CHORES = 300, MAX_LOG = 50000;
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-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const newId = () => crypto.randomBytes(6).toString('hex');
const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || '') && !Number.isNaN(Date.parse(s + 'T12:00:00Z'));
const utc = s => new Date(s + 'T12:00:00Z');
const ymd = d => d.toISOString().slice(0, 10);
const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n); return ymd(d); };
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
let cache = null;
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [] }; } catch { return { chores: [], log: [] }; } })());
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chores')).map(u => ({ id: u.id, name: u.name }));
/** The period a chore is due in on this day (the key its completion is filed under), or null when it is not due. Weekly chores with no chosen days, monthly and one-off chores stay due until done. */
function dueKey(c, day) {
if (!c.active || (c.start && day < c.start)) return null; const wd = weekday(day);
switch (c.repeat) {
case 'daily': return day;
case 'weekdays': return wd >= 1 && wd <= 5 ? day : null;
case 'weekly': return c.days && c.days.length ? (c.days.includes(wd) ? day : null) : monday(day);
case 'monthly': { const dom = c.dom || 1, [y, m, d] = day.split('-').map(Number), last = new Date(Date.UTC(y, m, 0)).getUTCDate(); return d >= Math.min(dom, last) ? day.slice(0, 7) : null; }
case 'once': return c.date && day >= c.date ? 'once' : null;
default: return null;
}
}
const assignees = c => Array.isArray(c.assign) ? c.assign.filter(live) : [];
/** Everything due on a day, for everyone: one entry per person it is given to (or one for "anyone"), with who finished it. */
function board(day) {
const out = [];
for (const c of db().chores) {
const key = dueKey(c, day); if (!key) continue; const who = assignees(c);
const targets = c.assign === 'anyone' || !who.length ? [null] : who;
for (const u of targets) {
const done = db().log.find(l => l.chore === c.id && l.key === key && (u === null || l.user === u));
if (c.repeat === 'once' && !done && c.date && c.date > day) continue; // (a one-off is shown from its day on)
out.push({ chore: c.id, key, title: c.title, notes: c.notes, points: c.points, repeat: c.repeat, user: u, userName: u ? nameOf(u) : 'Anyone', done: done ? { by: done.by, byName: nameOf(done.by), at: done.at, pts: done.pts } : null, overdue: !done && c.repeat === 'once' && c.date < day });
}
}
return out.sort((a, b) => (a.done ? 1 : 0) - (b.done ? 1 : 0) || a.title.localeCompare(b.title));
}
function clean(b, old) {
const title = str(b.title, 120); if (!title) throw fail(400, 'Give the chore a name');
const repeat = REPEATS.includes(b.repeat) ? b.repeat : 'daily'; const c = { id: old ? old.id : newId(), title, notes: str(b.notes, 400), repeat, points: Math.max(0, Math.min(50, Math.round(+b.points) || 0)), active: b.active === false ? false : true, created: old ? old.created : Date.now(), start: ymdOk(b.start) ? b.start : (old ? old.start : undefined) };
c.assign = b.assign === 'anyone' ? 'anyone' : [...new Set((Array.isArray(b.assign) ? b.assign : []).map(String))].filter(live).slice(0, 20);
if (c.assign !== 'anyone' && !c.assign.length) c.assign = 'anyone';
if (repeat === 'weekly') c.days = [...new Set((Array.isArray(b.days) ? b.days : []).map(Number).filter(d => d >= 0 && d <= 6))].sort();
if (repeat === 'monthly') c.dom = Math.max(1, Math.min(31, Math.round(+b.dom) || 1));
if (repeat === 'once') { if (!ymdOk(b.date)) throw fail(400, 'Choose the day for this one-off chore'); c.date = b.date; }
return c;
}
/** Points and counts per person over a stretch of days ending on `end`. */
function summary(range, end) {
const days = range === 'month' ? 30 : 7, from = addDays(end, -(days - 1)), per = new Map(people().map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, perDay: Array(days).fill(0) }]));
for (const l of db().log) { if (l.date < from || l.date > end) continue; const e = per.get(l.by) || (live(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, perDay: Array(days).fill(0) }).get(l.by) : null); if (!e) continue; e.points += l.pts || 0; e.count++; e.perDay[Math.round((utc(l.date) - utc(from)) / 864e5)]++; }
return { range, from, to: end, people: [...per.values()].sort((a, b) => b.points - a.points || b.count - a.count) };
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/chores'.length) || '/', me = users.current(), q = u.searchParams, admin = me.role === 'admin';
try {
let m;
const day = q.get('date') || ymd(new Date()); if (!ymdOk(day)) throw fail(400, 'Bad date');
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin }, day, people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
if (p === '/' && req.method === 'POST') {
if (!admin) throw fail(403, 'Only an admin can add or change chores'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), old = b.id ? db().chores.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Chore not found'); if (!old && db().chores.length >= MAX_CHORES) throw fail(400, 'That is plenty of chores');
const c = clean(b, old); if (old) db().chores[db().chores.indexOf(old)] = c; else db().chores.push(c); save(); return json(res, 200, c);
}
if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { if (!admin) throw fail(403, 'Only an admin can delete chores'); const d = db(); d.chores = d.chores.filter(x => x.id !== m[1]); d.log = d.log.filter(l => l.chore !== m[1]); save(); return json(res, 200, { ok: true }); }
if (p === '/done' && req.method === 'POST') { // tick a chore off (or take the tick back)
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = db().chores.find(x => x.id === String(b.chore)), date = b.date || ymd(new Date()); if (!c) throw fail(404, 'Chore not found'); if (!ymdOk(date)) throw fail(400, 'Bad date');
const today = ymd(new Date()); if (date > addDays(today, 1) || date < addDays(today, -7)) throw fail(400, 'That day is out of range');
const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day');
const who = c.assign === 'anyone' ? null : String(b.user || me.id); if (who && !assignees(c).includes(who)) throw fail(400, 'That chore is not given to that person');
if (who && who !== me.id && !admin) throw fail(403, 'You can only tick off your own chores');
const idx = db().log.findIndex(l => l.chore === c.id && l.key === key && (who === null || l.user === who));
if (b.undo) { if (idx < 0) return json(res, 200, { ok: true }); const l = db().log[idx]; if (l.by !== me.id && !admin) throw fail(403, 'Only the person who ticked it can take it back'); db().log.splice(idx, 1); save(); return json(res, 200, { ok: true }); }
if (idx >= 0) return json(res, 200, { ok: true, already: true });
const by = who || me.id, entry = { chore: c.id, key, date, user: who, by, at: Date.now(), pts: c.points }; db().log.push(entry); if (db().log.length > MAX_LOG) db().log.splice(0, db().log.length - MAX_LOG); save(); return json(res, 200, { ok: true, pts: c.points });
}
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
/** Chores whose name or notes mention the words. */
const find = q => db().chores.filter(c => (c.title + ' ' + c.notes).toLowerCase().includes(q)).map(c => ({ id: c.id, title: c.title, repeat: c.repeat, who: c.assign === 'anyone' ? 'Anyone' : assignees(c).map(nameOf).join(', '), points: c.points }));
module.exports = { handle, dueKey, board, summary, ymd, addDays, find };
+6 -6
View File
@@ -42,22 +42,22 @@ A messenger inside the hub for the family, so family chats do not need Signal. E
Not planned: voice and video calls, stories, usernames and phone-number lookup, sealed sender.
## 2. Family core
- [ ] **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
- [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)
- [ ] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
- [ ] **Meal planning (M):** a weekly plan from Recipes that builds the shopping list (and can flag what is already in the pantry)
- [ ] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list
## 3. Kids and safety
- [ ] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits
- [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.**
- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves
- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents
## 4. Everyday convenience
- [ ] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home
- [ ] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas
@@ -66,7 +66,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
- [ ] **Podcast and audiobook stats (S):** listening history like Music has
- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights
- [ ] **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.
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
- [ ] **Casting (M):** pick AirPlay or Cast targets for audio
## 6. Reliability (the admin's side)
+24
View File
@@ -0,0 +1,24 @@
// One list of genres for books and audiobooks, with how each source says it: Project Gutenberg subjects ("s." searches), Standard Ebooks tags, Internet Archive and Open Library
// subjects, Smashwords category numbers, and a pattern for LibriVox's own genre names (LibriVox's genre filter does not work, so its genres are matched locally).
const GENRES = [
{ id: 'fiction', label: 'Fiction', gu: 'fiction', se: 'Fiction', ia: 'fiction', ol: 'fiction', sw: 3, lv: /fiction|novel/i },
{ id: 'mystery', label: 'Mystery', gu: 'mystery', se: 'Mystery', ia: 'mystery', ol: 'mystery_and_detective_stories', sw: 879, lv: /mystery|crime|detective/i },
{ id: 'fantasy', label: 'Fantasy', gu: 'fantasy', se: 'Fantasy', ia: 'fantasy', ol: 'fantasy', sw: 1206, lv: /fantas|myths|fairy/i },
{ id: 'scifi', label: 'Science fiction', gu: 'science fiction', se: 'Science Fiction', ia: 'science fiction', ol: 'science_fiction', sw: 1213, lv: /science fiction|sci-fi/i },
{ id: 'adventure', label: 'Adventure', gu: 'adventure', se: 'Adventure', ia: 'adventure', ol: 'adventure_and_adventurers', sw: 892, lv: /adventure|nautical|western/i },
{ id: 'classics', label: 'Classics', gu: 'classic', se: null, ia: 'classics', ol: 'classic_literature', sw: 1348, lv: /classic|epic/i },
{ id: 'children', label: "Children's", gu: 'juvenile', se: 'Children', ia: 'children', ol: 'juvenile_fiction', sw: 61, lv: /child/i },
{ id: 'history', label: 'History', gu: 'history', se: 'History', ia: 'history', ol: 'history', sw: 884, lv: /history|historical/i },
{ id: 'biography', label: 'Biography', gu: 'biography', se: 'Biography', ia: 'biography', ol: 'biography', sw: 1105, lv: /biograph|memoir/i },
{ id: 'faith', label: 'Faith', gu: 'religion', se: 'Religion', ia: 'religion', ol: 'religion', sw: 1223, lv: /religion|bible|christian|spiritual/i },
{ id: 'poetry', label: 'Poetry', gu: 'poetry', se: 'Poetry', ia: 'poetry', ol: 'poetry', sw: 56, lv: /poetry|sonnet/i },
{ id: 'humor', label: 'Humor', gu: 'humor', se: 'Humor', ia: 'humor', ol: 'humor', sw: 882, lv: /humor|satire/i },
{ id: 'philosophy', label: 'Philosophy', gu: 'philosophy', se: 'Philosophy', ia: 'philosophy', ol: 'philosophy', sw: null, lv: /philosoph/i },
{ id: 'science', label: 'Science', gu: 'science', se: 'Science', ia: 'science', ol: 'science', sw: null, lv: /science|astronomy|physics|psycholog|nature/i },
{ id: 'travel', label: 'Travel', gu: 'travel', se: 'Travel', ia: 'travel', ol: 'travel', sw: null, lv: /travel|geograph/i },
{ id: 'cooking', label: 'Cooking', gu: 'cooking', se: 'Cooking', ia: 'cooking', ol: 'cooking', sw: null, lv: /cooking|house & home/i },
];
const byId = id => GENRES.find(g => g.id === id) || null;
const list = () => GENRES.map(({ id, label }) => ({ id, label }));
const KID = ['children', 'adventure', 'fantasy', 'classics', 'history', 'biography', 'science', 'poetry', 'humor', 'faith', 'travel', 'cooking']; // what a child account may browse by
module.exports = { GENRES, byId, list, KID };
+33
View File
@@ -0,0 +1,33 @@
// Notifications center: a short list of what happened for each person (chat messages, reminders, announcements) so nothing depends on a phone buzz being seen.
// Each person's list lives in their own folder (inbox.json); a chat conversation keeps one entry that grows a count instead of filling the list.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const MAX = 120, json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const file = uid => path.join(users.dirOf(uid), 'inbox.json');
const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf8')); return Array.isArray(j.items) ? j.items : []; } catch { return []; } };
const save = (uid, items) => { try { fs.mkdirSync(path.dirname(file(uid)), { recursive: true }); atomicWrite(file(uid), JSON.stringify({ items }), { mode: 0o600 }); } catch (e) { console.warn('[inbox]', e.message); } };
/** Add something for a person. With a `tag`, a still-unread entry with the same tag is updated (and counted) instead of added again. */
function add(uid, { kind = 'info', title, body = '', url = '', tag = '' }) {
if (!users.get(uid)) return; const items = load(uid), now = Date.now();
const old = tag ? items.find(i => i.tag === tag && !i.read) : null;
if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; items.sort((a, b) => b.at - a.at); }
else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), at: now, read: false, count: 1 });
save(uid, items.slice(0, MAX));
}
const unread = uid => load(uid).filter(i => !i.read).length;
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname.slice('/api/inbox'.length) || '/', me = users.current();
try {
if (p === '/' && req.method === 'GET') { const items = load(me.id); return json(res, 200, { items, unread: items.filter(i => !i.read).length }); }
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); return json(res, 200, { unread: items.filter(i => !i.read).length }); }
if (p === '/clear' && req.method === 'POST') { save(me.id, []); return json(res, 200, { unread: 0 }); }
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, add, unread, load };
+50
View File
@@ -0,0 +1,50 @@
// Child accounts: a daily time limit and a bedtime, enforced here on the server (so a child cannot get round them by changing the device's clock or reloading), with a way to ask
// a parent for more time. Time counts while the app is open and visible (the app pings once a minute). A locked account can still reach its own status and the "ask a parent" button.
const fs = require('fs'), path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const push = require('./push');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'kid-usage.json');
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 load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } };
const save = d => atomicWrite(FILE(), JSON.stringify(d), { mode: 0o600 });
const hm = /^([01]\d|2[0-3]):[0-5]\d$/;
/** What a child account may be set to: { on, limitMin (0 = no limit), bedtime: { on, from, to }, safe }. */
function cleanKid(k) {
if (!k || typeof k !== 'object') return undefined; const n = Math.round(+k.limitMin) || 0, b = k.bedtime || {};
return { on: !!k.on, limitMin: n <= 0 ? 0 : Math.max(15, Math.min(720, n)), bedtime: { on: !!b.on, from: hm.test(b.from) ? b.from : '20:30', to: hm.test(b.to) ? b.to : '07:00' }, safe: k.safe !== false };
}
const tzOf = uid => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || process.env.HUB_TZ || 'UTC'; } catch { return process.env.HUB_TZ || 'UTC'; } };
/** Where this child stands right now. */
function status(u) {
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return { on: false };
const now = push.localNow(tzOf(u.id)), day = load()[u.id] && load()[u.id][now.day] || { secs: 0, extra: 0 }, limit = k.limitMin ? k.limitMin + (day.extra || 0) : 0, used = Math.floor((day.secs || 0) / 60);
const q = k.bedtime || {}, asleep = !!q.on && (q.from <= q.to ? now.hm >= q.from && now.hm < q.to : now.hm >= q.from || now.hm < q.to);
const locked = asleep ? 'bedtime' : limit && used >= limit ? 'time' : null;
return { on: true, used, limit, left: limit ? Math.max(0, limit - used) : null, extra: day.extra || 0, bedtime: q.on ? { from: q.from, to: q.to } : null, locked, day: now.day };
}
function ping(u) {
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return status(u); const st = status(u); if (st.locked) return st;
const d = load(), now = push.localNow(tzOf(u.id)), mine = d[u.id] || (d[u.id] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }), t = Date.now();
day.secs += day.last ? Math.min(90, Math.max(0, (t - day.last) / 1000)) : 30; day.last = t; for (const k2 of Object.keys(mine)) if (k2 < new Date(t - 40 * 864e5).toISOString().slice(0, 10)) delete mine[k2]; save(d); return status(u);
}
function give(uid, minutes) { const u = users.get(uid); if (!u || !u.kid || !u.kid.on) throw fail(400, 'That is not a child account'); const d = load(), now = push.localNow(tzOf(uid)), mine = d[uid] || (d[uid] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }); day.extra = Math.max(0, Math.min(600, (day.extra || 0) + Math.round(+minutes || 0))); save(d); return status(u); }
const ALLOWED_WHILE_LOCKED = /^\/api\/(me|kid(\/|$)|csp-report|push\/)/;
/** For the server: should this request be refused because the account is locked? Returns the status when it should. */
function lockedFor(u, pathname) { if (!u || !u.kid || !u.kid.on || u.role === 'admin' || ALLOWED_WHILE_LOCKED.test(pathname)) return null; const st = status(u); return st.locked ? st : null; }
async function handle(req, res, me, hooks = {}) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kid'.length) || '/';
try {
if (p === '/status' && req.method === 'GET') return json(res, 200, status(me));
if (p === '/ping' && req.method === 'POST') return json(res, 200, ping(me));
if (p === '/ask' && req.method === 'POST') { if (!me.kid || !me.kid.on) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').slice(0, 300).trim() || 'Can I have a little more time, please?'; const n = hooks.askParents ? hooks.askParents(me, text) : 0; return json(res, 200, { ok: true, told: n }); }
if (p === '/extra' && req.method === 'POST') { if (me.role !== 'admin') throw fail(403, 'Only a parent (an admin) can give more time'); const b = JSON.parse(await readBody(req, 1e4) || '{}'); return json(res, 200, give(String(b.user || ''), b.minutes)); }
if (p === '/usage' && req.method === 'GET') { if (me.role !== 'admin') throw fail(403, 'Only an admin can see this'); const t = users.get(u.searchParams.get('user') || ''); if (!t) throw fail(404, 'Person not found'); return json(res, 200, status(t)); }
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, status, ping, give, lockedFor, cleanKid };
+22 -2
View File
@@ -1,7 +1,7 @@
// Free audiobooks from LibriVox (public-domain books read by volunteers). The hub searches LibriVox's own catalogue, hands the browser each chapter's audio
// address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one.
const fs = require('fs'), path = require('path');
const users = require('./users');
const users = require('./users'), genres = require('./genres');
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
@@ -23,6 +23,18 @@ async function api(query) { // LibriVox answers 404 with a message when nothing
}
const author = b => (b.authors || []).map(a => [a.first_name, a.last_name].filter(Boolean).join(' ')).join(', ');
const summary = b => ({ id: String(b.id), title: plain(b.title), author: author(b), secs: Math.round(+b.totaltimesecs || 0), language: b.language || '', year: b.copyright_year || '', description: plain(b.description).slice(0, 800), cover: b.coverart_jpg || b.coverart_thumbnail || '', link: b.url_librivox || '' });
// ---- genres: LibriVox's own genre filter does not work, so the genres of its catalogue are gathered once (a page of 100 books at a time, with their genres) into a small
// index kept in the data folder, and refreshed weekly in the background. Searching by genre looks through that index.
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), IDX = () => path.join(DATA_DIR, 'librivox-index.json');
let idx = null, building = null;
const loadIdx = () => { if (idx) return idx; try { const j = JSON.parse(fs.readFileSync(IDX(), 'utf8')); if (j && Array.isArray(j.books)) idx = j; } catch { /* none yet */ } return idx; };
async function crawl() {
const pages = +process.env.LIBRIVOX_INDEX_PAGES || 30, books = [], seen = new Set();
for (let i = 0; i < pages; i += 2) for (const l of await Promise.all([i, i + 1].filter(n => n < pages).map(n => api(`extended=1&limit=100&offset=${n * 100}`).catch(() => [])))) for (const b of l) if (!seen.has(b.id)) { seen.add(b.id); books.push({ ...summary(b), genres: (b.genres || []).map(g => g.name).filter(Boolean) }); }
if (books.length) { idx = { t: Date.now(), books }; fs.mkdirSync(path.dirname(IDX()), { recursive: true }); const tmp = IDX() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(idx)); fs.renameSync(tmp, IDX()); }
}
function ensureIndex() { const ix = loadIdx(); if ((!ix || Date.now() - ix.t > 7 * 864e5) && !building) building = crawl().catch(e => console.warn('[librivox] index:', e.message)).finally(() => { building = null; }); return ix; }
const inGenre = (b, g) => b.genres.some(n => g.lv.test(n));
const cache = new Map(); // book id -> { t, book } (chapters included)
async function book(id) {
if (!/^\d{1,9}$/.test(id)) throw fail(400, 'Bad book');
@@ -38,6 +50,14 @@ async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab/free'.length) || '/';
try {
let m;
if (p === '/genres' && req.method === 'GET') { const ix = ensureIndex(); return json(res, 200, { building: !!building, ready: !!ix, genres: genres.list().map(g => ({ ...g, count: ix ? ix.books.filter(b => inGenre(b, genres.byId(g.id))).length : undefined })) }); }
const me0 = users.current(), safe = !!(me0 && me0.kid && me0.kid.on && me0.kid.safe); if (safe && p === '/search' && !genres.KID.includes(u.searchParams.get('genre') || '')) u.searchParams.set('genre', 'children'); // (a child account browses a few safe genres)
if (p === '/search' && req.method === 'GET' && u.searchParams.get('genre')) { // by genre: from the index
const g = genres.byId(u.searchParams.get('genre')), q = (u.searchParams.get('q') || '').trim().slice(0, 100).toLowerCase(), page = Math.min(50, Math.max(1, +u.searchParams.get('page') || 1)); if (!g) return json(res, 200, { items: [], more: false });
const ix = ensureIndex(); if (!ix) return json(res, 200, { items: [], more: false, building: true });
const words = q.split(/\s+/).filter(Boolean), hits = ix.books.filter(b => inGenre(b, g) && words.every(w => (b.title + ' ' + b.author + ' ' + b.description).toLowerCase().includes(w)));
return json(res, 200, { items: hits.slice((page - 1) * 20, page * 20).map(({ genres: gs, ...b }) => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}?u=${encodeURIComponent(b.cover)}` : '' })), more: hits.length > page * 20, building: !!building, total: hits.length });
}
if (p === '/search' && req.method === 'GET') {
const q = (u.searchParams.get('q') || '').trim().slice(0, 100), page = Math.min(50, Math.max(1, +u.searchParams.get('page') || 1)), off = (page - 1) * 20;
const lists = q ? await Promise.all([api(`title=${encodeURIComponent(q)}&limit=20&offset=${off}`), q.split(/\s+/).length <= 2 ? api(`author=${encodeURIComponent(q.split(/\s+/).pop())}&limit=20&offset=${off}`) : []]) : [await api(`limit=20&offset=${off}`)];
@@ -83,4 +103,4 @@ async function handle(req, res) {
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
}
module.exports = { handle, book, allowed };
module.exports = { handle, book, allowed, crawl, loadIdx };
+8 -1
View File
@@ -58,4 +58,11 @@ async function handle(req, res) {
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message, ...(e.extra || {}) }); }
}
module.exports = { handle };
/** Notes and lists this person can see that mention the words: [{ id, type, title, snippet, share }]. */
function search(me, q) {
const needle = q.toLowerCase(), out = [];
for (const n of all()) { if (!sees(n, me) || n.archived) continue; const text = n.type === 'list' ? n.items.map(i => i.text).join(' · ') : n.body, hay = (n.title + ' ' + text).toLowerCase(); if (!hay.includes(needle)) continue;
const at = text.toLowerCase().indexOf(needle); out.push({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), snippet: (at > 20 ? '…' : '') + text.slice(Math.max(0, at - 20), Math.max(0, at - 20) + 90), share: n.share === 'family' ? 'Family' : n.share === 'me' ? 'Just me' : 'Shared' }); }
return out.slice(0, 8);
}
module.exports = { handle, search };
+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; do node tools/$t-test.js || exit 1; done",
"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; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+4 -3
View File
@@ -30,14 +30,14 @@ async function piGet(path) {
const r = await fetch(PI_URL + path, { headers: { 'user-agent': 'NE-Hub/1.0', 'x-auth-key': process.env.PODCASTINDEX_KEY, 'x-auth-date': date, authorization: sig }, signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status); return r.json();
}
const piShape = f => ({ title: f.title, author: f.author || f.ownerName || '', feedUrl: f.url, image: f.image || f.artwork || '' });
const piShape = f => ({ title: f.title, author: f.author || f.ownerName || '', feedUrl: f.url, image: f.image || f.artwork || '', explicit: !!f.explicit });
const piKeep = f => f && /^https?:\/\//i.test(f.url || '') && !f.dead;
const piSearch = async q => ((await piGet('/search/byterm?max=20&q=' + encodeURIComponent(q))).feeds || []).filter(piKeep).map(piShape);
const piTrending = async cat => ((await piGet('/podcasts/trending?max=20&lang=en&cat=' + encodeURIComponent(cat))).feeds || []).filter(piKeep).map(piShape);
async function appleSearch(q) {
const r = await fetch((process.env.APPLE_PODCASTS_URL || 'https://itunes.apple.com/search') + '?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status);
return ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100 }));
return ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100, explicit: x.collectionExplicitness === 'explicit' || x.trackExplicitness === 'explicit' }));
}
const idOf = o => o && (o.ID !== undefined ? o.ID : o.id);
async function podgrab(p, init = {}) {
@@ -63,7 +63,8 @@ async function handle(req, res) {
if (pi.status === 'rejected' && ap.status === 'rejected') return json(res, 502, { error: 'The podcast directory did not answer' });
const seen = new Set(), out = [];
for (const x of [...(pi.value || []), ...(ap.value || [])]) { const k = x.feedUrl.replace(/\/+$/, '').toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(x); } }
return json(res, 200, out.slice(0, 30));
const me0 = users.current(), safe = !!(me0 && me0.kid && me0.kid.on && me0.kid.safe); // (a child account does not see podcasts marked explicit)
return json(res, 200, out.filter(x => !(safe && x.explicit)).slice(0, 30));
}
if (p === '/discover/topics') return json(res, 200, piOn() ? PI_TOPICS : []);
if (p === '/progress') {
+35 -7
View File
@@ -4,6 +4,7 @@ const _fetch = window.fetch.bind(window);
window.fetch = async (...a) => {
const r = await _fetch(...a);
if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login');
if (r.status === 403 && r.headers.get('x-hub-limit') && typeof ME !== 'undefined' && ME && ME.kid) r.clone().json().then(j => { kidUi.st = j.status || { on: true, locked: j.limit }; kidPaint(); }).catch(() => {}); // (a child account whose time is up: show the lock screen)
return r;
};
const $ = s => document.querySelector(s);
@@ -168,8 +169,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', notes: 'doc', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
@@ -179,14 +180,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS)
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'notes', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
const navGroups = (tabs = navTabs()) => {
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
return out.filter(g => g.tabs.length);
};
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', notes: '#e5b800', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
@@ -256,7 +257,7 @@ function drawSubnav(name) {
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = '';
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -324,6 +325,7 @@ function homeApps() {
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'),
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'),
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
@@ -336,7 +338,7 @@ function homeApps() {
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
};
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
const HOME_GROUPS = [['Work', ['planner', 'notes', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
}
@@ -349,7 +351,7 @@ function viewHome() {
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
$('#view').innerHTML = `<div class="wrap home">
<header class="homehead"><div><h1>${greet}</h1><p class="sub">${today}</p></div>
<div class="homeact"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
<div class="homeact">${inboxBell()}<button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
<div id="annc"></div>
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
${allowed('music') ? '<div id="npslot"></div>' : ''}
@@ -362,6 +364,7 @@ function viewHome() {
${evening ? '' : '<div id="resume"></div>'}
<div class="homeapps">${homeApps()}</div><div id="homeext"></div></div>`;
if (typeof stHomeExt === 'function' && allowed('store')) stHomeExt($('#homeext'));
inboxWire();
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
if (typeof homeResume === 'function') homeResume($('#resume'));
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
@@ -391,3 +394,28 @@ async function walkSearch(repos, term) {
}
return out;
}
// ---- A child account: the app counts the time it is open (a ping each minute), shows how much is left, and locks when the time is up or it is bedtime (the server refuses
// requests too, so this screen is only the explanation). "Ask a parent" sends a message to the parents.
const kidUi = { st: null, timer: null, told: new Set() };
async function kidStart() {
if (!ME || !ME.kid || !ME.kid.on || kidUi.timer) return;
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST' }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
await tick(); kidUi.timer = setInterval(tick, 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); });
}
function kidPaint() {
const st = kidUi.st; if (!st || !st.on) return; let chip = document.getElementById('kidchip');
if (st.left !== null && !st.locked) {
if (!chip) { chip = document.createElement('div'); chip.id = 'kidchip'; document.body.appendChild(chip); }
chip.textContent = '⏱ ' + (st.left >= 60 ? Math.floor(st.left / 60) + ' h ' + (st.left % 60) + ' min' : st.left + ' min') + ' left today'; chip.classList.toggle('low', st.left <= 10);
for (const m of [10, 5, 1]) if (st.left <= m && !kidUi.told.has(m) && st.left > 0) { kidUi.told.add(m); toast(st.left + (st.left === 1 ? ' minute' : ' minutes') + ' left today'); break; }
} else if (chip) chip.remove();
if (st.locked) kidLock(st); else { const l = document.getElementById('kidlock'); if (l) l.remove(); }
}
function kidLock(st) {
if (document.getElementById('kidlock')) return; try { closeSheet(); } catch {} try { pl.a.pause(); } catch {}
const el = document.createElement('div'); el.id = 'kidlock'; el.setAttribute('role', 'alertdialog');
el.innerHTML = `<div class="kidbox"><div style="font-size:56px">${st.locked === 'bedtime' ? '🌙' : '⏰'}</div><h1>${st.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today'}</h1><p>${st.locked === 'bedtime' ? 'The hub opens again at ' + esc(st.bedtime ? st.bedtime.to : 'morning') + '.' : 'You have used all of today\'s time.'}</p><button class="btn" id="kidask">Ask a parent for more time</button><p class="sub" id="kidmsg"></p></div>`;
document.body.appendChild(el);
el.querySelector('#kidask').onclick = async e => { e.currentTarget.disabled = true; try { const r = await jfetch('/api/kid/ask', { method: 'POST', body: JSON.stringify({ text: st.locked === 'bedtime' ? 'Can I stay up a little longer, please?' : 'Can I have a little more time, please?' }) }); el.querySelector('#kidmsg').textContent = r.told ? 'I asked. Wait for a yes.' : 'Nobody could be asked right now.'; } catch (err) { el.querySelector('#kidmsg').textContent = err.message; e.currentTarget.disabled = false; } };
}
+1 -1
View File
@@ -18,7 +18,7 @@
setInterval(checkForUpdate, 10 * 60e3);
await loadMe();
await loadSettings();
sgStart(); if (typeof chStart === 'function') chStart();
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart();
addEventListener('hashchange', route);
restorePlayer();
route();
+62
View File
@@ -0,0 +1,62 @@
'use strict';
// ---- Chores: today's chores for you and the family, ticking them off, points for the week, and (admin) making and changing chores. See chores.js.
const cr = { day: '', data: null, range: 'week', sum: null };
const crApi = (p, o) => jfetch('/api/chores' + p, o);
const crPost = (p, b) => crApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const crYmd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const crShift = (s, n) => { const d = new Date(s + 'T12:00:00'); d.setDate(d.getDate() + n); return crYmd(d); };
const crDayLabel = s => s === crYmd(new Date()) ? 'Today' : s === crShift(crYmd(new Date()), -1) ? 'Yesterday' : s === crShift(crYmd(new Date()), 1) ? 'Tomorrow' : new Date(s + 'T12:00:00').toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
const CR_REPEAT = { daily: 'Every day', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', once: 'One time' };
const CR_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
async function viewChores() {
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
try { [cr.data, cr.sum] = await Promise.all([crApi('/?date=' + cr.day), crApi('/summary?range=' + cr.range + '&date=' + cr.day)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/chores')) return;
const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me);
const item = i => `<label class="critem ${i.done ? 'done' : ''}"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}" ${i.done ? 'checked' : ''} ${i.canDo ? '' : 'disabled'}><span class="grow"><b>${esc(i.title)}</b>${i.notes ? `<small>${esc(i.notes)}</small>` : ''}${i.done ? `<small>Done by ${esc(i.done.byName)}</small>` : i.overdue ? '<small style="color:var(--danger)">Overdue</small>' : ''}</span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`;
const person = p => { const l = d.board.filter(i => i.user === p.id), done = l.filter(i => i.done).length; return `<div class="crperson"><div class="crph"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><b>${esc(p.name)}</b><small>${l.length ? done + ' of ' + l.length : 'nothing today'}</small></div>${l.length ? `<div class="crbar"><i style="width:${Math.round(done / l.length * 100)}%"></i></div>` : ''}<div>${l.map(item).join('')}</div></div>`; };
const max = Math.max(1, ...cr.sum.people.map(p => p.points));
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Chores</h1><span class="pspace grow"></span>${d.me.admin ? `<button class="btn ghost" id="crman">${icon('cog')}Manage</button>` : ''}</div>
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="crprev" aria-label="Previous day">${icon('back')}</button><b style="min-width:150px;text-align:center">${esc(crDayLabel(cr.day))}</b><button class="ibtn" id="crnext" aria-label="Next day" style="transform:scaleX(-1)">${icon('back')}</button>${cr.day !== crYmd(new Date()) ? '<button class="btn ghost" id="crtoday">Today</button>' : ''}</div>
<h2>Mine</h2><div class="crcard">${mine.map(item).join('') || '<p class="empty" style="margin:8px 0">Nothing for you ' + (cr.day === crYmd(new Date()) ? 'today' : 'on this day') + '. 🎉</p>'}</div>
${anyone.length ? `<h2>Anyone can do</h2><div class="crcard">${anyone.map(item).join('')}</div>` : ''}
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).join('')}</div>` : ''}
<div class="ptop"><h2 style="margin:18px 0 6px">Points</h2><span class="pspace grow"></span><div class="seg" style="margin:0"><a href="#" data-r="week" class="${cr.range === 'week' ? 'on' : ''}">Week</a><a href="#" data-r="month" class="${cr.range === 'month' ? 'on' : ''}">Month</a></div></div>
<div class="crcard">${cr.sum.people.map((p, i) => `<div class="crrank"><span class="num">${i + 1}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar"><i style="width:${Math.round(p.points / max * 100)}%"></i></div></span><small>${p.points} pts · ${p.count} done</small></div>`).join('') || '<p class="empty">No chores yet.</p>'}</div></div>`;
v.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: cr.day, user: c.dataset.u || undefined, undo: !c.checked }); if (c.checked) toast('Nice work!'); viewChores(); } catch (e) { toast(e.message); c.checked = !c.checked; } });
$('#crprev').onclick = () => { cr.day = crShift(cr.day, -1); viewChores(); }; $('#crnext').onclick = () => { cr.day = crShift(cr.day, 1); viewChores(); }; if ($('#crtoday')) $('#crtoday').onclick = () => { cr.day = crYmd(new Date()); viewChores(); };
v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); cr.range = a.dataset.r; viewChores(); });
if ($('#crman')) $('#crman').onclick = crManage;
}
// ---- making and changing chores (admin)
function crManage() {
const d = cr.data, who = c => c.assign === 'anyone' ? 'Anyone' : d.people.filter(p => c.assign.includes(p.id)).map(p => p.name).join(', ');
const when = c => CR_REPEAT[c.repeat] + (c.repeat === 'weekly' && c.days && c.days.length ? ' · ' + c.days.map(x => CR_DAYS[x]).join(' ') : '') + (c.repeat === 'monthly' ? ' · day ' + (c.dom || 1) : '') + (c.repeat === 'once' ? ' · ' + c.date : '');
const el = openSheet('Manage chores', 'Make jobs for the family and choose who does them', `<div class="list" style="max-height:52dvh;overflow:auto">${d.chores.map(c => `<button class="row" data-e="${esc(c.id)}"><span class="grow"><b>${esc(c.title)}${c.active ? '' : ' (paused)'}</b><small style="white-space:normal">${esc(when(c))} · ${esc(who(c))}${c.points ? ' · ' + c.points + ' pts' : ''}</small></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No chores yet.</p>'}</div>
<div class="bar"><button class="btn" id="crnew" style="width:100%;justify-content:center">${icon('plus')}New chore</button></div>`);
el.querySelector('#crnew').onclick = () => { closeSheet(); crEdit(null); };
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { closeSheet(); crEdit(d.chores.find(c => c.id === b.dataset.e)); });
}
function crEdit(c) {
const d = cr.data, x = c || { title: '', notes: '', repeat: 'daily', days: [], dom: 1, date: crYmd(new Date()), assign: 'anyone', points: 0, active: true }, chosen = new Set(x.assign === 'anyone' ? [] : x.assign);
const el = openSheet(c ? 'Edit chore' : 'New chore', '', `<form id="cref"><label for="crt">What needs doing</label><input type="text" id="crt" value="${esc(x.title)}" maxlength="120" required autocomplete="off" placeholder="Empty the dishwasher">
<label for="crn">Notes (optional)</label><input type="text" id="crn" value="${esc(x.notes)}" maxlength="400" autocomplete="off">
<label for="crr">How often</label><select id="crr">${Object.entries(CR_REPEAT).map(([k, l]) => `<option value="${k}"${x.repeat === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
<div id="crd" hidden><label>On which days (none: any day that week)</label><div class="seg">${CR_DAYS.map((n, i) => `<a href="#" data-d="${i}" class="${(x.days || []).includes(i) ? 'on' : ''}">${n}</a>`).join('')}</div></div>
<div id="crm" hidden><label for="crdom">Day of the month</label><input type="number" id="crdom" min="1" max="31" value="${x.dom || 1}"></div><div id="cro" hidden><label for="crdt">On</label><input type="date" id="crdt" value="${esc(x.date || '')}"></div>
<label>Who does it</label><div class="list"><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="anyone"${x.assign === 'anyone' ? ' checked' : ''}><span class="grow"><b>Anyone</b><small>Whoever gets to it first</small></span></label><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="people"${x.assign !== 'anyone' ? ' checked' : ''}><span class="grow"><b>These people</b><small>Each one has their own to do</small></span></label></div>
<div class="list" id="crp">${d.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div>
<label for="crpt">Points for doing it</label><input type="number" id="crpt" min="0" max="50" value="${x.points || 0}">
<label class="row" style="cursor:pointer"><span class="grow"><b>Active</b><small>Turn off to pause it</small></span><input type="checkbox" id="cra"${x.active ? ' checked' : ''}></label><div class="err" id="cre"></div>
<div class="bar">${c ? '<button type="button" class="btn danger" id="crdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="crx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
const sync = () => { const r = el.querySelector('#crr').value; el.querySelector('#crd').hidden = r !== 'weekly'; el.querySelector('#crm').hidden = r !== 'monthly'; el.querySelector('#cro').hidden = r !== 'once'; el.querySelector('#crp').hidden = el.querySelector('[name=crw]:checked').value !== 'people'; };
el.querySelector('#crr').onchange = sync; el.querySelectorAll('[name=crw]').forEach(r => r.onchange = sync); sync(); el.querySelectorAll('#crd [data-d]').forEach(a => a.onclick = e => { e.preventDefault(); a.classList.toggle('on'); });
el.querySelector('#crx').onclick = closeSheet;
if (c) el.querySelector('#crdel').onclick = () => { closeSheet(); confirmSheet('Delete this chore?', c.title + ' and its history.', 'Delete', async () => { try { await crApi('/' + c.id, { method: 'DELETE' }); toast('Deleted'); viewChores(); } catch (e) { toast(e.message); } }); };
el.querySelector('#cref').onsubmit = async e => { e.preventDefault(); const people = [...el.querySelectorAll('[data-p]:checked')].map(i => i.dataset.p);
try { await crPost('/', { id: c && c.id, title: el.querySelector('#crt').value, notes: el.querySelector('#crn').value, repeat: el.querySelector('#crr').value, days: [...el.querySelectorAll('#crd .on')].map(a => +a.dataset.d), dom: +el.querySelector('#crdom').value, date: el.querySelector('#crdt').value, assign: el.querySelector('[name=crw]:checked').value === 'anyone' ? 'anyone' : people, points: +el.querySelector('#crpt').value, active: el.querySelector('#cra').checked }); closeSheet(); toast('Saved'); viewChores(); }
catch (x) { el.querySelector('#cre').textContent = x.message; } };
}
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and
// only shows if the person has that part of the hub, so one slow or missing piece never blanks the page.
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty" style="margin:6px 0">Loading…</p></div></section>`;
const fmBody = id => document.querySelector('#' + id + ' .fmb');
const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `<p class="sub" style="margin:6px 0">${esc(e && e.message || 'Could not load this')}</p>`; };
function viewFamily() {
const h = new Date().getHours(), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening', today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
const cards = [['cal', allowed('calendar'), fmCard('fmcal', 'Today and tomorrow', '#/calendar', 'Calendar')], ['chores', allowed('chores'), fmCard('fmch', 'Chores today', '#/chores')], ['shop', allowed('recipes'), fmCard('fmsh', 'Shopping list', '#/recipes/shop')],
['notes', allowed('notes'), fmCard('fmnt', 'Family notes and lists', '#/notes')], ['chat', allowed('chat'), fmCard('fmct', 'Chat', '#/chat')], ['tasks', allowed('tasks'), fmCard('fmtk', 'Your tasks', '#/tasks')]].filter(c => c[1]);
$('#view').innerHTML = `<div class="wrap fmwrap"><header class="homehead"><div><h1>${greet}${ME ? ', ' + esc(ME.name.split(' ')[0]) : ''}</h1><p class="sub">${today}</p></div><div class="homeact">${inboxBell()}</div></header>
<div id="annc"></div><div class="fmgrid">${cards.map(c => c[2]).join('') || '<p class="empty">Nothing here yet. An admin can turn on Calendar, Chores, Notes, Chat and more for you.</p>'}</div></div>`;
inboxWire();
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
const done = new Set(cards.map(c => c[0])), run = (k, f) => { if (done.has(k)) f().catch(e => fmErr({ cal: 'fmcal', chores: 'fmch', shop: 'fmsh', notes: 'fmnt', chat: 'fmct', tasks: 'fmtk' }[k], e)); };
run('cal', fmEvents); run('chores', fmChores); run('shop', fmShopping); run('notes', fmNotes); run('chat', fmChat); run('tasks', fmTasks);
}
async function fmEvents() {
const d0 = new Date(), t0 = crYmd(d0), t1 = crShift(t0, 1), from = new Date(t0 + 'T00:00:00').getTime(), to = new Date(t1 + 'T23:59:59').getTime();
const r = await jfetch('/api/cal/events?' + new URLSearchParams({ from, to, fd: t0, td: t1 })), b = fmBody('fmcal'); if (!b) return;
const day = (ymdS, e) => e.allDay ? e.start <= ymdS && e.end > ymdS : crYmd(new Date(e.start)) === ymdS;
const section = (label, ymdS) => { const l = r.events.filter(e => day(ymdS, e)).sort((a, c) => (c.allDay - a.allDay) || (a.allDay ? 0 : a.start - c.start)); return `<div class="fmday"><small style="display:block">${label}</small>${l.map(e => `<div class="fmrow"><span class="fmdot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="grow"><b>${esc(e.title)}</b></span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || '<small class="sub" style="display:block;text-transform:none;letter-spacing:0;font-size:13px;margin:4px 0">Nothing</small>'}</div>`; };
b.innerHTML = section('Today', t0) + section('Tomorrow', t1);
}
async function fmChores() {
const d = await crApi('/?date=' + crYmd(new Date())), b = fmBody('fmch'); if (!b) return; const me = d.me.id;
const per = d.people.map(p => { const l = d.board.filter(i => i.user === p.id), dn = l.filter(i => i.done).length; return { p, l, dn }; }).filter(x => x.l.length), any = d.board.filter(i => i.user === null);
const mine = d.board.filter(i => i.user === me || i.user === null).filter(i => !i.done);
b.innerHTML = `${mine.length ? mine.slice(0, 5).map(i => `<label class="critem"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}"><span class="grow"><b>${esc(i.title)}</b></span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`).join('') : '<p class="empty" style="margin:6px 0">Nothing left for you today. 🎉</p>'}
${per.length ? `<div class="fmprog">${per.map(x => `<div class="fmpr"><span class="sgav" style="background:${sgColor(x.p.id)};width:26px;height:26px;font-size:12px">${esc(sgInitial(x.p.name))}</span><span class="grow"><small>${esc(x.p.name)} · ${x.dn} of ${x.l.length}</small><div class="crbar"><i style="width:${Math.round(x.dn / x.l.length * 100)}%"></i></div></span></div>`).join('')}</div>` : ''}${any.length ? `<small class="sub">${any.filter(i => !i.done).length} for anyone</small>` : ''}`;
b.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: crYmd(new Date()), user: c.dataset.u || undefined }); toast('Nice work!'); fmChores(); } catch (e) { toast(e.message); c.checked = false; } });
}
async function fmShopping() {
const b0 = fmBody('fmsh'); const st = await jfetch('/api/recipes/status'); if (!st.enabled) { if (b0) b0.innerHTML = '<p class="sub" style="margin:6px 0">The shopping list is not set up yet.</p>'; return; }
const l = await jfetch('/api/recipes/shopping'), b = fmBody('fmsh'); if (!b) return; const open = l.items.filter(i => !i.checked);
b.innerHTML = `${open.slice(0, 8).map(i => `<label class="critem"><input type="checkbox" data-id="${esc(i.id)}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty" style="margin:6px 0">Nothing to buy.</p>'}${open.length > 8 ? `<small class="sub">+ ${open.length - 8} more</small>` : ''}
<form class="bar" id="fmshf" style="margin:8px 0 0;gap:8px;flex-wrap:nowrap"><input type="text" id="fmshi" placeholder="Add an item" maxlength="200" autocomplete="off" style="flex:1"><button class="btn ghost">Add</button></form>`;
b.querySelectorAll('input[data-id]').forEach(c => c.onchange = async () => { try { await jfetch('/api/recipes/shopping/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: c.dataset.id, checked: true }) }); fmShopping(); } catch (e) { toast(e.message); c.checked = false; } });
b.querySelector('#fmshf').onsubmit = async e => { e.preventDefault(); const i = b.querySelector('#fmshi'), t = i.value.trim(); if (!t) return; try { await jfetch('/api/recipes/shopping/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: t }) }); fmShopping(); } catch (x) { toast(x.message); } };
}
async function fmNotes() {
const r = await jfetch('/api/notes'), b = fmBody('fmnt'); if (!b) return; const l = r.notes.filter(n => !n.archived && n.share !== 'me').slice(0, 4);
b.innerHTML = l.map(n => `<a class="fmrow" href="#/notes/${esc(n.id)}" style="text-decoration:none;color:inherit"><span class="fmdot" style="background:${NT_COLORS[n.color] || 'var(--line)'}"></span><span class="grow"><b>${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}${n.pinned ? ' 📌' : ''}</b><small>${n.type === 'list' ? ntDone(n) + ' of ' + n.items.length + ' done' + (n.items.find(i => !i.done) ? ' · next: ' + esc(n.items.find(i => !i.done).text) : '') : esc(n.body.slice(0, 70))}</small></span></a>`).join('') || '<p class="empty" style="margin:6px 0">No shared notes yet.</p>';
}
async function fmChat() {
const r = await jfetch('/api/chat/convs'), b = fmBody('fmct'); if (!b) return; const l = r.convs.filter(c => !c.archived).slice(0, 4);
b.innerHTML = l.map(c => `<a class="fmrow" href="#/chat/${encodeURIComponent(c.id)}" style="text-decoration:none;color:inherit"><span class="sgav" style="background:${sgColor(c.id)};width:30px;height:30px;font-size:13px">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b><small>${esc(c.preview || '')}</small></span>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</a>`).join('') || '<p class="empty" style="margin:6px 0">No chats yet.</p>';
}
async function fmTasks() {
const r = await jfetch('/api/tasks/upcoming?today=' + crYmd(new Date())), b = fmBody('fmtk'); if (!b) return;
b.innerHTML = r.tasks.slice(0, 5).map(t => `<a class="fmrow" href="#/tasks" style="text-decoration:none;color:inherit"><span class="grow"><b>${esc(t.title)}</b></span><small style="${t.due < crYmd(new Date()) ? 'color:var(--danger)' : ''}">${t.due === crYmd(new Date()) ? 'Today' : esc(t.due)}</small></a>`).join('') || '<p class="empty" style="margin:6px 0">Nothing due this week. 🎉</p>';
}
+24
View File
@@ -0,0 +1,24 @@
'use strict';
// ---- Notifications: a bell with the count of things you have not seen yet (chat, reminders, announcements); tapping it opens the list.
let inboxN = 0;
const inboxBell = () => `<button class="btn ghost inboxbell" id="inboxbtn" aria-label="Notifications" title="Notifications">${icon('bell')}<i class="nbadge" id="inboxn" ${inboxN ? '' : 'hidden'}>${inboxN > 99 ? '99+' : inboxN}</i></button>`;
function inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); }
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
async function inboxCount() { try { const r = await jfetch('/api/inbox'); inboxN = r.unread; inboxPaint(); } catch {} }
setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && document.getElementById('inboxbtn')) inboxCount(); }, 45000);
const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
const INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] };
async function inboxOpen() {
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
let items = [];
const draw = () => {
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
l.innerHTML = items.length ? `<div class="list">${items.map(i => { const [ic, col] = INBOX_ICON[i.kind] || ['bell', '#6a7280']; return `<a class="row" href="${esc(i.url || '#/home')}" data-id="${esc(i.id)}" style="${i.read ? 'opacity:.62' : ''}"><span class="ic" style="background:${col}">${icon(ic)}</span><span class="grow"><b>${esc(i.title)}${i.count > 1 ? ` <small>(${i.count})</small>` : ''}</b><small style="white-space:normal">${esc(i.body)}</small></span><small>${inboxAgo(i.at)}</small>${i.read ? '' : '<i class="nbadge" style="position:static;min-width:10px;height:10px;padding:0"></i>'}</a>`; }).join('')}</div>` : '<p class="empty">Nothing yet. Messages, reminders and announcements will show up here.</p>';
l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; jfetch('/api/inbox/read', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(r => { inboxN = r.unread; inboxPaint(); }).catch(() => {}); closeSheet(); }));
};
try { const r = await jfetch('/api/inbox'); items = r.items; inboxN = r.unread; inboxPaint(); } catch (e) { toast(e.message); }
draw();
const post = (p, b) => jfetch('/api/inbox/' + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) });
el.querySelector('#inboxall').onclick = async () => { try { const r = await post('read', { all: true }); items.forEach(i => i.read = true); inboxN = r.unread; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
el.querySelector('#inboxclr').onclick = async () => { try { await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
}
+3
View File
@@ -94,11 +94,14 @@
<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="/boot.js"></script>
<script src="/shell.js"></script>
</body>
+4 -3
View File
@@ -50,12 +50,13 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!here()) return;
const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 };
const S = { q: '', genre: '', page: 1, items: [], more: false, busy: false, seq: 0 }; let genreList = []; lvApi('/genres').then(r => { genreList = r.genres || []; gchips(); }).catch(() => {});
const card = b => lvCard(b);
v.innerHTML = `<div class="wrap">${host ? '' : `<div class="ptop"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><h1 style="margin:0">Free audiobooks</h1></div>`}
<p class="sub">Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.</p>
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div class="seg" id="lvgenres" aria-label="Genres"></div><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
const known = new Map(lvShelf.map(b => [b.id, b]));
const gchips = () => { const el = $('#lvgenres'); if (!el) return; el.innerHTML = [{ id: '', label: 'All genres' }, ...genreList.filter(g => g.count === undefined || g.count > 0)].map(g => `<a href="#" data-g="${esc(g.id)}" class="${S.genre === g.id ? 'on' : ''}">${esc(g.label)}</a>`).join(''); el.querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.genre = a.dataset.g; gchips(); run(true); }); };
const draw = () => {
const mine = lvRecent();
$('#lvbody').innerHTML = (!S.q && mine.length ? `<h2>My free audiobooks</h2><div class="vgrid">${mine.map(card).join('')}</div>` : '') +
@@ -66,7 +67,7 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
};
const run = async reset => {
const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++; S.busy = true; $('#lvnote').innerHTML = ''; draw();
try { const r = await lvApi(`/search?q=${encodeURIComponent(S.q)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); }
try { const r = await lvApi(`/search?q=${encodeURIComponent(S.q)}&genre=${encodeURIComponent(S.genre)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; if (r.building && !r.items.length) { $('#lvnote').innerHTML = '<p class="sub">Sorting the audiobooks by genre for the first time. This takes about a minute…</p>'; setTimeout(() => { if (my === S.seq && S.genre) run(true); }, 8000); } r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); }
catch (e) { if (my === S.seq) $('#lvnote').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
if (my === S.seq) { S.busy = false; draw(); }
};
+6 -5
View File
@@ -154,14 +154,15 @@ async function rdMobiOpen(u8) {
async function rdStore(host) { // host: { el, hash } when shown inside the Store tab
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty">Loading…</p></div>';
let sources, mine;
try { [sources, mine] = await Promise.all([rdApi('/store/sources'), rdApi('/list')]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
let genreList = []; try { [sources, mine, genreList] = await Promise.all([rdApi('/store/sources'), rdApi('/list'), rdApi('/store/genres').catch(() => [])]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!here()) return;
const key = b => (b.title + '|' + (b.author || '')).toLowerCase().replace(/\s+/g, ' ').trim(), have = new Map(mine.map(b => [key(b), b.id]));
const S = { q: '', source: 'all', page: 1, items: [], more: false, seq: 0 };
const S = { q: '', source: 'all', genre: '', page: 1, items: [], more: false, seq: 0 };
v.innerHTML = `<div class="wrap" id="rdst"><div class="ptop">${host ? '' : `<a class="btn ghost" href="#/read">${icon('back')}Books</a><h1 style="margin:0">Book store</h1>`}<span class="pspace grow"></span>${ME && ME.role === 'admin' ? `<button class="btn ghost" id="rdcat">${icon('cog')}Catalogues</button>` : ''}</div>
<p class="sub">Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.</p>
<input type="search" id="rdsq" placeholder="Search titles and authors" autocomplete="off" autocapitalize="off" style="width:100%">
<div class="seg" id="rdchips"></div><div id="rdnote"></div><div id="rdres"></div><div id="rdmore" class="bar" style="justify-content:center"></div></div>`;
<div class="seg" id="rdchips"></div><div class="seg" id="rdgenres" aria-label="Genres"></div><div id="rdnote"></div><div id="rdres"></div><div id="rdmore" class="bar" style="justify-content:center"></div></div>`;
const gchips = () => { const el = $('#rdgenres'); if (!el) return; el.innerHTML = [{ id: '', label: 'All genres' }, ...genreList].map(g => `<a href="#" data-g="${esc(g.id)}" class="${S.genre === g.id ? 'on' : ''}">${esc(g.label)}</a>`).join(''); el.querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.genre = a.dataset.g; gchips(); run(true); }); };
const chips = () => { $('#rdchips').innerHTML = [{ id: 'all', name: 'All' }, ...sources].map(x => `<a href="#" data-s="${esc(x.id)}" class="${S.source === x.id ? 'on' : ''}">${esc(x.name)}</a>`).join(''); $('#rdchips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.source = a.dataset.s; chips(); run(true); }); };
const draw = () => {
const res = $('#rdres'); if (!res) return;
@@ -174,7 +175,7 @@ async function rdStore(host) { // host: { el, hash } when shown inside the Store
const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++;
S.busy = true; $('#rdnote').innerHTML = ''; draw();
try {
const r = await rdApi(`/store/search?q=${encodeURIComponent(S.q)}&source=${encodeURIComponent(S.source)}&page=${S.page}`); if (my !== S.seq) return;
const r = await rdApi(`/store/search?q=${encodeURIComponent(S.q)}&source=${encodeURIComponent(S.source)}&genre=${encodeURIComponent(S.genre)}&page=${S.page}`); if (my !== S.seq) return;
S.items = S.items.concat(r.items); S.more = r.more;
const errs = Object.values(r.errors || {}).map(esc);
$('#rdnote').innerHTML = errs.length ? `<p class="sub" style="color:var(--danger)">${errs.join('<br>')}</p>` : '';
@@ -212,7 +213,7 @@ async function rdStore(host) { // host: { el, hash } when shown inside the Store
};
list();
};
chips();
chips(); gchips();
let t; $('#rdsq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); };
$('#rdsq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } };
if ($('#rdcat')) $('#rdcat').onclick = catalogues;
+14 -4
View File
@@ -45,7 +45,7 @@ async function searchBooks(term) {
}
function viewSearch() {
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Mail, tasks, events, files, music, movies & TV, podcasts, audiobooks…" autocomplete="off" autocapitalize="off">
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Mail, chat, notes, tasks, events, files, music, recipes, contacts, books…" autocomplete="off" autocapitalize="off">
<p class="sub" id="src" style="margin:8px 0 0"></p>
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
@@ -74,7 +74,7 @@ function viewSearch() {
const enc = encodeURIComponent, lower = term.toLowerCase();
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
const [libs, files, music, video, pods, books, ddg, hub, mailR] = await Promise.allSettled([
const [libs, files, music, video, pods, books, ddg, hub, mailR, myBooks] = await Promise.allSettled([
libsP,
libsP.then(l => searchFiles(term, l)),
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
@@ -84,6 +84,7 @@ function viewSearch() {
jfetch('/api/ddg?q=' + enc(term)),
jfetch('/api/search?q=' + enc(term)),
allowed('mail') && cfg.mail ? jfetch('/api/mail/messages?folder=inbox&q=' + enc(term)) : Promise.reject(new Error('not allowed')),
allowed('read') ? jfetch('/api/read/list') : Promise.reject(new Error('not allowed')),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
@@ -112,6 +113,15 @@ function viewSearch() {
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
// the family parts: notes, chat, contacts, chores, recipes, and the books on your shelf
const row = (href, color, ic, title, sub) => `<a class="row" href="${href}"><span class="ic" style="background:${color}">${icon(ic)}</span><span class="grow"><b>${esc(title)}</b><small style="white-space:normal">${esc(sub)}</small></span></a>`;
const myBookHtml = (myBooks.status === 'fulfilled' && Array.isArray(myBooks.value) ? myBooks.value : []).filter(b => ((b.title || '') + ' ' + (b.author || '')).toLowerCase().includes(lower)).slice(0, 8).map(b => row('#/read/open/' + enc(b.id), '#2b8a5e', 'book', b.title, b.author || 'Your books')).join('');
const famHtml = sec('Notes and lists', (h.notes || []).map(n => row('#/notes/' + enc(n.id), '#e5b800', n.type === 'list' ? 'check' : 'doc', n.title, n.share + (n.snippet ? ' · ' + n.snippet : ''))).join(''))
+ sec('Chat', (h.chat || []).map(c => row('#/chat/' + enc(c.conv), '#2aa6a0', 'chat', c.convName, c.msg.name + ': ' + c.msg.text)).join(''))
+ sec('Contacts', (h.contacts || []).map(c => row('#/contacts/c/' + enc(c.id), '#5b8def', 'people', c.name, [c.org, c.email, c.phone].filter(Boolean).join(' · '))).join(''))
+ sec('Chores', (h.chores || []).map(c => row('#/chores', '#2f9e6e', 'check', c.title, [c.repeat, c.who, c.points ? c.points + ' pts' : ''].filter(Boolean).join(' · '))).join(''))
+ sec('Recipes', (h.recipes || []).map(r => row('#/recipes/r/' + enc(r.slug), '#e0742b', 'chef', r.name, r.minutes ? r.minutes + ' min' : 'Recipe')).join(''))
+ sec('Your books', myBookHtml);
evStore.length = 0;
const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
return `<button class="row" data-tl="${esc(t.list)}"><span class="ic" style="background:#4f7cff">${icon('check')}</span><span class="grow"><b${t.done ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(t.title)}</b><small>${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''}</small></span></button>`; }).join('');
@@ -130,8 +140,8 @@ function viewSearch() {
});
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
res.innerHTML = card + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.innerHTML = card + famHtml + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
if (!famHtml && !taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
+13 -2
View File
@@ -43,7 +43,13 @@ async function addPersonSheet() {
function personSheet(u) {
const self = u.id === ME.id, admin = u.role === 'admin';
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required>
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}`}
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}
<h2 style="margin-top:14px">Child account</h2><label class="row" style="cursor:pointer"><span class="grow"><b>This is a child</b><small style="white-space:normal">Adds a daily time limit and a bedtime, and a safer Store and podcast search.</small></span><input type="checkbox" id="kon"${u.kid && u.kid.on ? ' checked' : ''}></label>
<div id="kopt" ${u.kid && u.kid.on ? '' : 'hidden'}><label for="klim">Time per day, in minutes (empty for no limit)</label><input type="number" id="klim" min="0" max="720" value="${u.kid && u.kid.limitMin ? u.kid.limitMin : ''}" placeholder="120">
<label class="row" style="cursor:pointer"><span class="grow"><b>Bedtime</b><small>The app is locked in these hours</small></span><input type="checkbox" id="kbo"${u.kid && u.kid.bedtime && u.kid.bedtime.on ? ' checked' : ''}></label>
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="kbf">From</label><input type="time" id="kbf" value="${esc((u.kid && u.kid.bedtime && u.kid.bedtime.from) || '20:30')}"></div><div style="flex:1"><label for="kbt">To</label><input type="time" id="kbt" value="${esc((u.kid && u.kid.bedtime && u.kid.bedtime.to) || '07:00')}"></div></div>
<label class="row" style="cursor:pointer"><span class="grow"><b>Safer Store and podcast search</b><small style="white-space:normal">Books and audiobooks from a few kid-friendly sources and genres, and no podcasts marked explicit</small></span><input type="checkbox" id="ksf"${!u.kid || u.kid.safe !== false ? ' checked' : ''}></label>
<div class="row" id="kuse"><span class="grow"><small>Today</small><b id="kuset">…</b></span><button type="button" class="btn ghost" data-m="15">+15 min</button><button type="button" class="btn ghost" data-m="30">+30 min</button><button type="button" class="btn ghost" data-m="60">+1 h</button></div></div>`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ue"></div></form>
<div class="list" style="background:var(--panel2);border:0;margin-top:6px">
<button class="row" id="u-inv">${icon('copy')}<span class="grow"><b>Invite code for a new device</b><small>Also the way back in if they lose their device</small></span></button>
@@ -54,7 +60,12 @@ function personSheet(u) {
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } };
const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) });
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); };
const kon = el.querySelector('#kon'); if (kon) {
const fillUse = async () => { try { const r = await jfetch('/api/kid/usage?user=' + encodeURIComponent(u.id)); el.querySelector('#kuset').textContent = r.on ? r.used + ' min used' + (r.limit ? ' of ' + r.limit : ' (no limit)') + (r.locked ? ' · locked: ' + (r.locked === 'bedtime' ? 'bedtime' : 'time is up') : '') : 'Not saved as a child account yet'; } catch { el.querySelector('#kuset').textContent = ''; } };
kon.onchange = () => { el.querySelector('#kopt').hidden = !kon.checked; }; fillUse();
el.querySelectorAll('#kuse [data-m]').forEach(b => b.onclick = async () => { try { await jfetch('/api/kid/extra', { method: 'POST', body: JSON.stringify({ user: u.id, minutes: +b.dataset.m }) }); toast('Added ' + b.dataset.m + ' minutes for today'); fillUse(); } catch (err) { toast(err.message); } });
}
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el), kid: { on: el.querySelector('#kon').checked, limitMin: +el.querySelector('#klim').value || 0, bedtime: { on: el.querySelector('#kbo').checked, from: el.querySelector('#kbf').value, to: el.querySelector('#kbt').value }, safe: el.querySelector('#ksf').checked } }) }), 'Saved'); };
el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
+21
View File
@@ -1035,3 +1035,24 @@ body.vizopen { overflow: hidden; }
/* The narrow menu rail (a tablet, or the menu collapsed) has no room for a label under every icon once there are many entries: below this height it shows the icons alone (the name is in the tooltip) */
@media (min-width: 768px) and (max-width: 1023px) and (max-height: 1100px) { #nav a > span:not(.nbadge), #nav .ngrp span { display: none; } #nav a { min-height: 34px; justify-content: center; } #nav a svg { width: 22px; height: 22px; } #nav .ngrp { margin-top: 3px; padding-top: 3px; } #nav { gap: 1px; } }
@media (min-width: 1024px) and (max-height: 1100px) { body.nav-mini #nav a > span:not(.nbadge), body.nav-mini #nav .ngrp span { display: none; } body.nav-mini #nav a { min-height: 34px; justify-content: center; } body.nav-mini #nav a svg { width: 22px; height: 22px; } body.nav-mini #nav { gap: 1px; } }
/* Chores */
.crcard { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 4px 12px; } .crgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } .crgrid .crperson { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 10px 12px; }
.critem { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); cursor: pointer; } .critem:last-child { border-bottom: 0; } .critem input { width: 24px; height: 24px; flex: none; } .critem small { display: block; color: var(--muted); } .critem.done b { text-decoration: line-through; color: var(--muted); } .critem input:disabled { opacity: .5; }
.crpts { font-style: normal; font-size: 12px; padding: 3px 8px; border-radius: 99px; background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); white-space: nowrap; }
.crph { display: flex; align-items: center; gap: 10px; padding: 4px 0; } .crph small { margin-left: auto; color: var(--muted); } .crbar { height: 6px; border-radius: 3px; background: var(--panel2); overflow: hidden; margin: 2px 0 4px; } .crbar i { display: block; height: 100%; background: var(--accent); }
.crrank { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); } .crrank:last-child { border-bottom: 0; } .crrank .num { width: 22px; color: var(--muted); }
/* Family page */
.fmgrid { display: grid; grid-template-columns: 1fr; gap: 14px; } @media (min-width: 760px) { .fmgrid { grid-template-columns: 1fr 1fr; } } @media (min-width: 1200px) { .fmgrid { grid-template-columns: repeat(3, 1fr); } }
.fmcard { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; min-width: 0; } .fmh { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } .fmh h2 { margin: 0 0 6px; } .fmmore { font-size: 13px; color: var(--accent); }
.fmrow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); } .fmrow:last-child { border-bottom: 0; } .fmrow small { color: var(--muted); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fmrow b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fmdot { width: 10px; height: 10px; border-radius: 50%; flex: none; } .fmday { margin-bottom: 8px; } .fmday > small { color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }
.fmprog { margin-top: 10px; display: grid; gap: 8px; } .fmpr { display: flex; gap: 10px; align-items: center; } .fmpr .grow { min-width: 0; } .fmpr small { color: var(--muted); }
/* Child account: time left and the lock screen */
#kidchip { position: fixed; right: 12px; bottom: calc(12px + var(--ph, 0px)); z-index: 40; padding: 6px 12px; border-radius: 99px; background: var(--panel2); border: 1px solid var(--line); font-size: 13px; box-shadow: 0 2px 10px rgba(0,0,0,.25); } #kidchip.low { background: var(--danger); color: #fff; border-color: transparent; }
#kidlock { position: fixed; inset: 0; z-index: 999; background: var(--bg); display: grid; place-items: center; padding: 24px; text-align: center; } .kidbox { max-width: 420px; } .kidbox h1 { margin: 8px 0; }
.inboxbell { position: relative; }
.inboxbell .nbadge { position: absolute; top: -6px; right: -6px; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v64';
const CACHE = 'hub-v70';
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'];
+9 -7
View File
@@ -9,6 +9,7 @@ const users = require('./users');
const tasks = require('./tasks');
const cal = require('./cal');
const mail = require('./mail');
const inbox = require('./inbox');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
@@ -34,7 +35,8 @@ function localNow(tz, at = new Date()) {
return { day: `${p.year}-${p.month}-${p.day}`, hm: `${p.hour}:${p.minute}` };
}
async function send(dir, st, payload) {
async function send(dir, st, uid, payload) {
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag }); // (reminders also land in the Notifications list)
keys();
let changed = false;
await Promise.all(st.subs.map(async s => {
@@ -61,7 +63,7 @@ async function tick(now = new Date(), force = false) {
const due = (await tasks.allTasks()).filter(t => !t.done && t.due && t.due <= lt.day);
if (due.length) {
const over = due.filter(t => t.due < lt.day).length;
await send(dir, st, { title: due.length === 1 ? due[0].title : due.length + ' tasks need attention', body: due.length === 1 ? (over ? 'Overdue' : 'Due today') : due.slice(0, 3).map(t => t.title).join(', ') + (due.length > 3 ? '…' : '') + (over ? ` (${over} overdue)` : ''), url: '#/tasks', tag: 'tasks-' + lt.day });
await send(dir, st, u.id, { title: due.length === 1 ? due[0].title : due.length + ' tasks need attention', body: due.length === 1 ? (over ? 'Overdue' : 'Due today') : due.slice(0, 3).map(t => t.title).join(', ') + (due.length > 3 ? '…' : '') + (over ? ` (${over} overdue)` : ''), url: '#/tasks', tag: 'tasks-' + lt.day });
}
} catch (e) { console.warn('[push] tasks check failed:', e.message); }
}
@@ -71,7 +73,7 @@ async function tick(now = new Date(), force = false) {
const bucket = ss.days <= 0 ? 0 : [1, 3, 7, 14, 30].find(t => ss.days <= t), key = `mailsecret:${ss.expires}:${bucket}`;
if (!st.sent.includes(key)) {
st.sent.push(key); dirty = true;
await send(dir, st, { title: ss.days <= 0 ? 'Mail sign-in has expired' : `Mail sign-in expires in ${ss.days} day${ss.days === 1 ? '' : 's'}`, body: 'Make a new client secret in Microsoft Entra and paste it in Settings -> Microsoft app.', url: '#/settings', tag: 'mailsecret' });
await send(dir, st, u.id, { title: ss.days <= 0 ? 'Mail sign-in has expired' : `Mail sign-in expires in ${ss.days} day${ss.days === 1 ? '' : 's'}`, body: 'Make a new client secret in Microsoft Entra and paste it in Settings -> Microsoft app.', url: '#/settings', tag: 'mailsecret' });
}
}
}
@@ -83,7 +85,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;
await send(dir, st, { 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' });
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' });
}
}
} catch (e) { console.warn('[push] mail check failed:', e.message); }
@@ -99,7 +101,7 @@ async function tick(now = new Date(), force = false) {
if (st.sent.includes(key)) continue;
st.sent.push(key); dirty = true;
const mins = Math.max(1, Math.round((e.start - from) / 6e4));
await send(dir, st, { title: e.title, body: `Starts in ${mins} min${e.location ? ' · ' + e.location : ''}`, url: '#/calendar', tag: key });
await send(dir, st, u.id, { title: e.title, body: `Starts in ${mins} min${e.location ? ' · ' + e.location : ''}`, url: '#/calendar', tag: key });
}
if (st.sent.length > 300) { st.sent = st.sent.slice(-200); dirty = true; }
} catch (e) { console.warn('[push] events check failed:', e.message); }
@@ -137,11 +139,11 @@ async function handle(req, res) {
}
if (p === '/api/push/test') {
if (!st.subs.length) return json(res, 400, { error: 'No device is signed up for reminders yet' });
await send(dir, st, { title: 'NE Hub reminders are on', body: 'You will see task and event reminders here.', url: '#/home', tag: 'test' });
await send(dir, st, null, { title: 'NE Hub reminders are on', body: 'You will see task and event reminders here.', url: '#/home', tag: 'test' });
return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
}
/** A notification for one person's devices (a chat message, say), unless they turned that kind off. */
async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, payload); }
async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, null, payload); }
module.exports = { handle, start, tick, localNow, notify };
+3 -1
View File
@@ -145,4 +145,6 @@ async function handle(req, res) {
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, enabled, minutes, full, ingredientText };
/** Recipes in Mealie that match the words (none when Mealie is not set up or does not answer). */
async function search(q) { if (!enabled()) return []; try { const j = await mealie(`/api/recipes?page=1&perPage=6&orderBy=name&orderDirection=asc&search=${encodeURIComponent(q)}`, { timeout: 6000 }); return (j.items || []).map(card); } catch { return []; } }
module.exports = { handle, enabled, minutes, full, ingredientText, search };
+10 -2
View File
@@ -3,13 +3,14 @@
const cal = require('./cal');
const tasks = require('./tasks');
const users = require('./users');
const notes = require('./notes'), chat = require('./chat'), contacts = require('./contacts'), recipes = require('./recipes'), choresMod = require('./chores');
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
async function handle(req, res) {
const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().toLowerCase().slice(0, 100);
if (q.length < 2) return json(res, 200, { tasks: [], events: [], errors: [] });
if (q.length < 2) return json(res, 200, { tasks: [], events: [], notes: [], chat: [], contacts: [], chores: [], recipes: [], errors: [] });
const errors = [];
let foundTasks = [], foundEvents = [];
const me = users.current();
@@ -27,7 +28,14 @@ async function handle(req, res) {
.sort((a, b) => { const ua = when(a) >= t0, ub = when(b) >= t0; return ua === ub ? (ua ? when(a) - when(b) : when(b) - when(a)) : ua ? -1 : 1; }) // upcoming first (soonest), then recent past
.slice(0, 8);
} catch (e) { errors.push('Calendar: ' + e.message); }
json(res, 200, { tasks: foundTasks, events: foundEvents, errors });
// the family parts of the hub
const fam = { notes: [], chat: [], contacts: [], chores: [], recipes: [] };
if (users.can(me, 'notes')) try { fam.notes = notes.search(me, q); } catch (e) { errors.push('Notes: ' + e.message); }
if (users.can(me, 'chat')) try { fam.chat = chat.searchFor(me, q); } catch (e) { errors.push('Chat: ' + e.message); }
if (users.can(me, 'contacts')) try { fam.contacts = contacts.load().filter(c => [c.name, c.org, ...c.emails.map(e => e.value), ...c.phones.map(p => p.value)].some(x => String(x || '').toLowerCase().includes(q))).slice(0, 8).map(c => ({ id: c.id, name: c.name, org: c.org, email: (c.emails[0] || {}).value || '', phone: (c.phones[0] || {}).value || '' })); } catch (e) { errors.push('Contacts: ' + e.message); }
if (users.can(me, 'chores')) try { fam.chores = choresMod.find(q).slice(0, 8); } catch (e) { errors.push('Chores: ' + e.message); }
if (users.can(me, 'recipes')) fam.recipes = await recipes.search(q);
json(res, 200, { tasks: foundTasks, events: foundEvents, ...fam, errors });
}
module.exports = { handle };
+9
View File
@@ -29,6 +29,7 @@ const auth = require('./auth');
const cal = require('./cal');
const tasks = require('./tasks');
const settings = require('./settings');
const inbox = require('./inbox');
const search = require('./search');
const users = require('./users');
const admin = require('./admin');
@@ -43,7 +44,9 @@ const savefiles = require('./savefiles'); // "Save to Files": copy books, lesson
const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies)
const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN)
const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export)
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 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
const musicdata = require('./musicdata'); // what was played, favorites, smart and Family playlists for Music
@@ -558,6 +561,7 @@ const ACCESS = [
[/^\/api\/bible\//, ['bible']],
[/^\/api\/read\//, ['read']],
[/^\/api\/radio\//, ['radio']],
[/^\/api\/chores(\/|$)/, ['chores']],
[/^\/api\/notes(\/|$)/, ['notes']],
[/^\/api\/chat(\/|$)/, ['chat']],
[/^\/api\/music(\/|$)/, ['music']],
@@ -616,6 +620,8 @@ async function handleRequest(req, res) {
}
// From here on, everything runs "as" this person: their own folder, and only the tabs they were given.
return users.runAs(user, async () => {
const lock = /^\/(api|sf|plex)\//.test(pathname) ? kidlimits.lockedFor(user, pathname) : null; // (a child whose time is up or whose bedtime it is)
if (lock) { res.writeHead(403, { 'content-type': 'application/json', 'x-hub-limit': lock.locked, 'cache-control': 'no-store' }); return res.end(JSON.stringify({ error: lock.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today', limit: lock.locked, status: lock })); }
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');
@@ -646,6 +652,7 @@ async function handleRequest(req, res) {
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
if (pathname === '/api/settings') return settings.handle(req, res);
if (pathname === '/api/search') return search.handle(req, res);
if (pathname === '/api/inbox' || pathname.startsWith('/api/inbox/')) return inbox.handle(req, res);
if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res);
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
if (pathname === '/api/audiobooks/enter') { // the secret sign-in link is added here, on the server, and never appears in the app's page or script
@@ -660,7 +667,9 @@ async function handleRequest(req, res) {
if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res);
if (pathname.startsWith('/api/read/')) return books.handle(req, res);
if (pathname.startsWith('/api/radio/')) return radio.handle(req, res);
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/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);
if (pathname.startsWith('/api/music/')) return musicdata.handle(req, res);
+7 -3
View File
@@ -53,8 +53,8 @@ const shelf = (type, cat, page) => getJson(`${BASE()}/api/bookshelves/category/v
const PER_API_PAGE = 10, API_PAGES_PER_PAGE = 3; // a page of the Store is three of Smashwords' pages of ten, since the filter removes some
/** The most downloaded free books, a page at a time. */
async function browse(page) {
const first = (page - 1) * API_PAGES_PER_PAGE + 1, pages = await Promise.all([0, 1, 2].map(i => shelf('mostdownloads', 1, first + i).catch(e => { if (i === 0) throw e; return { book_list: [], total_books: 0 }; })));
async function browse(page, cat = 1) {
const first = (page - 1) * API_PAGES_PER_PAGE + 1, pages = await Promise.all([0, 1, 2].map(i => shelf('mostdownloads', cat, first + i).catch(e => { if (i === 0) throw e; return { book_list: [], total_books: 0 }; })));
const seen = new Set(), items = [];
for (const p of pages) for (const e of p.book_list || []) { const b = e.book; if (b && !seen.has(b.id) && free(b) && hasEpub(b) && keep(b)) { seen.add(b.id); items.push(clean(item(b))); } }
const total = pages[0].total_books || 0;
@@ -73,7 +73,11 @@ async function buildPool() {
return out;
}
const pool = () => { if (!poolP || Date.now() - poolAt > 6 * 3600e3) { poolAt = Date.now(); poolDone = false; poolP = buildPool().then(l => { poolDone = true; return l; }, e => { poolP = null; throw e; }); } return poolP; };
async function search(q, page, wait = true) { // (wait: false is for a search across every source, which must not stall for the first-time build; it starts and the next search has it)
async function search(q, page, wait = true, g = null) {
if (g && g.sw) { // a genre: that category's most downloaded, and (with words) only those that mention them
const words = q.toLowerCase().split(/\s+/).filter(Boolean), r = await browse(page, g.sw);
return words.length ? { items: r.items.filter(b => words.every(x => (b.title + ' ' + b.author + ' ' + b.summary).toLowerCase().includes(x))), more: r.more } : r;
} // (wait: false is for a search across every source, which must not stall for the first-time build; it starts and the next search has it)
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return browse(page);
if (!wait && !poolDone) { pool().catch(() => {}); return { items: [], more: false }; }
+26 -20
View File
@@ -2,7 +2,7 @@
// admin adds (Calibre-Web, Kavita, ...). The hub server does all the talking to those sites: searching, covers and downloading a chosen book straight onto the
// signed-in person's shelf (books.js), so the browser never contacts them and no catalogue sign-in reaches the browser.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const users = require('./users'), auth = require('./auth'), books = require('./books'), smashwords = require('./smashwords');
const users = require('./users'), auth = require('./auth'), books = require('./books'), smashwords = require('./smashwords'), genres = require('./genres');
const UA = 'NE-Hub/1.0 (self-hosted e-book reader)';
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
@@ -95,8 +95,9 @@ async function opdsSearch(src, q, page) {
// ---- Project Gutenberg (its own OPDS catalogue), Standard Ebooks (its public pages), and the Internet Archive
const entriesOf = xml => [...xml.matchAll(/<entry[\s>][\s\S]*?<\/entry>/gi)].map(m => m[0]);
async function gutenbergSearch(src, q, page) {
const url = `${src.base}/ebooks/search.opds/?${q ? 'query=' + encodeURIComponent(q) : 'sort_order=downloads'}${page > 1 ? '&start_index=' + ((page - 1) * 25 + 1) : ''}`;
async function gutenbergSearch(src, q, page, g) {
const words = [q, g && g.gu ? 's.' + g.gu : ''].filter(Boolean).join(' ');
const url = `${src.base}/ebooks/search.opds/?${words ? 'query=' + encodeURIComponent(words) : 'sort_order=downloads'}${page > 1 ? '&start_index=' + ((page - 1) * 25 + 1) : ''}`;
const xml = await getText(src, url, 'application/atom+xml'), items = [], seen = new Set();
for (const e of entriesOf(xml)) { const id = (/\/ebooks\/(\d+)\.opds/.exec(tag0(e, 'id')) || [])[1]; if (!id || seen.has(id)) continue; seen.add(id); items.push({ ref: id, format: 'epub', title: tag(e, 'title'), author: tag(e, 'content'), summary: '', cover: `${src.base}/cache/epub/${id}/pg${id}.cover.medium.jpg` }); }
return { items, more: /<link[^>]+rel="next"/i.test(xml.replace(/<entry[\s>][\s\S]*?<\/entry>/gi, '')) };
@@ -106,8 +107,9 @@ async function gutenbergResolve(src, ref) {
if (!/^\d{1,9}$/.test(ref)) throw fail(400, 'Bad book');
return { url: `${src.base}/ebooks/${ref}.epub3.images`, format: 'epub' }; // (the address Project Gutenberg itself gives for the EPUB with pictures)
}
async function standardSearch(src, q, page) {
const html = await getText(src, `${src.base}/ebooks?${q ? 'query=' + encodeURIComponent(q) + '&' : ''}page=${page}&per-page=24`, 'text/html, application/xhtml+xml'), items = [];
async function standardSearch(src, q, page, g) {
if (g && !g.se) return { items: [], more: false }; // (no such tag there)
const html = await getText(src, `${src.base}/ebooks?${q ? 'query=' + encodeURIComponent(q) + '&' : ''}${g ? 'tags[]=' + encodeURIComponent(g.se) + '&' : ''}page=${page}&per-page=24`, 'text/html, application/xhtml+xml'), items = [];
for (const m of html.matchAll(/<li typeof="schema:Book" about="(\/ebooks\/[^"]+)">([\s\S]*?)<\/li>/g)) {
const card = m[2], title = plain((/<p><a [^>]*property="schema:url"><span property="schema:name">([\s\S]*?)<\/span>/.exec(card) || [])[1]);
const who = [...(/class="author"[\s\S]*?<\/p>/.exec(card) || [''])[0].matchAll(/<span property="schema:name">([\s\S]*?)<\/span>/g)].map(x => plain(x[1])).join(', '), img = (/<img src="([^"]+)"/.exec(card) || [])[1] || '';
@@ -121,8 +123,8 @@ async function standardResolve(src, ref) {
const href = links[0] || `${ref}/downloads/${ref.replace(/^\/ebooks\//, '').split('/').join('_')}.epub`;
return { url: new URL(href, src.base).href + '?source=download', format: 'epub' }; // (without this the site answers with a "your download has started" page)
}
async function archiveSearch(src, q, page) {
const query = `(${q}) AND mediatype:texts AND format:EPUB AND -access-restricted-item:true`;
async function archiveSearch(src, q, page, g) {
const query = `(${[q, g ? 'subject:(' + g.ia + ')' : ''].filter(Boolean).join(' AND ')}) AND mediatype:texts AND format:EPUB AND -access-restricted-item:true`;
const j = await getJson(src, `${src.base}/advancedsearch.php?q=${encodeURIComponent(query)}&fl[]=identifier&fl[]=title&fl[]=creator&rows=20&page=${page}&output=json`);
const docs = (j.response && j.response.docs) || [], total = (j.response && j.response.numFound) || 0;
return { items: docs.filter(d => /^[\w.-]+$/.test(d.identifier || '')).map(d => ({ ref: d.identifier, format: 'epub', title: [].concat(d.title || '')[0] || d.identifier, author: [].concat(d.creator || []).join(', '), summary: '', cover: `${src.base}/services/img/${d.identifier}` })), more: page * 20 < total };
@@ -135,8 +137,8 @@ async function archiveResolve(src, ref) {
return { url: `${src.base}/download/${ref}/${f.name.split('/').map(encodeURIComponent).join('/')}`, format: 'epub' };
}
async function openLibrarySearch(src, q, page) {
const j = await getJson(src, `${src.base}/search.json?q=${encodeURIComponent(q)}&has_fulltext=true&fields=key,title,author_name,cover_i,ia,ebook_access&limit=20&page=${page}`);
async function openLibrarySearch(src, q, page, g) {
const j = await getJson(src, `${src.base}/search.json?${q ? 'q=' + encodeURIComponent(q) + '&' : ''}${g ? 'subject=' + encodeURIComponent(g.ol) + '&' : ''}has_fulltext=true&fields=key,title,author_name,cover_i,ia,ebook_access&limit=20&page=${page}`);
const docs = j.docs || [], items = [];
for (const d of docs) { // only editions anyone may read (not "borrowable", which has DRM); the Internet Archive identifier is what gets downloaded
const ia = [].concat(d.ia || []).find(x => /^[\w.-]+$/.test(x)); if (!ia || d.ebook_access !== 'public') continue;
@@ -145,12 +147,13 @@ async function openLibrarySearch(src, q, page) {
return { items, more: page * 20 < (j.numFound || 0) };
}
async function searchOne(src, q, page, everywhere) {
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page);
if (src.id === 'standardebooks') return standardSearch(src, q, page);
if (src.id === 'archive') return q ? archiveSearch(src, q, page) : { items: [], more: false };
if (src.id === 'smashwords') return smashwords.search(q, page, !everywhere);
if (src.id === 'openlibrary') return q ? openLibrarySearch(src, q, page) : { items: [], more: false };
async function searchOne(src, q, page, everywhere, g) {
if (g && !BUILTIN[src.id]) return { items: [], more: false }; // (an OPDS catalogue of your own cannot be asked for a genre)
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page, g);
if (src.id === 'standardebooks') return standardSearch(src, q, page, g);
if (src.id === 'archive') return q || g ? archiveSearch(src, q, page, g) : { items: [], more: false };
if (src.id === 'smashwords') return g && !g.sw ? { items: [], more: false } : smashwords.search(q, page, !everywhere, g);
if (src.id === 'openlibrary') return q || g ? openLibrarySearch(src, q, page, g) : { items: [], more: false };
return opdsSearch(src, q, page);
}
const stamp = (src, it) => ({ ...it, source: src.id, sourceName: src.name, cover: it.cover ? `/api/read/store/cover?s=${encodeURIComponent(src.id)}&u=${encodeURIComponent(it.cover)}` : '', coverUrl: it.cover || '' });
@@ -159,7 +162,8 @@ const listSources = () => [...Object.keys(BUILTIN), ...customs().map(c => c.id)]
async function handle(req, res, p) {
const me = users.current();
try {
if (p === '/sources' && req.method === 'GET') return json(res, 200, listSources());
const safe = !!(me && me.kid && me.kid.on && me.kid.safe), SAFE_SOURCES = ['gutenberg', 'standardebooks'];
if (p === '/sources' && req.method === 'GET') return json(res, 200, listSources().filter(s => !safe || SAFE_SOURCES.includes(s.id)));
if (p === '/sources' && req.method === 'POST') { // add an OPDS catalogue (admin)
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can add a catalogue');
const b = await readBody(req), name = String(b.name || '').trim().slice(0, 60), url = String(b.url || '').trim();
@@ -180,10 +184,12 @@ async function handle(req, res, p) {
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can remove a catalogue');
saveCustoms(customs().filter(x => x.id !== m[1])); return json(res, 200, { removed: true });
}
if (p === '/genres' && req.method === 'GET') return json(res, 200, genres.list());
if (p === '/search' && req.method === 'GET') {
const u = new URL(req.url, 'http://x'), q = (u.searchParams.get('q') || '').trim().slice(0, 120), page = Math.min(20, Math.max(1, +u.searchParams.get('page') || 1)), want = u.searchParams.get('source') || 'all';
const ids = want === 'all' ? listSources().map(s => s.id) : [want], lists = [], errors = {}; let more = false;
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page, want === 'all'); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
const u = new URL(req.url, 'http://x'), q = (u.searchParams.get('q') || '').trim().slice(0, 120), page = Math.min(20, Math.max(1, +u.searchParams.get('page') || 1)), want = u.searchParams.get('source') || 'all'; let genre = genres.byId(u.searchParams.get('genre') || '');
if (safe) { if (want !== 'all' && !SAFE_SOURCES.includes(want)) throw fail(403, 'That source is not available on this account'); if (!genre || !genres.KID.includes(genre.id)) genre = genres.byId('children'); } // (a child account browses a few safe genres, from a few sources)
const ids = want === 'all' ? listSources().map(s => s.id).filter(id => !safe || SAFE_SOURCES.includes(id)) : [want], lists = [], errors = {}; let more = false;
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page, want === 'all', genre); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
lists.sort((a, b) => ids.indexOf(a[0]) - ids.indexOf(b[0]));
const items = []; for (let i = 0; lists.some(l => l[1][i]); i++) for (const l of lists) if (l[1][i]) items.push(l[1][i]); // one from each catalogue in turn
return json(res, 200, { items, errors, more });
@@ -194,7 +200,7 @@ async function handle(req, res, p) {
const buf = await limited(r, MAX_COVER); res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf);
}
if (p === '/add' && req.method === 'POST') {
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || '');
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || ''); if (safe && !SAFE_SOURCES.includes(src.id)) throw fail(403, 'That source is not available on this account');
let target, dl = src;
if (src.id === 'gutenberg') target = await gutenbergResolve(src, ref);
else if (src.id === 'standardebooks') target = await standardResolve(src, ref);
+39
View File
@@ -0,0 +1,39 @@
// Chores: when each kind of chore is due, who it is given to, ticking off and undoing, who may tick what, and the points for the week.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chores-'));
const users = require('../users'), chores = require('../chores');
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chores'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chores'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) };
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chores.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port + '/api/chores', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
const today = chores.ymd(new Date()), board = async (who, date = today) => (await j(await call('/?date=' + date, {}, who))).board;
try {
// when chores are due (pure rules, on known dates: 2026-10-26 is a Monday)
const c = (o) => ({ active: true, ...o }), mon = '2026-10-26', sat = '2026-10-31', sun = '2026-11-01';
assert.equal(chores.dueKey(c({ repeat: 'daily' }), sat), sat); assert.equal(chores.dueKey(c({ repeat: 'weekdays' }), sat), null); assert.equal(chores.dueKey(c({ repeat: 'weekdays' }), mon), mon);
assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [6] }), sat), sat); assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [6] }), sun), null);
assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [] }), sun), mon, 'a weekly chore with no chosen day is due all week, filed under its Monday'); assert.equal(chores.dueKey(c({ repeat: 'weekly', days: [] }), '2026-11-02'), '2026-11-02');
assert.equal(chores.dueKey(c({ repeat: 'monthly', dom: 15 }), '2026-10-14'), null); assert.equal(chores.dueKey(c({ repeat: 'monthly', dom: 15 }), '2026-10-20'), '2026-10', 'a monthly chore stays due after its day'); assert.equal(chores.dueKey(c({ repeat: 'monthly', dom: 31 }), '2026-11-30'), '2026-11', 'day 31 in a short month means the last day');
assert.equal(chores.dueKey(c({ repeat: 'once', date: '2026-10-28' }), mon), null); assert.equal(chores.dueKey(c({ repeat: 'once', date: '2026-10-28' }), '2026-11-03'), 'once'); assert.equal(chores.dueKey(c({ repeat: 'daily', active: false }), mon), null); assert.equal(chores.dueKey(c({ repeat: 'daily', start: '2026-11-01' }), mon), null);
// permissions and creating
assert.equal((await post('/', { title: 'Dishes' }, 'b')).status, 403, 'only an admin makes chores'); assert.equal((await post('/', { title: ' ' })).status, 400); assert.equal((await post('/', { title: 'Once', repeat: 'once' })).status, 400, 'a one-off needs a day');
const dishes = await j(await post('/', { title: 'Dishes', repeat: 'daily', assign: [P.b.id, P.c.id, P.d.id, 'ghost'], points: 3 })); assert.deepEqual(dishes.assign.sort(), [P.b.id, P.c.id].sort(), 'only people who have Chores');
const trash = await j(await post('/', { title: 'Take out the trash', repeat: 'daily', assign: 'anyone', points: 5 })); const lawn = await j(await post('/', { title: 'Mow', repeat: 'weekly', days: [0, 6, 9, 'x'], assign: [P.b.id], points: 10 })); assert.deepEqual(lawn.days, [0, 6]); assert.equal((await j(await post('/', { id: lawn.id, title: 'Mow the lawn', repeat: 'weekly', days: [6], assign: [P.b.id], points: 10 }))).title, 'Mow the lawn', 'a chore can be changed');
let bd = await board('b'); assert.equal(bd.filter(x => x.title === 'Dishes').length, 2, 'given to two people: each has their own'); assert.equal(bd.filter(x => x.title === 'Take out the trash').length, 1, 'an anyone chore is one');
assert.ok(bd.find(x => x.title === 'Dishes' && x.user === P.b.id).canDo && !bd.find(x => x.title === 'Dishes' && x.user === P.c.id).canDo, 'you can tick your own, not another person\'s'); assert.ok((await board('a')).every(x => x.canDo), 'an admin can tick any');
// ticking
assert.equal((await post('/done', { chore: dishes.id, date: today, user: P.c.id }, 'b')).status, 403, 'not for someone else'); assert.equal((await post('/done', { chore: dishes.id, date: today }, 'b')).status, 200);
bd = await board('c'); assert.equal(bd.find(x => x.title === 'Dishes' && x.user === P.b.id).done.byName, 'Bob'); assert.equal(bd.find(x => x.title === 'Dishes' && x.user === P.c.id).done, null, 'Cy still has his own to do'); assert.equal((await j(await post('/done', { chore: dishes.id, date: today }, 'b'))).already, true, 'ticking twice counts once');
assert.equal((await post('/done', { chore: trash.id, date: today }, 'c')).status, 200); bd = await board('b'); assert.equal(bd.find(x => x.title === 'Take out the trash').done.byName, 'Cy', 'an anyone chore: whoever does it gets it'); assert.equal((await post('/done', { chore: trash.id, date: today, undo: true }, 'b')).status, 403, 'only who ticked it (or an admin) takes it back'); assert.equal((await post('/done', { chore: trash.id, date: today, undo: true }, 'c')).status, 200); assert.equal((await board('b')).find(x => x.title === 'Take out the trash').done, null);
assert.equal((await post('/done', { chore: dishes.id, date: today, user: P.c.id }, 'a')).status, 200, 'an admin can tick for someone');
assert.equal((await post('/done', { chore: dishes.id, date: '2020-01-01' }, 'b')).status, 400, 'not years back'); assert.equal((await post('/done', { chore: dishes.id, user: P.d.id }, 'a')).status, 400, 'only to people it is given to');
// a one-off carries until done and is marked overdue
const once = await j(await post('/', { title: 'Fix the gate', repeat: 'once', date: chores.addDays(today, -2), assign: 'anyone', points: 8 })); let ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.overdue, true); await post('/done', { chore: once.id, date: today }, 'b'); ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.done.byName, 'Bob'); assert.equal(ob.overdue, false);
// points
const sm = await j(await call('/summary?range=week', {}, 'a')); const bob = sm.people.find(x => x.name === 'Bob'), cy = sm.people.find(x => x.name === 'Cy'); assert.equal(bob.points, 3 + 8, 'Bob: dishes 3 and the gate 8'); assert.equal(cy.points, 3, 'Cy: dishes ticked by an admin counts for Cy'); assert.equal(sm.people[0].name, 'Bob', 'most points first'); assert.equal(bob.perDay.length, 7); assert.equal(bob.perDay.at(-1), 2);
// delete removes its history
assert.equal((await call('/' + once.id, { method: 'DELETE' }, 'b')).status, 403); assert.equal((await call('/' + once.id, { method: 'DELETE' })).status, 200); assert.equal((await j(await call('/summary', {}, 'a'))).people.find(x => x.name === 'Bob').points, 3);
assert.equal((await call('/?date=nope')).status, 400);
console.log('chores ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+20
View File
@@ -0,0 +1,20 @@
// Notifications center: entries from chat and announcements, counting, reading, clearing.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-inbox-'));
const users = require('../users'), inbox = require('../inbox'), chat = require('../chat');
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => req.url.startsWith('/api/chat') ? chat.handle(req, res) : inbox.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port, call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
try {
const dm = (await j(await post('/api/chat/new', { type: 'direct', with: P.b.id }, 'a'))).conv; await post('/api/chat/send', { conv: dm.id, text: 'hello' }, 'a'); await post('/api/chat/send', { conv: dm.id, text: 'again' }, 'a');
let r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.length, 1, 'one entry per conversation'); assert.equal(r.items[0].count, 2); assert.equal(r.items[0].body, 'again'); assert.equal(r.unread, 1);
assert.equal((await j(await call('/api/inbox', {}, 'a'))).items.length, 0, 'the sender gets nothing');
await post('/api/chat/announcement', { text: 'Pizza night' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items[0].kind, 'announce'); assert.equal(r.unread, 2);
r = await j(await post('/api/inbox/read', { id: r.items[0].id }, 'b')); assert.equal(r.unread, 1);
await post('/api/chat/send', { conv: dm.id, text: 'new one' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 1, 'still unread: updated in place'); assert.equal(r.items.find(i => i.kind === 'chat').count, 3);
r = await j(await post('/api/inbox/read', { all: true }, 'b')); assert.equal(r.unread, 0);
await post('/api/chat/send', { conv: dm.id, text: 'after reading' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 2, 'a read entry stays; a new one starts');
await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0); console.log('inbox ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+31
View File
@@ -0,0 +1,31 @@
// Child accounts: the daily limit counts time from pings, bedtime locks, locked accounts are refused (except for what they need to ask), parents give more time and are asked.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-kid-')); process.env.HUB_TZ = 'UTC';
const users = require('../users'), kid = require('../kidlimits'), chat = require('../chat');
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), sam = users.create({ name: 'Sam', role: 'user', tabs: ['music'], handle: 'Yg' }), pat = users.create({ name: 'Pat', role: 'user', tabs: ['music'], handle: 'Yw' });
const asAdmin = (u, fn) => users.runAs(u, fn);
assert.throws(() => users.update(ann.id, { kid: { on: true } }), /admin cannot be a child/, 'an admin cannot be a child account');
users.update(sam.id, { kid: { on: true, limitMin: 3, bedtime: { on: false, from: '20:30', to: '07:00' } } }); const s0 = users.get(sam.id).kid; assert.deepEqual(s0, { on: true, limitMin: 15, bedtime: { on: false, from: '20:30', to: '07:00' }, safe: true }, 'a limit is at least 15 minutes; safe search is on unless turned off');
assert.equal(users.publicView(users.get(sam.id)).kid.limitMin, 15); assert.equal(users.publicView(users.get(pat.id)).kid, undefined, 'only child accounts carry the settings');
assert.deepEqual(kid.status(users.get(pat.id)), { on: false }); assert.equal(kid.lockedFor(users.get(pat.id), '/api/music/x'), null);
// time counts from pings (one a minute while the app is open)
const real = Date.now; let t = real(); Date.now = () => t; let st; for (let i = 0; i < 14; i++) { st = kid.ping(users.get(sam.id)); t += 60e3; } assert.equal(st.used, 13, 'the first ping counts half a minute, the rest a minute each'); assert.equal(st.left, 2); assert.equal(st.locked, null);
st = kid.ping(users.get(sam.id)); t += 60e3; st = kid.ping(users.get(sam.id)); t += 60e3; assert.equal(st.locked, 'time', 'time is up'); assert.ok(kid.lockedFor(users.get(sam.id), '/api/music/x'), 'so requests are refused');
assert.equal(kid.lockedFor(users.get(sam.id), '/api/kid/status'), null, 'except to see the status'); assert.equal(kid.lockedFor(users.get(sam.id), '/api/me'), null); const used = kid.ping(users.get(sam.id)).used; t += 60e3; assert.equal(kid.ping(users.get(sam.id)).used, used, 'a locked account stops counting');
assert.throws(() => kid.give(pat.id, 15), /not a child/); st = kid.give(sam.id, 20); assert.equal(st.locked, null, 'more time unlocks it'); assert.equal(st.limit, 35);
// bedtime
users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: true, from: '00:00', to: '23:59' } } }); assert.equal(kid.status(users.get(sam.id)).locked, 'bedtime'); users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: false } } }); assert.equal(kid.status(users.get(sam.id)).locked, null, 'no limit, no bedtime: never locked'); assert.equal(kid.status(users.get(sam.id)).left, null);
Date.now = real;
// over HTTP: who may do what, and asking a parent
const hub = http.createServer((req, res) => { const who = { sam, ann, pat }[req.headers['x-user'] || 'sam']; users.runAs(users.get(who.id), () => kid.handle(req, res, users.get(who.id), { askParents: chat.askParents })); }).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port + '/api/kid', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
try {
assert.equal((await j(await call('/status'))).on, true); assert.equal((await post('/ping', {})).status, 200); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'sam')).status, 403, 'a child cannot give themselves time'); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'ann')).status, 200);
assert.equal((await call('/usage?user=' + sam.id, {}, 'sam')).status, 403); assert.equal((await j(await call('/usage?user=' + sam.id, {}, 'ann'))).on, true);
assert.equal((await post('/ask', { text: 'More time please?' }, 'pat')).status, 400, 'only child accounts ask'); const r = await j(await post('/ask', { text: 'Can I have 15 more minutes? I am nearly done.' })); assert.equal(r.told, 1, 'the one admin is told');
const dm = 'd:' + [sam.id, ann.id].sort().join(':'); const conv = await asAdmin(users.get(ann.id), async () => { const out = {}; await chat.handle({ url: '/api/chat/conv/' + encodeURIComponent(dm), method: 'GET', headers: {} }, { writeHead() {}, end(b) { out.j = JSON.parse(b); }, headersSent: false }); return out.j; });
assert.equal(conv.msgs[0].text, 'Can I have 15 more minutes? I am nearly done.'); assert.equal(conv.msgs[0].name, 'Sam', 'it arrives as a chat message from the child');
console.log('kidlimits ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+9 -1
View File
@@ -1,10 +1,11 @@
// Free audiobooks: LibriVox search and book details, chapter audio is only ever sent to the recording hosts, each person's shelf and listening position.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-lv-'));
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-lv-')); process.env.LIBRIVOX_INDEX_PAGES = '2';
const gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'), seen = []; let port;
const fake = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p + u.search);
const J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
if (p === '/api/feed/audiobooks/' && u.searchParams.get('extended') === '1' && u.searchParams.get('offset') !== null) { const off = +u.searchParams.get('offset'); const mk = (id, title, gs) => ({ id: String(id), title, description: 'A story.', language: 'English', totaltimesecs: 600, authors: [{ first_name: 'A', last_name: 'Writer' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif`, genres: gs.map((name, i) => ({ id: String(i), name })) }); return J({ books: off === 0 ? [mk(901, 'The Hound Case', ['Crime & Mystery Fiction', 'Published 1900 onward']), mk(902, 'Dragons Abound', ['Fantastic Fiction'])] : [mk(903, 'Rhymes for Bed', ["Children's Fiction", 'Poetry']), mk(901, 'The Hound Case', ['Crime & Mystery Fiction'])] }); }
if (p === '/api/feed/audiobooks/') {
const book = { id: '55', title: 'Pride &amp; Prejudice', description: '<p>A novel</p>', url_librivox: 'https://librivox.org/pp', language: 'English', copyright_year: '1813', totaltimesecs: 600, authors: [{ first_name: 'Jane', last_name: 'Austen' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif` };
const full = { ...book, sections: [{ section_number: '1', title: 'Chapter 1', listen_url: `http://127.0.0.1:${port}/dl/1.mp3`, playtime: '300', readers: [{ display_name: 'Ann' }] }, { section_number: '2', title: 'Chapter 2', listen_url: 'https://evil.example.com/2.mp3', playtime: '300' }, { section_number: '3', title: 'Chapter 3', listen_url: `http://127.0.0.1:${port}/dl/3.mp3`, playtime: '300' }] };
@@ -39,6 +40,13 @@ const fake = http.createServer((req, res) => {
await post({ 'lv:55': { pos: 400, dur: 600, t: 10 } }); assert.equal((await post({ 'lv:55': { pos: 5, dur: 600, t: 3 } }))['lv:55'].pos, 400, 'the newest position wins');
assert.deepEqual(await j(await call('/progress', { headers: { 'x-user': 'b' } })), {});
await call('/shelf/55', { method: 'DELETE' }); assert.deepEqual(await j(await call('/shelf')), []);
// genres: gathered once into an index, then searched locally
let j2; r = await call('/search?genre=mystery'); j2 = await r.json(); assert.equal(j2.items.length, 0); assert.equal(j2.building, true, 'the first time it says the index is being built');
for (let i = 0; i < 40 && !lv.loadIdx(); i++) await new Promise(ok => setTimeout(ok, 100)); assert.ok(lv.loadIdx(), 'the index is built and kept');
j2 = await (await call('/search?genre=mystery')).json(); assert.deepEqual(j2.items.map(b => b.id), ['901'], 'a book in two pages is listed once'); assert.equal(j2.items[0].author, 'A Writer'); assert.match(j2.items[0].cover, /^\/api\/ab\/free\/cover\/901/);
assert.deepEqual((await (await call('/search?genre=children')).json()).items.map(b => b.id), ['903']); assert.deepEqual((await (await call('/search?genre=poetry')).json()).items.map(b => b.id), ['903'], 'a book can be in several genres'); assert.deepEqual((await (await call('/search?genre=fantasy')).json()).items.map(b => b.id), ['902']);
assert.deepEqual((await (await call('/search?genre=fiction&q=hound')).json()).items.map(b => b.id), ['901'], 'words narrow a genre'); assert.deepEqual((await (await call('/search?genre=fiction&q=zebra')).json()).items, []);
const gl = await (await call('/genres')).json(); assert.equal(gl.ready, true); assert.equal(gl.genres.find(g => g.id === 'mystery').count, 1); assert.equal(gl.genres.find(g => g.id === 'cooking').count, 0); assert.deepEqual(await (await call('/search?genre=nope')).json(), { items: [], more: false });
console.log('librivox test ok');
} catch (err) { console.error(err); process.exitCode = 1; }
hub.close(); fake.close();
+18
View File
@@ -0,0 +1,18 @@
// Search across the hub: notes, chat, contacts, chores and recipes (and what each person is allowed to see).
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-search-')); process.env.MEALIE_URL = 'http://127.0.0.1:9'; delete process.env.MEALIE_TOKEN;
const users = require('../users'), search = require('../search'), notes = require('../notes'), chat = require('../chat'), contacts = require('../contacts'), chores = require('../chores');
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['search', 'notes', 'chat'], handle: 'Yg' }) };
const wrap = (mod, base) => (req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => mod.handle(req, res));
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => req.url.startsWith('/api/notes') ? notes.handle(req, res) : req.url.startsWith('/api/chat') ? chat.handle(req, res) : req.url.startsWith('/api/contacts') ? contacts.handle(req, res) : req.url.startsWith('/api/chores') ? chores.handle(req, res) : search.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port, call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json(), find = async (q, who) => j(await call('/api/search?q=' + encodeURIComponent(q), {}, who));
try {
await post('/api/notes', { type: 'list', title: 'Groceries', items: ['Sourdough starter', 'Eggs'], share: 'family' }, 'a'); await post('/api/notes', { type: 'note', title: 'Private', body: 'my sourdough secret', share: 'me' }, 'a'); await post('/api/contacts', { name: 'Sourdough Bakery', emails: [{ value: 'hi@bakery.test' }] }, 'a');
await post('/api/chores', { title: 'Feed the sourdough', repeat: 'daily', assign: 'anyone', points: 2 }, 'a'); const dm = (await j(await post('/api/chat/new', { type: 'direct', with: P.b.id }, 'a'))).conv; await post('/api/chat/send', { conv: dm.id, text: 'Who has the sourdough recipe?' }, 'a');
let r = await find('sourdough', 'a'); assert.deepEqual(r.notes.map(x => x.title).sort(), ['Groceries', 'Private'], 'the admin sees both notes (their own, and the family one)'); assert.equal(r.notes.find(x => x.title === 'Groceries').snippet.includes('Sourdough starter'), true, 'with where it matched');
assert.equal(r.chat.length, 1); assert.equal(r.chat[0].convName, 'Bob'); assert.equal(r.contacts[0].name, 'Sourdough Bakery'); assert.equal(r.chores[0].title, 'Feed the sourdough'); assert.deepEqual(r.recipes, [], 'no Mealie: no recipes, and no error');
r = await find('sourdough', 'b'); assert.deepEqual(r.notes.map(x => x.title), ['Groceries'], 'Bob does not see the private note'); assert.equal(r.chat.length, 1); assert.deepEqual(r.contacts, [], 'no Contacts tab: nothing from there'); assert.deepEqual(r.chores, [], 'and none from Chores');
r = await find('s', 'a'); assert.deepEqual(r.notes, [], 'one letter is not a search'); console.log('search ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+15
View File
@@ -78,6 +78,21 @@ const fake = http.createServer((req, res) => {
r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('/ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF');
const ol = (await (await call('/search?q=moby&source=openlibrary')).json()).items; assert.deepEqual(ol.map(i => i.ref), ['moby1'], 'only freely readable editions are listed'); assert.equal(ol[0].author, 'Melville'); assert.match(decodeURIComponent(ol[0].coverUrl), /covers\.openlibrary\.org\/b\/id\/7-M\.jpg/);
r = await post('/add', { source: 'openlibrary', ref: 'moby1', title: 'Moby Dick' }); assert.equal(r.status, 200, 'an Open Library book is fetched from the Internet Archive');
// genres: each source is asked in its own words
assert.deepEqual((await (await call('/genres')).json()).slice(0, 3).map(g => g.id), ['fiction', 'mystery', 'fantasy']);
const sawSince = (n, re) => seen.slice(n).some(x => re.test(decodeURIComponent(x)));
let n0 = seen.length; await call('/search?genre=mystery&source=gutenberg'); assert.ok(sawSince(n0, /search\.opds\/\?query=s\.mystery/), 'Gutenberg gets a subject search'); n0 = seen.length; await call('/search?q=hound&genre=mystery&source=gutenberg'); assert.ok(sawSince(n0, /query=hound s\.mystery/), 'with words and a genre');
n0 = seen.length; await call('/search?genre=fantasy&source=standardebooks'); assert.ok(sawSince(n0, /tags\[\]=Fantasy/), 'Standard Ebooks gets a tag'); n0 = seen.length; await call('/search?genre=classics&source=standardebooks'); assert.ok(!sawSince(n0, /\/ebooks\?/), 'a genre it has no tag for is not asked');
n0 = seen.length; await call('/search?genre=history&source=archive'); assert.ok(sawSince(n0, /advancedsearch[^ ]*subject:\(history\)/), 'the Archive is asked by subject even with no words'); n0 = seen.length; await call('/search?genre=poetry&source=openlibrary'); assert.ok(sawSince(n0, /subject=poetry&has_fulltext=true/), 'Open Library by subject');
n0 = seen.length; await call('/search?genre=mystery&source=smashwords'); assert.ok(sawSince(n0, /shelf\/mostdownloads\/879\//), 'Smashwords by its category number'); n0 = seen.length; j = await (await call('/search?genre=philosophy&source=smashwords')).json(); assert.deepEqual(j.items, [], 'a genre Smashwords has no category for gives nothing');
j = await (await call('/search?genre=mystery')).json(); assert.ok(j.items.length > 0 && Object.keys(j.errors || {}).length === 0, 'asking every source at once works'); assert.deepEqual((await (await call('/search?genre=nope&source=gutenberg')).json()).errors, {}, 'an unknown genre is just ignored');
// a child account sees a few kid-friendly sources and genres
users.update(people.b.id, { kid: { on: true, limitMin: 0, bedtime: { on: false }, safe: true } }); const kb = { 'x-user': 'b' };
assert.deepEqual((await (await call('/sources', { headers: kb })).json()).map(s => s.id), ['gutenberg', 'standardebooks'], 'only two sources');
n0 = seen.length; j = await (await call('/search?q=oz&source=all', { headers: kb })).json(); assert.ok(sawSince(n0, /search\.opds\/\?query=oz s\.juvenile/), 'with no genre, it is the children\'s genre'); assert.ok(!sawSince(n0, /\/ia\/|\/ol\/|\/sw\//), 'and the other sources are not asked');
n0 = seen.length; await call('/search?genre=mystery&source=gutenberg', { headers: kb }); assert.ok(sawSince(n0, /s\.juvenile/) && !sawSince(n0, /s\.mystery/), 'a genre outside the kid list becomes the children\'s one');
assert.equal((await call('/search?source=smashwords', { headers: kb })).status, 403); assert.equal((await post('/add', { source: 'smashwords', ref: '101' }, { 'x-user': 'b' })).status, 403, 'and cannot add from the others');
users.update(people.b.id, { kid: { on: false } });
// Smashwords: the family filter, the sign-in, and the download
r = await call('/search?q=&source=smashwords'); j = await r.json(); assert.deepEqual(j.items.map(i => i.ref), ['101', '105', '110'], 'adult, LGBTQ+, keyword-flagged, unlabelled romance and paid books are left out; a clean romance and a harmless name are kept'); assert.equal(j.items[0].author, 'Ann Author'); assert.match(j.items[0].cover, /^\/api\/read\/store\/cover\?s=smashwords/);
j = await (await call('/search?q=pilgrim&source=smashwords')).json(); assert.deepEqual(j.items.map(i => i.ref), ['101'], 'searching looks through the free books it can see'); assert.deepEqual((await (await call('/search?q=gay&source=smashwords')).json()).items.map(i => i.ref), ['105']);
+1 -1
View File
@@ -93,7 +93,7 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
r = await admin.call('/api/audit'); const evs = (r.json.events || []).map(e => e.event);
ok('the admin sees the audit log: invite use, recovery codes and recovery use are recorded', ['recovery codes created', 'recovery code used', 'passkey added with invite'].every(e => evs.includes(e)), evs);
ok('and it never contains a code', !JSON.stringify(r.json).includes(recCodes[0]) && !JSON.stringify(r.json).includes(samCode), 'code leaked');
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json);
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Family, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,family,home,search,settings,tasks', r.json);
// ---- private data
r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json);
+5 -4
View File
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
fs.mkdirSync(DATA_DIR, { recursive: true });
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const ALWAYS = ['home', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'chores', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const ALWAYS = ['home', 'family', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', chores: 'Chores', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
let cache = null;
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
@@ -60,6 +60,7 @@ function update(id, patch) {
if (d && u.role === 'admin' && adminCount() <= 1) throw new Error('There must be at least one admin');
u.disabled = d; u.epoch++; // disabling also signs them out everywhere
}
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.signOutEverywhere) u.epoch++;
save(); return u;
}
@@ -73,7 +74,7 @@ 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 });
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 } : {}) });
// ---- 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.)