Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95ade02bf2 | ||
|
|
275ef2009f | ||
|
|
435f442761 | ||
|
|
db58bfa982 | ||
|
|
94d13b82d7 | ||
|
|
f80b718bd4 | ||
|
|
808274b4fe | ||
|
|
54d18594a3 | ||
|
|
2766424bb2 | ||
|
|
fe651a8c37 | ||
|
|
d761020869 | ||
|
|
b2b772941b | ||
|
|
342c8af747 | ||
|
|
b6786881c3 | ||
|
|
c51134e0e5 | ||
|
|
bcaedbab5c | ||
|
|
e506c4ce98 | ||
|
|
b0ca8daa2e | ||
|
|
d7ee6f1339 | ||
|
|
3e3505ba33 | ||
|
|
a20858a6e1 | ||
|
|
388dd5b0e7 | ||
|
|
7b708a711c | ||
|
|
36395eb7f0 | ||
|
|
714680a94e | ||
|
|
c97b62004e | ||
|
|
98b428e8af | ||
|
|
a09d6b17f4 | ||
|
|
41c48b10e8 | ||
|
|
b0d60d0a5b | ||
|
|
71e759e477 | ||
|
|
24dd05db5d | ||
|
|
8d31d98eaa | ||
|
|
4d6cf9b961 | ||
|
|
dfe9880e47 | ||
|
|
822eb499e4 | ||
|
|
cc064d0ccf | ||
|
|
b204f968c6 |
No files matched your search
@@ -18,6 +18,7 @@ jobs:
|
||||
node-version: 22
|
||||
- run: npm ci
|
||||
- run: npm audit --omit=dev --audit-level=high
|
||||
- run: node tools/frontend-test.js # every page script loads (no syntax errors, no names declared twice)
|
||||
- run: node tools/smoke-test.js
|
||||
- run: node tools/tasks-test.js # repeating tasks and subtasks
|
||||
- run: node tools/push-test.js # reminders: when they are sent, never twice
|
||||
@@ -30,6 +31,11 @@ jobs:
|
||||
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
|
||||
- run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy
|
||||
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
||||
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
||||
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
||||
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
||||
|
||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
||||
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
||||
|
||||
@@ -160,7 +160,7 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free
|
||||
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
|
||||
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
|
||||
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
|
||||
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`). A refused request rejects with an error. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
|
||||
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`), `hub.notes.list() / add({ title, body })` (new notes are private), `hub.recipes.search(text) / get(slug)` and `hub.music.nowPlaying()` (what is playing, or `null`). A refused request rejects with an error. Each permission can be switched off again from the extension's **Permissions** button, and when an admin changes an extension (its version or file) the people who have it see "Updated" the next time they open it. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
|
||||
|
||||
Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions up to 2 MB). Only an admin can add, change or delete items.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async function direct(path, init = {}) {
|
||||
}
|
||||
|
||||
// Listening positions kept by the hub (direct mode)
|
||||
const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge;
|
||||
const prog = require('./progress')('audiobook-progress.json', 'audiobook'), progLoad = prog.load, progMerge = prog.merge;
|
||||
|
||||
async function upstream(me, path, init = {}, retry = true) {
|
||||
if (OA) return direct(path, init);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Automations: "when this happens, do that" rules, one set per person. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to the Shopping
|
||||
// list, or notify me. Rules only ever act on the person's own things, a rule runs at most 20 times a day, and what a rule does never starts another rule.
|
||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
const WHEN = ['time', 'task.done', 'mail.new', 'pantry.low', 'podcast.new'], THEN = ['task', 'shopping', 'notify'], MAX_RULES = 30, DAILY_CAP = 20;
|
||||
const file = () => path.join(users.dir(), 'automations.json');
|
||||
const load = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const save = l => atomicWrite(file(), JSON.stringify(l, null, 1), { mode: 0o600 });
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
|
||||
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
||||
|
||||
/** A rule from what the browser sent, or an error that says what is missing. */
|
||||
function clean(b, old) {
|
||||
const wt = String(b.when && b.when.type), tt = String(b.then && b.then.type);
|
||||
if (!WHEN.includes(wt)) throw fail(400, 'Choose when it should happen'); if (!THEN.includes(tt)) throw fail(400, 'Choose what it should do');
|
||||
const when = { type: wt }; if (wt === 'time') { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(b.when.at || ''))) throw fail(400, 'Pick a time of day'); when.at = b.when.at; } else when.match = str(b.when.match, 80);
|
||||
if (wt === 'mail.new' && !when.match) throw fail(400, 'Say what the sender or subject should contain (so it is not every email)');
|
||||
if (wt === 'podcast.new' && !when.match) throw fail(400, 'Say which show (part of its name)');
|
||||
const text = str(b.then.text, 200); if (!text) throw fail(400, tt === 'notify' ? 'Write the message' : 'Write the task');
|
||||
return { id: old ? old.id : crypto.randomBytes(5).toString('hex'), name: str(b.name, 60) || (wt === 'time' ? 'Every day at ' + when.at : wt === 'task.done' ? 'When a task is finished' : wt === 'pantry.low' ? 'When the pantry runs low' : wt === 'podcast.new' ? 'When a new episode comes out' : 'When mail arrives'), on: b.on !== false, when, then: { type: tt, text }, runs: old ? old.runs || 0 : 0, last: old ? old.last || 0 : 0, day: old ? old.day : '', today: old ? old.today || 0 : 0 };
|
||||
}
|
||||
const fill = (text, ctx) => text.replace(/\{(title|from|subject|show)\}/g, (_, k) => str(ctx[k] || ctx.title || '', 120));
|
||||
/** Do a rule's action for the person it belongs to (run inside their context). */
|
||||
async function act(rule, ctx) {
|
||||
const me = users.current(), text = fill(rule.then.text, ctx);
|
||||
if (rule.then.type === 'task' || rule.then.type === 'shopping') { require('./tasks').addLocalTask(rule.then.type === 'shopping' ? 'Shopping' : '', text); return; }
|
||||
require('./inbox').add(me.id, { kind: 'info', title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id });
|
||||
try { await require('./push').notify(me.id, { title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id }); } catch { /* the Notifications list has it */ }
|
||||
}
|
||||
async function run(rule, ctx) {
|
||||
const l = load(), r = l.find(x => x.id === rule.id); if (!r) return false;
|
||||
if (r.day !== today()) { r.day = today(); r.today = 0; } if (r.today >= DAILY_CAP) return false;
|
||||
r.today++; r.runs = (r.runs || 0) + 1; r.last = Date.now(); save(l);
|
||||
try { await act(r, ctx); return true; } catch (e) { console.warn('[automations]', r.name, e.message); return false; }
|
||||
}
|
||||
/** Something happened for a person: run their matching rules. type: 'task.done' ({ title }) or 'mail.new' ({ from, subject }). */
|
||||
async function fire(uid, type, ctx) {
|
||||
const u = users.get(uid); if (!u || u.disabled) return;
|
||||
await users.runAs(u, async () => {
|
||||
for (const r of load()) {
|
||||
if (!r.on || r.when.type !== type) continue; const m = r.when.match.toLowerCase(), hay = (type === 'mail.new' ? (ctx.from || '') + ' ' + (ctx.subject || '') : type === 'podcast.new' ? (ctx.show || '') + ' ' + (ctx.title || '') : ctx.title || '').toLowerCase();
|
||||
if (m && !hay.includes(m)) continue; await run(r, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Something happened that concerns everyone (the pantry went low, a new episode): run every person's matching rules. */
|
||||
async function fireAll(type, ctx) { for (const u of users.all()) await fire(u.id, type, ctx).catch(() => {}); }
|
||||
/** Daily rules whose time has come, for one person; `lt` is their own { day, hm }. Run once a minute. */
|
||||
async function tickUser(u, lt) {
|
||||
await users.runAs(u, async () => {
|
||||
for (const r of load()) if (r.on && r.when.type === 'time' && r.when.at <= lt.hm && r.doneDay !== lt.day) {
|
||||
const l = load(), x = l.find(y => y.id === r.id); if (!x) continue; x.doneDay = lt.day; save(l); // (marked first, so a slow action is never run twice)
|
||||
if (lt.hm >= r.when.at && minutes(lt.hm) - minutes(r.when.at) <= 180) await run(x, { title: x.name }); // (not for a time that passed hours ago, such as a rule made in the evening)
|
||||
}
|
||||
});
|
||||
}
|
||||
/** New podcast episodes since the last look (not the first look, which only notes what is already there): run the "new episode" rules. Checked every half hour by push.js. */
|
||||
const SEEN = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'podcast-seen.json');
|
||||
async function checkEpisodes(getRecent) {
|
||||
let seen = null; try { seen = JSON.parse(fs.readFileSync(SEEN(), 'utf8')); } catch { /* the first look */ }
|
||||
const eps = await getRecent(5); if (!Array.isArray(eps)) return 0;
|
||||
const ids = new Set(Array.isArray(seen) ? seen : []), fresh = Array.isArray(seen) && seen.length ? eps.filter(e => e.id && !ids.has(e.id)) : [];
|
||||
atomicWrite(SEEN(), JSON.stringify([...new Set([...eps.map(e => e.id), ...ids])].slice(-600)), { mode: 0o600 });
|
||||
for (const e of fresh.slice(0, 10)) await fireAll('podcast.new', { title: e.title, show: e.show });
|
||||
return fresh.length;
|
||||
}
|
||||
const minutes = hm => +hm.slice(0, 2) * 60 + +hm.slice(3);
|
||||
|
||||
async function handle(req, res) {
|
||||
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/auto'.length) || '/';
|
||||
try {
|
||||
if (!me) throw fail(401, 'Sign in');
|
||||
if (p === '/' && req.method === 'GET') return json(res, 200, { rules: load().map(({ day, doneDay, ...r }) => r) });
|
||||
if (req.method !== 'POST') throw fail(404, 'Not found');
|
||||
const b = JSON.parse(await readBody(req, 2e4) || '{}'), l = load();
|
||||
if (p === '/') { const i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (b.id && i < 0) throw fail(404, 'That rule is gone'); if (i < 0 && l.length >= MAX_RULES) throw fail(400, 'That is the most rules (' + MAX_RULES + ')'); const r = clean(b, i >= 0 ? l[i] : null); if (i >= 0) l[i] = r; else l.push(r); save(l); return json(res, 200, { rules: l }); }
|
||||
const i = l.findIndex(x => x.id === b.id); if (i < 0) throw fail(404, 'That rule is gone');
|
||||
if (p === '/toggle') { l[i].on = !!b.on; save(l); return json(res, 200, { rules: l }); }
|
||||
if (p === '/delete') { l.splice(i, 1); save(l); return json(res, 200, { rules: l }); }
|
||||
if (p === '/test') { const ok = await run(l[i], { title: 'a test', from: 'Test', subject: 'a test' }); return json(res, 200, { ok }); }
|
||||
throw fail(404, 'Not found');
|
||||
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||
}
|
||||
module.exports = { handle, fire, fireAll, tickUser, checkEpisodes };
|
||||
@@ -1,13 +1,61 @@
|
||||
// Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz.
|
||||
// Treat the file like a password: it contains the session secret and encrypted sign-in tokens.
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
// Restore (admin): upload that .tar.gz; it is checked, unpacked beside the data, and each top-level item is swapped in with the old one kept in .pre-restore-<date>; then the hub restarts.
|
||||
// Export (anyone): your own data as a .tar.gz, without sign-in tokens.
|
||||
const { spawn, execFile } = require('child_process');
|
||||
const fs = require('fs'), path = require('path'), { pipeline } = require('stream/promises');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
|
||||
const MAX_RESTORE = 4 * 1024 ** 3;
|
||||
const send = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const run = (cmd, args) => new Promise((ok, no) => execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024 }, (e, out, err) => e ? no(new Error((String(err) || e.message).trim().slice(0, 300))) : ok(String(out))));
|
||||
const rm = p => fs.rmSync(p, { recursive: true, force: true });
|
||||
/** Refuse an archive that could write outside the data folder or plant links: every name must stay inside, and only plain files and folders are allowed. */
|
||||
async function checkArchive(file) {
|
||||
const names = (await run('tar', ['-tzf', file])).split('\n').filter(Boolean), long = (await run('tar', ['-tvzf', file])).split('\n').filter(Boolean);
|
||||
if (!names.length) throw new Error('That backup is empty');
|
||||
for (const n of names) if (path.isAbsolute(n) || n.split('/').includes('..')) throw new Error('That is not a backup this hub made (unsafe file names)');
|
||||
for (const l of long) if (!/^[-d]/.test(l)) throw new Error('That is not a backup this hub made (it contains links or special files)');
|
||||
return names;
|
||||
}
|
||||
async function restore(req, res, who) {
|
||||
if (req.method !== 'POST') return send(res, 405, { error: 'Use POST' });
|
||||
const stage = path.join(DATA_DIR, '.restore'), up = stage + '.tar.gz', out = path.join(stage, 'files'); rm(stage); rm(up);
|
||||
try {
|
||||
if ((+req.headers['content-length'] || 0) > MAX_RESTORE) return send(res, 413, { error: 'That file is too large' });
|
||||
let n = 0; const cap = new (require('stream').Transform)({ transform(c, _e, cb) { n += c.length; cb(n > MAX_RESTORE ? Object.assign(new Error('That file is too large'), { status: 413 }) : null, c); } });
|
||||
await pipeline(req, cap, fs.createWriteStream(up, { mode: 0o600 }));
|
||||
await checkArchive(up); fs.mkdirSync(out, { recursive: true, mode: 0o700 }); await run('tar', ['-xzf', up, '--no-same-owner', '--no-same-permissions', '-C', out]);
|
||||
const items = fs.readdirSync(out); if (!items.includes('secret.json') && !items.includes('users')) throw new Error('That does not look like an NE Hub backup (no accounts or secret in it)');
|
||||
const keep = path.join(DATA_DIR, '.pre-restore-' + new Date().toISOString().replace(/[:.]/g, '-')); fs.mkdirSync(keep, { recursive: true, mode: 0o700 });
|
||||
for (const it of items) { const dst = path.join(DATA_DIR, it); if (fs.existsSync(dst)) fs.renameSync(dst, path.join(keep, it)); fs.renameSync(path.join(out, it), dst); }
|
||||
audit.log('backup restored', { who: who || '', items: items.length, kept: path.basename(keep) }, req);
|
||||
send(res, 200, { ok: true, items: items.length, kept: path.basename(keep), restarting: true });
|
||||
if (!process.env.NO_RESTART_AFTER_RESTORE) setTimeout(() => process.exit(0), 800); // the container restarts (restart: unless-stopped) and loads the restored data
|
||||
} catch (e) { if (!res.headersSent) send(res, e.status || 400, { error: e.message }); }
|
||||
finally { rm(stage); rm(up); }
|
||||
}
|
||||
/** Your own data as a .tar.gz: tasks, contacts, settings, Bible notes, your notes and so on. Sign-in tokens for mail, iCloud and Plex are left out. */
|
||||
async function exportMine(req, res) {
|
||||
const me = users.current(); if (!me) return send(res, 401, { error: 'Sign in' });
|
||||
const tmp = fs.mkdtempSync(path.join(DATA_DIR, '.export-')), SECRET = new Set(['mail-accounts.json', 'mail.json', 'icloud.json', 'plex.json', 'push.json']);
|
||||
try {
|
||||
fs.cpSync(users.dirOf(me.id), path.join(tmp, 'me'), { recursive: true, filter: s => !SECRET.has(path.basename(s)) });
|
||||
try { const notes = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'notes.json'), 'utf8')), l = Array.isArray(notes) ? notes : notes.notes || []; fs.writeFileSync(path.join(tmp, 'me', 'my-notes.json'), JSON.stringify(l.filter(n => n.owner === me.id), null, 1)); } catch { /* no notes yet */ }
|
||||
const name = 'nehub-' + String(me.name || 'me').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
||||
const tar = spawn('tar', ['-czf', '-', '--exclude=*.tmp', '-C', tmp, 'me'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
||||
tar.stdout.pipe(res); res.on('close', () => { tar.kill(); rm(tmp); }); tar.on('error', () => res.destroy());
|
||||
audit.log('personal export', { who: me.name }, req);
|
||||
} catch (e) { rm(tmp); if (!res.headersSent) send(res, 500, { error: 'Could not make the export: ' + e.message }); }
|
||||
}
|
||||
function handle(req, res, who) {
|
||||
if (new URL(req.url, 'http://x').pathname === '/api/backup/restore') return restore(req, res, who);
|
||||
if (new URL(req.url, 'http://x').pathname === '/api/export') return exportMine(req, res);
|
||||
if (req.method !== 'GET') { res.writeHead(405); return res.end(); }
|
||||
const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
||||
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=./.restore*', '--exclude=./.export-*', '--exclude=./.pre-restore-*', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); });
|
||||
tar.stderr.on('data', d => console.warn('[backup]', String(d).trim()));
|
||||
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
||||
@@ -15,4 +63,4 @@ function handle(req, res, who) {
|
||||
res.on('close', () => tar.kill());
|
||||
audit.log('backup downloaded', { who: who || '' }, req);
|
||||
}
|
||||
module.exports = { handle };
|
||||
module.exports = { handle, checkArchive };
|
||||
@@ -4,7 +4,7 @@ const fs = require('fs'), path = require('path');
|
||||
const users = require('./users');
|
||||
|
||||
const file = () => path.join(users.dir(), 'bible.json');
|
||||
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } };
|
||||
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [], plan: o.plan || null }; } catch { return { pos: null, marks: [], plan: null }; } };
|
||||
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
|
||||
@@ -23,9 +23,33 @@ function mergeMarks(have, sent) {
|
||||
}
|
||||
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
|
||||
|
||||
|
||||
// ---- Devotion: a one-year reading plan with a check-in per day (kept per person), and a family prayer list everyone can add to and mark answered.
|
||||
const crypto = require('crypto');
|
||||
const PRAYERS = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'prayers.json');
|
||||
const loadPrayers = () => { try { const a = JSON.parse(fs.readFileSync(PRAYERS(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const savePrayers = a => { const f = PRAYERS(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(a), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||
const prayerView = (x, me) => ({ id: x.id, text: x.text, by: x.by, byName: (users.get(x.by) || {}).name || 'Someone', at: x.at, answered: !!x.answered, answeredAt: x.answeredAt || 0, mine: x.by === me.id });
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
async function devotion(req, res, p) {
|
||||
const me = users.current(); if (!me) return json(res, 401, { error: 'Sign in' });
|
||||
if (p === '/prayers' && req.method === 'GET') return json(res, 200, loadPrayers().map(x => prayerView(x, me)));
|
||||
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
|
||||
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' });
|
||||
if (p === '/plan') { const s = load(); if (b.stop) s.plan = null; else if (DAY.test(b.start || '')) s.plan = { start: b.start, done: [] }; else return json(res, 400, { error: 'Pick a start date' }); save(s); return json(res, 200, { plan: s.plan }); }
|
||||
if (p === '/plan/check') { const s = load(), d = Math.round(+b.day); if (!s.plan || !(d >= 0 && d < 366)) return json(res, 400, { error: 'No plan' }); const set = new Set(s.plan.done); if (b.on) set.add(d); else set.delete(d); s.plan.done = [...set].sort((x, y) => x - y); save(s); return json(res, 200, { plan: s.plan }); }
|
||||
const l = loadPrayers();
|
||||
if (p === '/prayers') { const text = clip(b.text, 500); if (!text) return json(res, 400, { error: 'Write the prayer request' }); if (l.length >= 500) return json(res, 400, { error: 'The prayer list is full' }); l.unshift({ id: crypto.randomBytes(6).toString('hex'), text, by: me.id, at: Date.now() }); savePrayers(l); return json(res, 200, l.map(x => prayerView(x, me))); }
|
||||
const x = l.find(y => y.id === b.id); if (!x) return json(res, 404, { error: 'That request is gone' });
|
||||
if (p === '/prayers/answer') { x.answered = !!b.on; x.answeredAt = b.on ? Date.now() : 0; savePrayers(l); return json(res, 200, l.map(y => prayerView(y, me))); }
|
||||
if (p === '/prayers/delete') { if (x.by !== me.id && me.role !== 'admin') return json(res, 403, { error: 'Only the person who wrote it can remove it' }); savePrayers(l.filter(y => y !== x)); return json(res, 200, l.filter(y => y !== x).map(y => prayerView(y, me))); }
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
|
||||
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); }
|
||||
if (p === '/plan' || p.startsWith('/plan/') || p.startsWith('/prayers')) return devotion(req, res, p);
|
||||
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del), plan: s.plan }); }
|
||||
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
|
||||
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
|
||||
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };
|
||||
|
||||
@@ -48,7 +48,7 @@ function eventFields(title, b) {
|
||||
async function board(day) {
|
||||
const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5;
|
||||
let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {}
|
||||
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
|
||||
@@ -116,7 +116,8 @@ async function handle(req, res) {
|
||||
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
||||
const st = state(), cur = st[id] || {};
|
||||
if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() };
|
||||
saveState(st); return json(res, 200, st[id]);
|
||||
saveState(st); if (st[id] && st[id].pct >= 0.98) { try { require('./progress').markFinished('ebook:' + id, 'ebook'); } catch { /* the goal count is a nicety */ } }
|
||||
return json(res, 200, st[id]);
|
||||
}
|
||||
|
||||
if (part === 'notes') {
|
||||
|
||||
@@ -231,6 +231,8 @@ const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b',
|
||||
const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares
|
||||
const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; };
|
||||
const myId = () => users.current().id;
|
||||
/** The colour of whoever added an event (the family calendar shows each person's own colour). */
|
||||
const byCol = ev => ev && ev.by && users.get(ev.by) ? users.colorOf(users.get(ev.by)) : '';
|
||||
|
||||
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
|
||||
function calsIn(dir) {
|
||||
@@ -326,18 +328,31 @@ async function collectEvents(win) {
|
||||
const mine = dirFor(myId()), evs = eventsIn(mine);
|
||||
const add = (dir, evs, c, id, extra) => {
|
||||
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
|
||||
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
|
||||
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: (extra.family && byCol(evs.find(x => x.uid === e.uid))) || over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
|
||||
};
|
||||
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
|
||||
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
|
||||
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName, family: sh.owner === FAM });
|
||||
} catch (e) { errors.push('NE Hub: ' + e.message); }
|
||||
try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts
|
||||
return { events: all, errors };
|
||||
}
|
||||
|
||||
/** An invitation inside the family: the event is put on each invited person's own calendar and they are told. (They can delete it if they cannot come.) */
|
||||
function invite(ids, uid, fields, ok) {
|
||||
if (!ok || !Array.isArray(ids) || !ids.length) return 0; const me = users.current(); let n = 0;
|
||||
for (const id of new Set(ids.map(String))) {
|
||||
const who = users.get(id); if (!who || who.disabled || who.id === me.id || !users.can(who, 'calendar')) continue;
|
||||
const dir = dirFor(who.id), all = eventsIn(dir), cal = calsIn(dir)[0].id; if (all.some(x => x.uid === uid && evCal(x) === cal)) continue;
|
||||
all.push({ uid, cal, fields, created: Date.now(), updated: Date.now(), by: me.id, byName: me.name, invitedBy: me.name }); saveEvents(dir, all); n++;
|
||||
try { require('./inbox').add(who.id, { kind: 'info', title: `${me.name} invited you: ${String(fields.title || '').slice(0, 60)}`, body: String(fields.start || '').replace('T', ' ').slice(0, 16), url: '#/calendar', tag: 'invite:' + uid }); } catch { /* the event is on their calendar anyway */ }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
try {
|
||||
if (u.pathname === '/api/cal/people') return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId() && users.can(x, 'calendar')).map(x => ({ id: x.id, name: x.name, color: users.colorOf(x) })));
|
||||
if (u.pathname === '/api/cal/list') {
|
||||
const over = colorOverrides();
|
||||
return json(res, 200, [
|
||||
@@ -438,7 +453,8 @@ async function handle(req, res) {
|
||||
const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() };
|
||||
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it)
|
||||
saveEvents(r.dir, all);
|
||||
return json(res, 200, { ok: true, uid });
|
||||
const invited = invite(b.invite, uid, b.fields, r.dir === dirFor(myId()) || r.dir === dirFor(FAM));
|
||||
return json(res, 200, { ok: true, uid, invited });
|
||||
}
|
||||
if (caldav.isCal(b.cal)) {
|
||||
ics.validate(b.fields);
|
||||
@@ -480,6 +496,6 @@ async function handle(req, res) {
|
||||
/** The Family calendar's events in a window, for the family board (no signed-in person needed). */
|
||||
function familyEvents(win) {
|
||||
const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields })));
|
||||
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
|
||||
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: byCol(evs.find(x => x.uid === e.uid)) || FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
|
||||
}
|
||||
module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
|
||||
@@ -17,6 +17,19 @@ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'applicat
|
||||
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, '').slice(0, n);
|
||||
const newId = () => crypto.randomBytes(8).toString('hex');
|
||||
// ---- share cards: a song, film, book... from elsewhere in the hub, with the picture and a link back to it
|
||||
const SHARE = { song: ['🎵', 'a song'], album: ['💿', 'an album'], artist: ['🎤', 'an artist'], playlist: ['📃', 'a playlist'], movie: ['🎬', 'a movie'], show: ['📺', 'a show'], episode: ['📺', 'an episode'], book: ['📖', 'a book'], audiobook: ['🎧', 'an audiobook'], podcast: ['🎙️', 'a podcast'], podcastep: ['🎙️', 'a podcast episode'], radio: ['📻', 'a radio station'] };
|
||||
const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+,=&?-]*)*$/;
|
||||
/** What a client sent as a share, made safe: whitelisted kind, short words, a picture from this hub only and a link that is a hash route (never another site). */
|
||||
function cleanShare(s) {
|
||||
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
|
||||
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
|
||||
if (!title) throw fail(400, 'Nothing to share');
|
||||
if (link.length > 300 || !LINK_OK.test(link)) throw fail(400, 'That link cannot be shared');
|
||||
if (art && (art.length > 600 || !ART_OK.test(art) || art.includes('..'))) throw fail(400, 'That picture cannot be shared');
|
||||
return { kind: s.kind, title, sub: str(s.sub, 120).trim(), art, link };
|
||||
}
|
||||
const shareLine = s => SHARE[s.kind][0] + ' Shared ' + SHARE[s.kind][1] + ': ' + s.title;
|
||||
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
|
||||
|
||||
// ---- storage (small enough to hold in memory and write whole)
|
||||
@@ -44,9 +57,10 @@ const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone';
|
||||
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
|
||||
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
|
||||
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
|
||||
const textOf = m => [m.text, m.share && m.share.title, m.share && m.share.sub].filter(Boolean).join(' · ');
|
||||
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
|
||||
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
|
||||
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
|
||||
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.share ? SHARE[m.share.kind][0] + ' ' + m.share.title : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
|
||||
|
||||
// ---- live updates (server-sent events), one stream per open app
|
||||
const annFile = () => path.join(ROOT(), 'announcement.json');
|
||||
@@ -60,7 +74,7 @@ const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.
|
||||
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
|
||||
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
|
||||
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
|
||||
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
|
||||
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, share: m.deleted ? undefined : m.share, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
|
||||
/** Someone has received or read the chat up to now: the senders' ticks change. */
|
||||
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
|
||||
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
|
||||
@@ -78,15 +92,22 @@ const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid =
|
||||
function send(me, b) {
|
||||
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
|
||||
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
|
||||
if (!text && !att.length) throw fail(400, 'Write something or attach a file');
|
||||
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
|
||||
return addMsg(me, c, { text, att, quote });
|
||||
const share = b.share ? cleanShare(b.share) : undefined;
|
||||
if (!text && !att.length && !share) throw fail(400, 'Write something or attach a file');
|
||||
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.share ? q.share.title : q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
|
||||
return addMsg(me, c, { text, att, quote, share });
|
||||
}
|
||||
/** This person has read a chat up to now (they opened it, or cleared its notification): their unread count, the senders' ticks and the Notifications list all follow. */
|
||||
function markRead(uid, c) {
|
||||
if (typeof c === 'string') c = convs().find(x => x.id === c && x.members.includes(uid)); if (!c) return;
|
||||
const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(uid, st);
|
||||
inbox.markTagRead(uid, 'chat:' + c.id); emit([uid], { t: 'read', conv: c.id, unread: totalUnread(uid) }); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id });
|
||||
}
|
||||
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
|
||||
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) {
|
||||
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
|
||||
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll, share }) {
|
||||
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, share, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
|
||||
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
|
||||
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;
|
||||
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : share ? shareLine(share) : 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 });
|
||||
@@ -176,16 +197,16 @@ async function handle(req, res) {
|
||||
if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
|
||||
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
|
||||
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); }
|
||||
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.share = undefined; x.react = {}; saveFiles(); }
|
||||
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); emit(c.members.filter(x => x !== me.id), { t: 'receipt', conv: c.id }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
|
||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); markRead(me.id, c); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
|
||||
if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); }
|
||||
if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null));
|
||||
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); 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);
|
||||
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', ref: { announce: a.id } }); 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 }); }
|
||||
@@ -213,11 +234,11 @@ async function handle(req, res) {
|
||||
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
|
||||
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
|
||||
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) });
|
||||
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, share: x.share, fwd: { name: nameOf(x.from) } }) });
|
||||
}
|
||||
if (p === '/search' && req.method === 'GET') {
|
||||
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const 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) }); }
|
||||
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 && textOf(x).toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
||||
return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
|
||||
}
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
@@ -236,7 +257,7 @@ function askParents(me, text) {
|
||||
/** 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) }); }
|
||||
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 && textOf(x).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 = { announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
|
||||
module.exports = { cleanShare, markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
|
||||
@@ -18,7 +18,7 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n);
|
||||
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 : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [] }; } })());
|
||||
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 : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {} }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {} }; } })());
|
||||
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'; };
|
||||
@@ -76,7 +76,7 @@ const forMe = (t, uid) => !Array.isArray(t.for) || t.for.includes(uid); // a rew
|
||||
function week(day) {
|
||||
const from = monday(day), to = addDays(from, 6), d = db(), tiers = d.rewards.slice().sort((a, b) => a.points - b.points), per = new Map(people().filter(p => p.kid).map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, done: [] }]));
|
||||
for (const l of d.log) { if (l.date < from || l.date > to) continue; const e = per.get(l.by) || (liveKid(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, done: [] }).get(l.by) : null); if (!e) continue; const c = d.chores.find(x => x.id === l.chore); e.points += l.pts || 0; e.count++; e.done.push({ title: c ? c.title : 'A chore that was removed', date: l.date, pts: l.pts || 0 }); }
|
||||
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
|
||||
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward), goal: (g => tiers.some(t => t.id === g && forMe(t, e.id)) ? g : null)((d.goals[e.id] || {})[from]) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
|
||||
return { from, to, tiers, people: out };
|
||||
}
|
||||
const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const bonus = Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)), r = { id: old ? old.id : newId(), points, title }; if (bonus) r.minutes = bonus; /* (optional: extra screen time that comes with the reward) */ const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); r.for = who; } return r; };
|
||||
@@ -86,7 +86,7 @@ async function handle(req, res) {
|
||||
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, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), 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 === 'GET') return json(res, 200, { me: { id: me.id, admin, kid: isKid(me) }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), 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);
|
||||
@@ -116,7 +116,7 @@ async function handle(req, res) {
|
||||
if (p === '/week' && req.method === 'GET') return json(res, 200, week(day));
|
||||
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
|
||||
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
|
||||
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); save(); return json(res, 200, { ok: true }); }
|
||||
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); for (const k of Object.keys(d.goals)) for (const w of Object.keys(d.goals[k])) if (d.goals[k][w] === b.id) delete d.goals[k][w]; save(); return json(res, 200, { ok: true }); }
|
||||
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
||||
}
|
||||
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
|
||||
@@ -124,11 +124,22 @@ async function handle(req, res) {
|
||||
if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); }
|
||||
const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) { d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); const rw = d.rewards.find(x => x.id === String(b.reward)); if (rw && rw.minutes) { try { require('./kidlimits').give(e.id, rw.minutes); } catch {} } } /* (a reward with bonus screen time adds it to today) */ if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for (an admin can choose for a child); no reward clears it
|
||||
const b = JSON.parse(await readBody(req, 2e3) || '{}'), d = db(), uid = admin && b.user ? String(b.user) : me.id;
|
||||
if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts');
|
||||
const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday)
|
||||
if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); }
|
||||
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} return json(res, 200, { ok: true, goal: t.id });
|
||||
}
|
||||
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 }); }
|
||||
}
|
||||
|
||||
/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */
|
||||
function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; }
|
||||
function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); }
|
||||
|
||||
/** 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, week, ymd, addDays, find };
|
||||
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find, goalNeeded, goalReminded };
|
||||
+9
-5
@@ -28,7 +28,7 @@ function clean(b, old = {}) {
|
||||
const c = {
|
||||
id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120),
|
||||
emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300),
|
||||
birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(),
|
||||
birthday: cleanBirthday(b.birthday), anniversary: cleanBirthday(b.anniversary), gifts: str(b.gifts, 2000), notes: str(b.notes, 2000), updated: Date.now(),
|
||||
};
|
||||
c.emails.forEach(e => { e.value = e.value.toLowerCase(); });
|
||||
if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org;
|
||||
@@ -57,6 +57,7 @@ function parseVcf(text) {
|
||||
else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') });
|
||||
else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', ');
|
||||
else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, '');
|
||||
else if (name === 'ANNIVERSARY' || name === 'X-ANNIVERSARY') cur.anniversary = val.replace(/T.*$/, '');
|
||||
else if (name === 'NOTE') cur.notes = unesc(val);
|
||||
}
|
||||
return out.map(c => ({ ...c, name: c.name || c.n || '' }));
|
||||
@@ -66,19 +67,22 @@ function toVcf(l) {
|
||||
...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`),
|
||||
...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`),
|
||||
c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')),
|
||||
c.anniversary && 'X-ANNIVERSARY:' + c.anniversary.replace(/-/g, '').replace(/^/, c.anniversary.startsWith('--') ? '--' : ''),
|
||||
c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : '');
|
||||
}
|
||||
|
||||
/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */
|
||||
/** Birthdays and anniversaries as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Each repeats every year. */
|
||||
function birthdayEvents(win, uid) {
|
||||
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
|
||||
for (const c of load(uid)) {
|
||||
if (!c.birthday) continue;
|
||||
const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4);
|
||||
for (const [field, label, color] of [['birthday', "'s birthday", '#d9578a'], ['anniversary', "'s anniversary", '#c9a227']]) {
|
||||
const v = c[field]; if (!v) continue;
|
||||
const [mo, d] = v.startsWith('--') ? [v.slice(2, 4), v.slice(5, 7)] : [v.slice(5, 7), v.slice(8, 10)], born = v.startsWith('--') ? 0 : +v.slice(0, 4);
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
|
||||
const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10);
|
||||
out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id });
|
||||
out.push({ title: `${c.name}${label}${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: field + ':' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color, editable: false, contactId: c.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
|
||||
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
||||
TRANSCRIBE_API_URL: http://127.0.0.1:8000 # the bundled speech-to-text service (whisper, below): makes a transcript for a podcast or audiobook on request. Remove this line and the service to skip it
|
||||
TRANSCRIBE_MODEL: ${TRANSCRIBE_MODEL:-Systran/faster-whisper-base} # smaller and about 3x faster than -small on a CPU-only host; set Systran/faster-whisper-small for better accuracy
|
||||
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
||||
OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead
|
||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||
|
||||
+22
-22
@@ -45,43 +45,43 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
||||
- [x] **Family Home (M):** one shared start page: who is around, today's family calendar, shared shopping list and chores, what everyone is listening to or reading **Done: the Family page: calendar, chores, shopping, notes, chat and tasks on one start page.**
|
||||
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
||||
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
||||
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
|
||||
- [ ] **Meal planning (M):** 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
|
||||
- [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
|
||||
- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand; they do not go down when you cook.**
|
||||
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
|
||||
|
||||
## 3. Kids and safety
|
||||
- [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.**
|
||||
- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves
|
||||
- [x] **Approvals (S):** **Done: requests.js ("Can I have...").** a child asks to add a book, app or extension and a parent approves
|
||||
- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js**
|
||||
- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents
|
||||
- [x] **Simple mode (S):** **Done: simple-mode.js.** a big-button layout with few tabs for young kids or grandparents
|
||||
|
||||
## 4. Everyday convenience
|
||||
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
|
||||
- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home
|
||||
- [x] **Continue row (S):** **Done: Home, today.js.** the last audiobook, episode, book, movie and song on Home
|
||||
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
|
||||
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
|
||||
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
|
||||
- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas
|
||||
- [x] **Tasks 2.0 (M):** **Done: a due time with a notification at that time, Today and Upcoming views, and subtasks on iCloud lists too. Lists serve as projects.**
|
||||
- [x] **Mail 2.0 (L):** **Done: an All inboxes view (newest of each account), snooze, templates, and saving an attachment to Files. Not tried against real Outlook, Gmail or iCloud accounts, only the tests.**
|
||||
- [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.**
|
||||
|
||||
## 5. Media
|
||||
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
|
||||
- [ ] **Podcast and audiobook stats (S):** listening history like Music has
|
||||
- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights
|
||||
- [x] **Movies & TV 2.0 (L):** **Done: favorites, a shared Movie night list with votes, mixes (random, family night, short, classics) and Your stats (counts, hours, favourite genres from Plex's last-watched dates). The Plex-based ones are tested only against the counting code, not a real Plex.**
|
||||
- [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).**
|
||||
- [x] **Reading goals (S):** **Done: a yearly goal for books finished (audiobooks and e-books, an e-book counts at 98% read), a listening streak, a family challenge with one shared goal, and downloading notes and highlights.**
|
||||
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
|
||||
- [ ] **Casting (M):** pick AirPlay or Cast targets for audio
|
||||
- [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio
|
||||
|
||||
## 6. Reliability (the admin's side)
|
||||
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
|
||||
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
|
||||
- [ ] **Backup and restore screen (S):** one-click restore, per-person export
|
||||
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
|
||||
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
|
||||
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
|
||||
- [~] **Browser smoke tests in CI (M):** **Done: tools/frontend-test.js loads every page script in one shared scope and fails on a syntax error, a name declared twice or a script that throws as it loads. Not a real browser: nothing clicks through the screens.**
|
||||
- [x] **Admin health page (S):** **Done: Settings > Admin checks Plex, Seafile, OnlyOffice, audiobooks, podcasts, Mealie, mail and storage.**
|
||||
- [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.**
|
||||
- [x] **Offline mode (L):** **Done: tasks, notes, recipes, the calendar, contacts and chores can be read with no signal (the last saved copy, kept per person); saved podcast episodes and albums already played; books can be saved on the device; adding a task, ticking one, adding to the shopping list and writing a note are kept and sent when the connection returns. Other changes still need a connection.**
|
||||
- [x] **Performance pass (S):** **Done: the Bible text, the reader libraries and the video player were already loaded on demand; page scripts now load deferred. Text was already compressed and cached.**
|
||||
- [x] **Accessibility (M):** **Done: text size, high contrast and reduce motion (Settings > Look and reading); focus outlines and the skip link already existed; dialogs now keep focus inside and give it back; Bible verses can be picked with the keyboard. The main screens were scanned for things only a mouse can click and that was the one gap found.**
|
||||
|
||||
## 7. Platform (later)
|
||||
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
|
||||
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
|
||||
- [ ] **Themes (S):** accent colours and an AMOLED dark mode per person
|
||||
- [x] **Extensions 2.0 (L):** **Done: notes, recipes and what is playing in the bridge; a Permissions screen per extension; an Updated notice when an admin changes one.**
|
||||
- [x] **Automations (L):** **Done: per-person rules (Settings > Automations): every day at a time, a task is finished, new mail arrives, a pantry item runs low or a new podcast episode comes out, then add a task, add to Shopping or notify.**
|
||||
- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.**
|
||||
|
||||
## Suggested order
|
||||
1. Family Chat phase 1, Family Home, Shared lists and notes, Chores
|
||||
|
||||
@@ -172,6 +172,7 @@ function todoLines(f, { completed = true } = {}) {
|
||||
const L = ['SUMMARY:' + esc(String(f.title).trim())];
|
||||
if (f.notes) L.push('DESCRIPTION:' + esc(f.notes));
|
||||
if (f.due) L.push('DUE;VALUE=DATE:' + f.due.replace(/-/g, ''));
|
||||
if (f.parent) L.push('RELATED-TO;RELTYPE=PARENT:' + esc(String(f.parent))); // a subtask points at its main task
|
||||
if (f.done) { L.push('STATUS:COMPLETED', 'PERCENT-COMPLETE:100'); if (completed) L.push('COMPLETED:' + nowStamp()); }
|
||||
else L.push('STATUS:NEEDS-ACTION');
|
||||
return L;
|
||||
@@ -199,6 +200,7 @@ function patchTodo(text, f) {
|
||||
const name = l.split(/[;:]/)[0].toUpperCase();
|
||||
if (name === 'COMPLETED') { if (f.done) { hadCompleted = true; out.push(l); } return; } // keep the original completion time
|
||||
if (['SUMMARY', 'DESCRIPTION', 'DUE', 'STATUS', 'PERCENT-COMPLETE'].includes(name)) return;
|
||||
if (name === 'RELATED-TO' && f.parent !== undefined && !/RELTYPE=(CHILD|SIBLING)/i.test(l)) return; // (replaced below when a parent is given; kept when none is mentioned)
|
||||
if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; }
|
||||
if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; }
|
||||
out.push(l);
|
||||
|
||||
@@ -12,22 +12,34 @@ const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf
|
||||
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 = '' }) {
|
||||
const mergeRef = (a, b) => !a || !b ? b || a : { ...a, ...b, ...(a.mail && b.mail ? { mail: [...a.mail, ...b.mail].slice(-50) } : {}) };
|
||||
function add(uid, { kind = 'info', title, body = '', url = '', tag = '', ref }) {
|
||||
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 });
|
||||
if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; if (ref) old.ref = mergeRef(old.ref, ref); 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), ref: ref || undefined, at: now, read: false, count: 1 });
|
||||
save(uid, items.slice(0, MAX));
|
||||
}
|
||||
const unread = uid => load(uid).filter(i => !i.read).length;
|
||||
/** The thing itself was dealt with somewhere else (a chat opened, say): its notification is done too. */
|
||||
function markTagRead(uid, tag) { const items = load(uid); let ch = false; for (const i of items) if (i.tag === tag && !i.read) { i.read = true; ch = true; } if (ch) save(uid, items); }
|
||||
/** Reading or clearing a notification also settles what it was about: a chat is marked read, new mail is marked read. (Reminders, requests and the like stay as they are: they still need doing or answering.) */
|
||||
async function settle(uid, items) {
|
||||
const jobs = [];
|
||||
for (const i of items) {
|
||||
const m = /^chat:(.+)$/.exec(i.tag || ''); if (m) { try { require('./chat').markRead(uid, m[1]); } catch (e) { console.warn('[inbox]', e.message); } }
|
||||
if (i.ref && Array.isArray(i.ref.mail) && i.ref.mail.length) jobs.push(require('./mail').markRead(i.ref.mail).catch(() => {}));
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
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 }); }
|
||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id), hit = items.filter(i => !i.read && (b.all || i.id === b.id)); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); await settle(me.id, hit); return json(res, 200, { unread: unread(me.id) }); }
|
||||
if (p === '/clear' && req.method === 'POST') { const hit = load(me.id).filter(i => !i.read); save(me.id, []); await settle(me.id, hit); 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 };
|
||||
module.exports = { handle, add, unread, load, markTagRead };
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one.
|
||||
const fs = require('fs'), path = require('path');
|
||||
const users = require('./users'), genres = require('./genres');
|
||||
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
|
||||
const prog = require('./progress')('librivox-progress.json', 'audiobook'); // keyed lv:<book id>: seconds into the whole book
|
||||
|
||||
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
|
||||
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
|
||||
|
||||
@@ -269,6 +269,30 @@ async function overview(light) {
|
||||
return { connected: shown.length > 0, accounts: shown, expired: expired && !shown.length, address: (shown[0] || {}).address, name: (shown[0] || {}).name, unread: shown.reduce((n, r) => n + (r.unread || 0), 0) };
|
||||
}
|
||||
|
||||
// ---- Mail 2.0: snooze, templates and a unified inbox. Snoozed messages and templates are kept by the hub for each person (the mail providers know nothing of them).
|
||||
// Snooze hides a message from the inbox lists until a time, then it is back and the person is told.
|
||||
const snoozeFile = () => path.join(users.dir(), 'mail-snoozed.json'), tplFile = () => path.join(users.dir(), 'mail-templates.json');
|
||||
const readArr = f => { try { const a = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const snoozed = () => readArr(snoozeFile()).filter(x => x && typeof x.id === 'string' && x.until > Date.now());
|
||||
const isInbox = f => /(^|~)inbox$/i.test(String(f || 'inbox'));
|
||||
const hideSnoozed = (items, folder) => { if (!isInbox(folder)) return items; const hide = new Set(snoozed().map(x => x.id)); return hide.size ? items.filter(m => !hide.has(m.id)) : items; };
|
||||
/** Snoozed messages whose time has come, for one person (and forgets them), so the caller can tell the person. */
|
||||
function snoozeDue(uid) {
|
||||
return users.runAs(users.get(uid), () => {
|
||||
const all = readArr(snoozeFile()), now = Date.now(), due = all.filter(x => x.until <= now);
|
||||
if (due.length) atomicWrite(snoozeFile(), JSON.stringify(all.filter(x => x.until > now)), { mode: 0o600 });
|
||||
return due;
|
||||
});
|
||||
}
|
||||
const tplClean = b => { const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the template a name'); return { name, subject: String(b.subject || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200), body: String(b.body || '').slice(0, 20000) }; };
|
||||
/** The newest inbox messages of every connected account, together: one list, newest first (the first page of each account). */
|
||||
async function unifiedInbox(search) {
|
||||
const accs = accounts(), parts = await Promise.all(accs.map(async a => { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: search }), 12000); return r.items.map(m => ({ ...m, id: pack(a, m.id), account: a.name || a.address, accountId: a.id })); } catch { return []; } }));
|
||||
return parts.flat().sort((x, y) => String(y.date || '').localeCompare(String(x.date || ''))).slice(0, 40);
|
||||
}
|
||||
/** An attachment's name and bytes (for saving into Files). `id` is the packed message id. */
|
||||
async function attachmentOf(id, aid) { const [a, nid] = unpack(id), f = await providerOf(a).attachment(a, nid, aid); return { name: safeName(f.name), buffer: f.buffer }; }
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
|
||||
try {
|
||||
@@ -279,10 +303,13 @@ async function handle(req, res) {
|
||||
const a = findAccount(q.get('acct') || undefined);
|
||||
return json(res, 200, (await providerOf(a).folders(a)).map(f => ({ ...f, id: pack(a, f.id) })));
|
||||
}
|
||||
if (p === '/api/mail/messages' && q.get('folder') === 'unified') { const items = hideSnoozed(await unifiedInbox((q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100)), 'inbox'); return json(res, 200, { items, more: false, next: items.length }); }
|
||||
if (p === '/api/mail/snoozed' && req.method === 'GET') return json(res, 200, snoozed());
|
||||
if (p === '/api/mail/templates' && req.method === 'GET') return json(res, 200, readArr(tplFile()));
|
||||
if (p === '/api/mail/messages') {
|
||||
const [a, fid] = unpack(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0);
|
||||
const r = await providerOf(a).list(a, { folder: fid || 'inbox', skip, q: search });
|
||||
return json(res, 200, { ...r, items: r.items.map(m => ({ ...m, id: pack(a, m.id) })) });
|
||||
return json(res, 200, { ...r, items: hideSnoozed(r.items.map(m => ({ ...m, id: pack(a, m.id) })), fid || 'inbox') });
|
||||
}
|
||||
if (p === '/api/mail/message') {
|
||||
const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid);
|
||||
@@ -295,6 +322,14 @@ async function handle(req, res) {
|
||||
}
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
const b = JSON.parse((await readBody(req)) || '{}'), me = users.current();
|
||||
if (p === '/api/mail/snooze') { // hide a message from the inbox until a time (a message that cannot be found is refused)
|
||||
const until = Math.round(+b.until); if (!(until > Date.now() && until < Date.now() + 366 * 864e5)) throw fail(400, 'Pick a time in the future (within a year)');
|
||||
const [a, nid] = unpack(b.id), id = pack(a, nid), l = snoozed().filter(x => x.id !== id); l.push({ id, until, subject: String(b.subject || '').slice(0, 150), from: String(b.from || '').slice(0, 100) });
|
||||
atomicWrite(snoozeFile(), JSON.stringify(l.slice(-500)), { mode: 0o600 }); return json(res, 200, { ok: true, until });
|
||||
}
|
||||
if (p === '/api/mail/unsnooze') { atomicWrite(snoozeFile(), JSON.stringify(snoozed().filter(x => x.id !== String(b.id))), { mode: 0o600 }); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/templates') { const t = tplClean(b), l = readArr(tplFile()), i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (i >= 0) l[i] = { ...l[i], ...t }; else { if (l.length >= 50) throw fail(400, 'That is the most templates (50)'); l.push({ id: crypto.randomBytes(4).toString('hex'), ...t }); } atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
|
||||
if (p === '/api/mail/templates/delete') { const l = readArr(tplFile()).filter(x => x.id !== b.id); atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
|
||||
if (p === '/api/mail/accounts') { // add a Gmail or iCloud account (an app password; checked against the server before it is saved)
|
||||
const a = await imap.connectNew({ type: b.type, address: b.address, password: b.password });
|
||||
const same = accounts().find(x => x.type === a.type && x.address.toLowerCase() === a.address.toLowerCase());
|
||||
@@ -332,11 +367,15 @@ async function handle(req, res) {
|
||||
/** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */
|
||||
async function newest() {
|
||||
const out = [];
|
||||
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
||||
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ acct: a.id, mid: m.id, id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
||||
return out;
|
||||
}
|
||||
/** Mark messages read: what a cleared "new mail" notification stands for. refs: [{ a: account id, id: message id }]. */
|
||||
async function markRead(refs) {
|
||||
for (const r of (Array.isArray(refs) ? refs : []).slice(0, 50)) { try { const a = findAccount(r.a); await providerOf(a).update(a, r.id, { read: true }); dropFolderCache(a.id); } catch { /* gone or unreachable: nothing to mark */ } }
|
||||
}
|
||||
/** For the admin health page: can each connected account be reached right now? */
|
||||
async function health() {
|
||||
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
|
||||
}
|
||||
module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||
module.exports = { snoozeDue, attachmentOf, markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Movies & TV for the family: Favorites (each person's own hearted titles) and Movie night (one shared list everyone adds to and votes on, so the family picks together).
|
||||
// Titles are kept as a small card (Plex id, title, year, poster path), so the lists draw without asking Plex. Favorites: users/<id>/video-favs.json; Movie night: DATA_DIR/movienight.json.
|
||||
const fs = require('fs'), path = require('path');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
const NIGHT = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'movienight.json');
|
||||
const FAVS = () => path.join(users.dir(), 'video-favs.json');
|
||||
const rd = (f, d) => { try { const v = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(v) ? v : d; } catch { return d; } };
|
||||
const wr = (f, v) => atomicWrite(f, JSON.stringify(v), { mode: 0o600 });
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, '').trim().slice(0, n);
|
||||
/** A title card from what the browser sent: only the fields a card needs, and the poster path must be a Plex path of ours. */
|
||||
function card(b) {
|
||||
const id = str(b.id, 20); if (!/^\d{1,12}$/.test(id)) throw fail(400, 'Pick a movie or show');
|
||||
const title = str(b.title, 160); if (!title) throw fail(400, 'That title has no name');
|
||||
return { id, title, year: Math.round(+b.year) || 0, type: b.type === 'show' ? 'show' : 'movie', thumb: /^\/library\/metadata\/\d+\/thumb(\/\d+)?$/.test(str(b.thumb, 120)) ? str(b.thumb, 120) : '' };
|
||||
}
|
||||
const nameOf = id => (users.get(id) || {}).name || 'Someone';
|
||||
const nightView = (l, me) => l.map(x => ({ ...x, byName: nameOf(x.by), votes: x.votes.length, voted: x.votes.includes(me.id), voters: x.votes.map(nameOf) })).sort((a, b) => b.votes - a.votes || a.added - b.added);
|
||||
|
||||
async function handle(req, res) {
|
||||
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/moviefam'.length) || '/';
|
||||
try {
|
||||
if (!me) throw fail(401, 'Sign in');
|
||||
if (p === '/night' && req.method === 'GET') return json(res, 200, { items: nightView(rd(NIGHT(), []), me) });
|
||||
if (p === '/favs' && req.method === 'GET') return json(res, 200, { items: rd(FAVS(), []) });
|
||||
if (req.method !== 'POST') throw fail(404, 'Not found');
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}');
|
||||
if (p === '/fav') { const c = card(b), l = rd(FAVS(), []).filter(x => x.id !== c.id); if (b.on) l.unshift({ ...c, added: Date.now() }); wr(FAVS(), l.slice(0, 500)); return json(res, 200, { on: !!b.on }); }
|
||||
const l = rd(NIGHT(), []);
|
||||
if (p === '/night/add') { const c = card(b); if (l.some(x => x.id === c.id)) throw fail(400, c.title + ' is already on the list'); if (l.length >= 60) throw fail(400, 'The list is full; remove something first'); l.push({ ...c, by: me.id, added: Date.now(), votes: [me.id] }); wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
|
||||
if (p === '/night/clear') { if (me.role !== 'admin') throw fail(403, 'Only an admin can clear the list'); wr(NIGHT(), []); return json(res, 200, { items: [] }); }
|
||||
const x = l.find(y => y.id === str(b.id, 20)); if (!x) throw fail(404, 'That title is not on the list');
|
||||
if (p === '/night/vote') { x.votes = x.votes.includes(me.id) ? x.votes.filter(v => v !== me.id) : [...x.votes, me.id]; wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
|
||||
if (p === '/night/remove') { if (x.by !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who added it can remove it'); wr(NIGHT(), l.filter(y => y !== x)); return json(res, 200, { items: nightView(l.filter(y => y !== x), me) }); }
|
||||
throw fail(404, 'Not found');
|
||||
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||
}
|
||||
module.exports = { handle };
|
||||
+11
-5
@@ -13,7 +13,7 @@ const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(
|
||||
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
|
||||
const MAX_FILE = 1024 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_EXT = 2 * 1024 * 1024, MAX_STATE = 256 * 1024, MAX_FILES_PER_ITEM = 200;
|
||||
const KINDS = ['book', 'music', 'app', 'extension', 'link'];
|
||||
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for
|
||||
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write', 'notes.read', 'notes.write', 'recipes.read', 'music.read']; // what an extension may ask the person for
|
||||
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
|
||||
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw3: 'application/vnd.amazon.mobi8-ebook', azw: 'application/vnd.amazon.ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', html: 'text/html; charset=utf-8', apk: 'application/vnd.android.package-archive', zip: 'application/zip', dmg: 'application/x-apple-diskimage', exe: 'application/vnd.microsoft.portable-executable', pkg: 'application/octet-stream', txt: 'text/plain; charset=utf-8' };
|
||||
@@ -37,7 +37,7 @@ const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.cre
|
||||
|
||||
// ---- the per-person bits: which extensions are installed, and each extension's saved settings
|
||||
const stateFile = () => path.join(users.dir(), 'mystore.json');
|
||||
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } };
|
||||
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {}, seen: j.seen && typeof j.seen === 'object' ? j.seen : {} }; } catch { return { installed: [], data: {}, grants: {}, seen: {} }; } };
|
||||
const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 });
|
||||
|
||||
/** Stream a stored file, with Range support so audio can be skipped through. */
|
||||
@@ -84,14 +84,17 @@ function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[
|
||||
window.hub={storage:{get:function(k){return call('storage.get',[k])},set:function(k,v){return call('storage.set',[k,v])},all:function(){return call('storage.all')},remove:function(k){return call('storage.remove',[k])}},toast:function(t){return call('toast',[String(t)])},user:function(){return call('user')},open:function(u){return call('open',[String(u)])},
|
||||
tasks:{lists:function(){return call('tasks.lists')},list:function(l){return call('tasks.list',[{list:l}])},add:function(o){return call('tasks.add',[o])},complete:function(l,id){return call('tasks.complete',[{list:l,id:id}])}},
|
||||
contacts:{list:function(){return call('contacts.list')},add:function(o){return call('contacts.add',[o])}},
|
||||
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}}}})()</script>`;
|
||||
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}},
|
||||
notes:{list:function(){return call('notes.list')},add:function(o){return call('notes.add',[o])}},
|
||||
recipes:{search:function(q){return call('recipes.search',[{q:q}])},get:function(slug){return call('recipes.get',[{slug:slug}])}},
|
||||
music:{nowPlaying:function(){return call('music.nowPlaying')}}}})()</script>`;
|
||||
const withBridge = html => /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, m => m + BRIDGE) : BRIDGE + html;
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/store/my'.length) || '/', me = users.current();
|
||||
try {
|
||||
let m;
|
||||
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, admin: !!me && me.role === 'admin' });
|
||||
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, seen: stateOf().seen, admin: !!me && me.role === 'admin' });
|
||||
if (p === '/items' && req.method === 'POST') { // add or change an item's details (admin)
|
||||
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems(), old = b.id ? l.find(x => x.id === b.id) : null;
|
||||
if (b.id && !old) throw fail(404, 'Item not found'); if (!old && !KINDS.includes(b.kind)) throw fail(400, 'Choose what kind of thing this is');
|
||||
@@ -148,7 +151,10 @@ async function handle(req, res) {
|
||||
}
|
||||
if ((m = /^\/ext\/([0-9a-f]{12})\/(install|uninstall)$/.exec(p)) && req.method === 'POST') {
|
||||
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found');
|
||||
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') s.installed.push(it.id); if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
|
||||
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') { s.installed.push(it.id); s.seen[it.id] = it.updated || 0; } if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; delete s.seen[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
|
||||
}
|
||||
if ((m = /^\/ext\/([0-9a-f]{12})\/seen$/.exec(p)) && req.method === 'POST') { // the person has looked at this version of the extension (so it stops saying it was updated)
|
||||
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf(); if (s.installed.includes(it.id)) { s.seen[it.id] = it.updated || 0; saveState(s); } return json(res, 200, { seen: s.seen });
|
||||
}
|
||||
if ((m = /^\/ext\/([0-9a-f]{12})\/grants$/.exec(p))) { // what the person has allowed this extension to reach in the hub
|
||||
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
|
||||
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
|
||||
const fs = require('fs'), path = require('path');
|
||||
const users = require('./users');
|
||||
const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode
|
||||
const prog = require('./progress')('podcast-progress.json', 'podcast'); // where each person got to in each episode
|
||||
const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, '');
|
||||
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
|
||||
|
||||
|
||||
+33
-3
@@ -3,13 +3,43 @@
|
||||
const fs = require('fs'), path = require('path');
|
||||
const users = require('./users');
|
||||
|
||||
module.exports = name => {
|
||||
// With a `kind` ('podcast' or 'audiobook'), each report also adds the time actually listened to that person's listening log (listening.json): seconds per day per kind, and
|
||||
// the items finished. A jump forward bigger than the time that passed (a seek) does not count.
|
||||
const day = t => { const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
||||
const logFile = () => path.join(users.dir(), 'listening.json');
|
||||
const loadLog = () => { try { const o = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return { days: o.days || {}, finished: o.finished || {} }; } catch { return { days: {}, finished: {} }; } };
|
||||
const stats = () => {
|
||||
const o = loadLog(), now = new Date(), today = day(now), tot = k => Object.values(o.days).reduce((n, d) => n + (d[k] || 0), 0), sum = (a, b) => Object.entries(o.days).filter(([k]) => k >= a && k <= b).reduce((n, [, d]) => n + (d.podcast || 0) + (d.audiobook || 0), 0);
|
||||
const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60;
|
||||
let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++;
|
||||
const year = String(now.getFullYear());
|
||||
return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => day(f.t).startsWith(year)).length, audiobooksYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, ebooksYear: Object.values(o.finished).filter(f => f.kind === 'ebook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length };
|
||||
};
|
||||
module.exports = (name, kind) => {
|
||||
const file = () => path.join(users.dir(), name);
|
||||
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
|
||||
const merge = body => {
|
||||
const mine = load();
|
||||
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
||||
const mine = load(), log = kind ? loadLog() : null; let logged = false;
|
||||
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) {
|
||||
const old = mine[k], n = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
||||
if (log && old && n.t > old.t) { const wall = (n.t - old.t) / 1000, gain = n.pos - old.pos; if (gain > 0 && gain <= wall * 2 + 5 && wall < 600) { const d = log.days[day(n.t)] = log.days[day(n.t)] || {}; d[kind] = Math.round(((d[kind] || 0) + Math.min(gain, wall + 5)) * 10) / 10; logged = true; } }
|
||||
if (log && n.dur > 60 && n.pos >= n.dur - 30 && !log.finished[k]) { log.finished[k] = { t: n.t || Date.now(), kind }; logged = true; }
|
||||
mine[k] = n;
|
||||
}
|
||||
if (logged) { for (const k of Object.keys(log.days).sort().slice(0, -400)) delete log.days[k]; const lf = logFile(), lt = lf + '.tmp'; fs.writeFileSync(lt, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(lt, lf); }
|
||||
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
||||
};
|
||||
return { load, merge };
|
||||
};
|
||||
/** A book (or e-book) finished: counts toward the yearly goals. Once only. */
|
||||
function markFinished(key, kind, t = Date.now()) { const log = loadLog(); if (log.finished[key]) return; log.finished[key] = { t, kind }; const lf = logFile(), tmp = lf + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(tmp, lf); }
|
||||
// The family challenge: one yearly goal for everyone, counted across everybody's finished books.
|
||||
const goalFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'readgoal.json');
|
||||
function familyGoal() {
|
||||
const year = new Date().getFullYear(); let goal = 0; try { const g = JSON.parse(fs.readFileSync(goalFile(), 'utf8')); if (g.year === year) goal = +g.goal || 0; } catch { /* none set */ }
|
||||
let finished = 0; const people = [];
|
||||
for (const u of users.all()) { if (u.disabled) continue; let log = { finished: {} }; try { log = JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'listening.json'), 'utf8')); } catch { /* nothing yet */ } const n = Object.values(log.finished || {}).filter(f => day(f.t).startsWith(String(year))).length; finished += n; if (n) people.push({ name: u.name, n }); }
|
||||
return { year, goal, finished, people: people.sort((a, b) => b.n - a.n) };
|
||||
}
|
||||
function setFamilyGoal(goal) { const g = Math.max(0, Math.min(1000, Math.round(+goal) || 0)); fs.mkdirSync(path.dirname(goalFile()), { recursive: true }); fs.writeFileSync(goalFile(), JSON.stringify({ year: new Date().getFullYear(), goal: g })); return familyGoal(); }
|
||||
module.exports.stats = stats; module.exports.markFinished = markFinished; module.exports.familyGoal = familyGoal; module.exports.setFamilyGoal = setFamilyGoal;
|
||||
@@ -367,6 +367,7 @@ function viewHome() {
|
||||
inboxWire();
|
||||
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||
if (typeof homeResume === 'function') homeResume($('#resume'));
|
||||
if (typeof homeListening === 'function') homeListening($('#resume'));
|
||||
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
||||
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
||||
|
||||
@@ -92,15 +92,17 @@ async function viewBooks(args = []) {
|
||||
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
||||
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
||||
lvBind(v, () => route());
|
||||
if (args[0] === 'book' && args[1]) { const bid = args[1], b0 = /^lv:/.test(bid) ? null : byId.get(bid); if (/^lv:/.test(bid)) lvSheet(bid.slice(3)); else if (b0) abSheet(b0); else toast('That book is not in your library'); } // (a link from a Chat share card)
|
||||
}
|
||||
|
||||
function abSheet(b) {
|
||||
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
|
||||
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
|
||||
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button></div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button>${allowed('chat') ? `<button class="btn ghost" id="abh">${icon('send')}Share</button>` : ''}</div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
||||
el.querySelector('#abp').onclick = () => go();
|
||||
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
||||
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||
el.querySelector('#abc').onclick = async () => {
|
||||
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
// ---- Automations (Settings): "when this happens, do that" rules for you. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to
|
||||
// the Shopping list, or send yourself a notification. {title}, {from} and {subject} in the text are filled in from what happened.
|
||||
const auApi = (p, o) => jfetch('/api/auto' + p, o), auOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const AU_WHEN = [['time', 'Every day at a time'], ['task.done', 'A task is finished'], ['mail.new', 'New mail arrives'], ['pantry.low', 'A pantry item runs low'], ['podcast.new', 'A new podcast episode comes out']], AU_THEN = [['task', 'Add a task'], ['shopping', 'Add to the Shopping list'], ['notify', 'Notify me']];
|
||||
const auSummary = r => `${r.when.type === 'time' ? 'Every day at ' + r.when.at : r.when.type === 'task.done' ? 'When a task' + (r.when.match ? ' with “' + r.when.match + '”' : '') + ' is finished' : r.when.type === 'pantry.low' ? 'When ' + (r.when.match ? '“' + r.when.match + '”' : 'a pantry item') + ' runs low' : r.when.type === 'podcast.new' ? 'When a new episode of “' + r.when.match + '” comes out' : 'When mail from or about “' + r.when.match + '” arrives'} → ${r.then.type === 'task' ? 'add the task' : r.then.type === 'shopping' ? 'add to Shopping' : 'notify me'} “${r.then.text}”`;
|
||||
function autoSettings(el) {
|
||||
if (!el) return;
|
||||
const draw = rules => {
|
||||
el.innerHTML = `<h2>Automations</h2><p class="sub">Little rules that run for you. Each can run up to 20 times a day.</p>
|
||||
<div class="list">${rules.map(r => `<div class="row" style="align-items:flex-start"><label class="grow" style="cursor:pointer;white-space:normal"><b>${esc(r.name)}</b><small style="white-space:normal">${esc(auSummary(r))}${r.runs ? ' · ran ' + r.runs + ' time' + (r.runs === 1 ? '' : 's') : ''}</small></label>
|
||||
<input type="checkbox" data-on="${esc(r.id)}" aria-label="Turn ${esc(r.name)} on or off"${r.on ? ' checked' : ''}><button class="ibtn" data-e="${esc(r.id)}" aria-label="Edit ${esc(r.name)}" title="Edit">${icon('edit')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>No automations yet.</small></span></div>'}</div>
|
||||
<div class="bar"><button class="btn ghost" id="auadd">${icon('plus')}Add an automation</button></div>`;
|
||||
el.querySelector('#auadd').onclick = () => edit(null);
|
||||
el.querySelectorAll('[data-on]').forEach(c => c.onchange = async () => { try { draw((await auApi('/toggle', auOpt({ id: c.dataset.on, on: c.checked }))).rules); } catch (e) { toast(e.message); c.checked = !c.checked; } });
|
||||
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => edit(rules.find(r => r.id === b.dataset.e)));
|
||||
};
|
||||
const edit = r => {
|
||||
const w = r ? r.when : { type: 'time', at: '08:00', match: '' }, t = r ? r.then : { type: 'task', text: '' };
|
||||
const sh = openSheet(r ? 'Edit automation' : 'New automation', '', `<form id="auf"><label for="aun">Name (optional)</label><input type="text" id="aun" value="${esc(r ? r.name : '')}" autocomplete="off">
|
||||
<label for="auw">When</label><select id="auw">${AU_WHEN.map(([v, l]) => `<option value="${v}"${v === w.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<div id="auwd"></div><label for="aut">Then</label><select id="aut">${AU_THEN.map(([v, l]) => `<option value="${v}"${v === t.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<label for="aux" id="auxl"></label><input type="text" id="aux" value="${esc(t.text)}" autocomplete="off" maxlength="200"><p class="sub" style="margin:4px 0 0">You can use {title} (the task, item or episode), {from} and {show}.</p><div class="err" id="aue"></div>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button>${r ? '<button type="button" class="btn ghost" id="autest">Test</button><button type="button" class="btn danger" id="aude">Delete</button>' : ''}</div></form>`);
|
||||
const label = () => { sh.querySelector('#auxl').textContent = sh.querySelector('#aut').value === 'notify' ? 'Message' : 'Task'; };
|
||||
const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `<label for="aua">At</label><input type="time" id="aua" value="${esc(w.at || '08:00')}" required>` : `<label for="aum">${ty === 'mail.new' ? 'Sender or subject contains' : ty === 'podcast.new' ? 'Show name contains' : ty === 'pantry.low' ? 'Item name contains (blank: any item)' : 'Task title contains (blank: any task)'}</label><input type="text" id="aum" value="${esc(w.type === ty ? w.match || '' : '')}" autocomplete="off"${ty === 'mail.new' || ty === 'podcast.new' ? ' required' : ''}>`; label(); };
|
||||
detail(); label(); sh.querySelector('#auw').onchange = detail; sh.querySelector('#aut').onchange = label;
|
||||
const body = () => { const ty = sh.querySelector('#auw').value; return { id: r && r.id, name: sh.querySelector('#aun').value, on: r ? r.on : true, when: { type: ty, at: ty === 'time' ? sh.querySelector('#aua').value : undefined, match: ty === 'time' ? '' : sh.querySelector('#aum').value }, then: { type: sh.querySelector('#aut').value, text: sh.querySelector('#aux').value } }; };
|
||||
sh.querySelector('#auf').onsubmit = async e => { e.preventDefault(); try { const l = (await auApi('/', auOpt(body()))).rules; closeSheet(); draw(l); toast('Saved'); } catch (er) { sh.querySelector('#aue').textContent = er.message; } };
|
||||
if (r) {
|
||||
sh.querySelector('#autest').onclick = async () => { try { const o = await auApi('/test', auOpt({ id: r.id })); toast(o.ok ? 'Ran it once' : 'Did not run (the daily limit?)'); } catch (e) { toast(e.message); } };
|
||||
sh.querySelector('#aude').onclick = () => confirmSheet('Delete this automation?', r.name, 'Delete', async () => { try { draw((await auApi('/delete', auOpt({ id: r.id }))).rules); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
};
|
||||
auApi('/').then(r => draw(r.rules)).catch(() => {});
|
||||
}
|
||||
@@ -46,8 +46,8 @@ function bbChapterHtml(book, c) {
|
||||
else if (b[0] === 'p') flush();
|
||||
else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; }
|
||||
else if (b[0] === 'v') {
|
||||
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
|
||||
else para += `<span class="vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
|
||||
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}" tabindex="0" role="button">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
|
||||
else para += `<span class="vs" data-v="${b[1]}" tabindex="0" role="button">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
|
||||
}
|
||||
}
|
||||
flush(); return html;
|
||||
@@ -68,23 +68,25 @@ async function viewBible(args = []) {
|
||||
if (!location.hash.startsWith('#/bible')) return;
|
||||
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
||||
if (args[0] === 'marks') return bbMarks();
|
||||
if (args[0] === 'today' && typeof bbToday === 'function') return bbToday();
|
||||
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
|
||||
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
|
||||
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
|
||||
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
|
||||
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
|
||||
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
|
||||
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
||||
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbtd" aria-label="Devotion: verse for today, reading plan, prayer list" title="Devotion">${icon('heart')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
||||
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
|
||||
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
|
||||
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
|
||||
const root = $('#bbtext'); bbPaint(root);
|
||||
if (vs) { const el = root.querySelector(`.vs[data-v="${vs}"]`); if (el) { setTimeout(() => el.scrollIntoView({ block: 'center' }), 50); el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 2200); } }
|
||||
root.addEventListener('keydown', e => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('.vs')) { e.preventDefault(); e.target.click(); } }); // (a verse is picked with the keyboard too)
|
||||
root.addEventListener('click', e => {
|
||||
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
|
||||
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
|
||||
});
|
||||
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
||||
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbtd').onclick = () => { location.hash = '#/bible/today'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
||||
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
|
||||
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
|
||||
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
|
||||
@@ -154,8 +156,9 @@ function bbSearch() {
|
||||
}
|
||||
async function bbMarks() {
|
||||
const order = id => bb.books.findIndex(b => b.id === id), list = bbSt.marks.filter(m => !m.del).sort((a, b) => order(a.b) - order(b.b) || a.c - b.c || a.v - b.v);
|
||||
$('#view').innerHTML = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1></div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
|
||||
$('#view').innerHTML = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1><span class="grow"></span>${list.length ? `<button class="btn ghost" id="bbexp">Download</button>` : ''}</div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
|
||||
${m.type === 'note' ? `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>` : ''}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>` : '<p class="empty">Nothing yet. Tap verses while reading to highlight them or add a note.</p>'}</div>`;
|
||||
const bx = $('#bbexp'); if (bx) bx.onclick = () => downloadText('Bible highlights and notes.md', '# Bible highlights and notes\n\n' + list.map(m => `**${bbRef(m.b, m.c, m.v)}**${m.type === 'note' ? ' (note)' : ''}\n${m.text ? '> ' + String(m.text).replace(/\n+/g, ' ') + '\n' : ''}${m.note ? '\n' + m.note + '\n' : ''}`).join('\n') + '\nBerean Standard Bible (BSB), public domain.\n');
|
||||
document.querySelectorAll('.bbm').forEach(r => { const m = list.find(x => x.id === r.dataset.id);
|
||||
r.querySelector('.bbmj').onclick = () => { location.hash = `#/bible/${m.b}/${m.c}/${m.v}`; };
|
||||
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => bbNoteDialog(m);
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTim
|
||||
const card = (t, body, add, cls) => `<section class="card ${cls || ''}"><h2 style="display:flex;justify-content:space-between;align-items:center">${t}${add ? `<button class="pbtn" data-add="${add}" aria-label="Add">+</button>` : ''}</h2>${body}</section>`;
|
||||
function weekCard(w) {
|
||||
if (!w || !w.people.length) return none('No points yet');
|
||||
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
|
||||
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), goal = mine.find(t => t.id === p.goal), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${goal ? (got.length ? ' · ' : '') + (p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached!' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)) : next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
|
||||
}
|
||||
const none = t => `<p class="none">${t}</p>`;
|
||||
|
||||
|
||||
+10
-1
@@ -18,6 +18,7 @@
|
||||
setInterval(checkForUpdate, 10 * 60e3);
|
||||
await loadMe();
|
||||
await loadSettings();
|
||||
if (typeof themeStart === 'function') themeStart();
|
||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
@@ -28,7 +29,15 @@
|
||||
let lastBadge = Date.now(); badgePoll();
|
||||
setInterval(() => { if (document.visibilityState === 'visible') { lastBadge = Date.now(); badgePoll(); } }, 2 * 60e3);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - lastBadge > 30e3) { lastBadge = Date.now(); badgePoll(); } });
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
// tell the service worker who is signed in, so what it saves for offline use is kept for that person only
|
||||
const who = () => navigator.serviceWorker.ready.then(r => (r.active || navigator.serviceWorker.controller || {}).postMessage && (r.active || navigator.serviceWorker.controller).postMessage({ type: 'who', id: ME ? ME.id : '' })).catch(() => {});
|
||||
who(); navigator.serviceWorker.addEventListener('controllerchange', who);
|
||||
}
|
||||
// offline: say so once, and that what is on screen is the last saved copy
|
||||
{ const note = () => { const el = document.getElementById('offnote') || Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); el.textContent = 'You are offline. Showing what was last saved; changes need a connection.'; el.hidden = navigator.onLine; if (!el.isConnected) document.body.appendChild(el); if (typeof offqNote === 'function') offqNote(); };
|
||||
addEventListener('offline', note); addEventListener('online', note); if (!navigator.onLine) note(); }
|
||||
})();
|
||||
|
||||
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
|
||||
|
||||
+6
-2
@@ -126,6 +126,8 @@ function openEvent(e) {
|
||||
}
|
||||
if (e.contactId) { // a birthday from Contacts
|
||||
const el = openSheet(e.title, 'Birthdays', `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button>${allowed('contacts') ? '<a class="btn" id="bco" style="flex:1;justify-content:center">Open contact</a>' : ''}</div>`);
|
||||
const inv = el.querySelector('#finv'), invOk = () => { const c = (o.cals || []).find(x => x.id === el.querySelector('#fc').value); return !!c && !c.icloud && !(c.shared && !String(c.id).startsWith('family~')); };
|
||||
if (inv) jfetch('/api/cal/people').then(ps => { if (!ps.length || !el.isConnected) return; inv.innerHTML = `<label>Invite</label><div class="invlist">${ps.map(p => `<label class="chk"><input type="checkbox" data-inv="${esc(p.id)}"><i class="invdot" style="background:${esc(p.color)}"></i>${esc(p.name)}</label>`).join('')}</div><p class="sub" id="invnote" style="margin:4px 0 0">The event is added to their calendar and they are told.</p>`; const sync = () => { inv.hidden = !invOk(); }; sync(); el.querySelector('#fc').addEventListener('change', sync); }).catch(() => {});
|
||||
el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; };
|
||||
return;
|
||||
}
|
||||
@@ -169,6 +171,7 @@ function eventForm(o) {
|
||||
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
|
||||
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
|
||||
${calPick}
|
||||
${editing ? '' : '<div id="finv"></div>'}
|
||||
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
|
||||
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
|
||||
@@ -200,9 +203,10 @@ function eventForm(o) {
|
||||
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
|
||||
const cal = editing ? o.calId : el.querySelector('#fc').value;
|
||||
try {
|
||||
await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
|
||||
const invite = editing || (inv && inv.hidden) ? [] : [...el.querySelectorAll('[data-inv]:checked')].map(c => c.dataset.inv);
|
||||
const sent = await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields, invite }) });
|
||||
if (!editing) store.set('cal_default', cal);
|
||||
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
|
||||
closeSheet(); toast(editing ? 'Event saved' : sent && sent.invited ? `Event added, ${sent.invited} invited` : 'Event added'); viewCalendar();
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
|
||||
};
|
||||
const del = el.querySelector('#fd');
|
||||
|
||||
+31
-7
@@ -17,6 +17,10 @@ async function chStart() {
|
||||
ch.es = new EventSource('/api/chat/events');
|
||||
ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
|
||||
}
|
||||
// (iOS keeps a suspended app's connection open for minutes, and the server skips the phone push for anyone it thinks is looking: close the stream whenever the app is hidden)
|
||||
const chHide = () => { if (ch.es) { ch.es.close(); ch.es = null; } };
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) chHide(); else chStart(); });
|
||||
window.addEventListener('pagehide', chHide);
|
||||
let chEvT = null;
|
||||
function chEvent(ev) {
|
||||
if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
|
||||
@@ -58,7 +62,7 @@ function chDrawList() {
|
||||
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
|
||||
const found = q.length >= 2 && ch.found ? ch.found : [];
|
||||
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
|
||||
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text)}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|
||||
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text || (f.msg.share ? f.msg.share.title : ''))}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|
||||
|| '';
|
||||
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
|
||||
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
|
||||
@@ -74,6 +78,16 @@ function chPoll(m) {
|
||||
${P.options.map(o => { const n = count(o.id); return `<button type="button" class="sgopt ${mine.includes(o.id) ? 'on' : ''}" data-o="${esc(o.id)}" ${P.closed ? 'disabled' : ''}><i style="width:${total ? Math.round(n / total * 100) : 0}%"></i><span>${mine.includes(o.id) ? '✓ ' : ''}${esc(o.text)}</span><small>${n}</small></button>`; }).join('')}
|
||||
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
|
||||
}
|
||||
// ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person.
|
||||
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'] };
|
||||
/** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */
|
||||
const chShareOk = link => { const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
|
||||
function chShareCard(m) {
|
||||
const s = m.share, k = CH_SHARE[s.kind] || ['🔗', 'Shared'], ok = chShareOk(s.link), art = /^\/(plex|api)\//.test(s.art || '') ? s.art : '';
|
||||
const body = `${art ? `<img class="sgshare-art" alt="" loading="lazy" src="${esc(art)}" onerror="this.replaceWith(Object.assign(document.createElement('i'),{className:'sgshare-art',textContent:'${k[0]}'}))">` : `<i class="sgshare-art">${k[0]}</i>`}
|
||||
<span class="sgshare-t"><small>${k[0]} ${esc(k[1])}</small><b>${esc(s.title)}</b>${s.sub ? `<em>${esc(s.sub)}</em>` : ''}${ok ? '' : '<small class="sgshare-off">Not available for you</small>'}</span>`;
|
||||
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
|
||||
}
|
||||
function chBubble(m, c, prev) {
|
||||
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
|
||||
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
|
||||
@@ -85,7 +99,7 @@ function chBubble(m, c, prev) {
|
||||
if (m.sys) return `${day}<div class="sgday sgsys">${esc(m.text)}</div>`;
|
||||
const tick = m.out && m.status ? `<span class="${m.status === 'read' ? 'sgrd' : ''}">${m.status === 'sent' ? '✓' : '✓✓'}</span>` : '';
|
||||
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${m.fwd ? `<small class="sgfwd">↪ Forwarded from ${esc(m.fwd.name)}</small>` : ''}${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
|
||||
${m.poll ? chPoll(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
||||
${m.poll ? chPoll(m) : ''}${m.share && !m.deleted ? chShareCard(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
||||
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${chMention(sgLink(esc(m.text)).replace(/\n/g, '<br>'))}</div>` : ''}
|
||||
${m.link && !m.deleted ? `<a class="sgprev" href="${esc(m.link.url)}" target="_blank" rel="noopener noreferrer">${m.link.image ? `<img alt="" loading="lazy" src="${esc(stOk(m.link.image))}" onerror="this.remove()">` : ''}<span><small>${esc(m.link.site || new URL(m.link.url).hostname)}</small><b>${esc(m.link.title)}</b>${m.link.desc ? `<em>${esc(m.link.desc)}</em>` : ''}</span></a>` : ''}
|
||||
<span class="sgmeta">${m.exp ? '⏱ ' : ''}${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}</span>
|
||||
@@ -135,7 +149,7 @@ function chMessageMenu(m, c) {
|
||||
}
|
||||
function chDrawReply() {
|
||||
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
|
||||
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || (ch.reply.share && ch.reply.share.title) || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
|
||||
}
|
||||
function chDrawTray() {
|
||||
@@ -216,8 +230,8 @@ function chPaintSub() {
|
||||
}
|
||||
function chDrawPin() {
|
||||
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
|
||||
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
||||
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
||||
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.share && last.share.title) || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
||||
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || (m.share && m.share.title) || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
||||
el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
|
||||
el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
|
||||
}
|
||||
@@ -321,6 +335,15 @@ async function shareToChat(text) {
|
||||
const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + '…' : text, `<div class="list" style="max-height:50dvh;overflow:auto">${convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span></button>`).join('') || '<p class="empty">Start a chat first.</p>'}</div>`);
|
||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
/** From a song, film, book...: pick a chat, add a note if you like, and a share card goes in. card = { kind, title, sub, art, link } (art and link are paths inside the hub). */
|
||||
async function shareMedia(card) {
|
||||
if (!allowed('chat')) return toast('Chat is not turned on for you');
|
||||
let convs = []; try { convs = (await chApi('/convs')).convs.filter(c => !c.archived); } catch (e) { return toast(e.message); }
|
||||
const k = CH_SHARE[card.kind] || ['🔗', 'Shared'];
|
||||
const el = openSheet('Share to Chat', k[0] + ' ' + card.title, `<label for="chsn">Add a note (optional)</label><input type="text" id="chsn" maxlength="300" autocomplete="off" placeholder="You will love this">
|
||||
<div class="list" style="max-height:44dvh;overflow:auto;margin-top:10px">${convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span>${icon('send')}</button>`).join('') || '<p class="empty">Start a chat first (Chat, then New).</p>'}</div>`);
|
||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const text = el.querySelector('#chsn').value, to = b.dataset.t; closeSheet(); try { await chPost('/send', { conv: to, text, share: { kind: card.kind, title: card.title, sub: card.sub || '', art: /^\/(plex|api)\/[^\s"'<>\\]*$/.test(card.art || '') && !card.art.includes('..') ? card.art : '', link: card.link } }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(to); } }); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
// ---- settings: quiet hours, and the family announcement (admin)
|
||||
async function chSettings() {
|
||||
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
|
||||
@@ -345,10 +368,11 @@ async function chAnnounceCard(el) {
|
||||
function chFit() {
|
||||
const m = chMounted() && ch.root.querySelector('.sgm'); if (!m || m.offsetParent === null) return;
|
||||
if (window.innerWidth >= 1024) { m.style.height = ''; return; }
|
||||
let bottom = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||
let bottom = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : window.innerHeight; // (page coordinates: the visible area can be panned down while the keyboard is up)
|
||||
const top0 = m.getBoundingClientRect().top; // (look up from the screen's edge for the first spot that is page content, not the bottom bar or mini player)
|
||||
for (let y = Math.floor(bottom) - 2; y > top0 + 130; y -= 4) { const hit = document.elementsFromPoint(innerWidth / 2, y).find(e => !e.closest('.sgm')); const covers = hit && !hit.closest('#view'); if (!covers) { bottom = y + 4; break; } }
|
||||
const top = m.getBoundingClientRect().top; m.style.height = Math.max(260, Math.floor(bottom - top - 10)) + 'px';
|
||||
const top = m.getBoundingClientRect().top; m.style.height = Math.max(document.body.classList.contains('kb') ? 150 : 260, Math.floor(bottom - top - 10)) + 'px';
|
||||
}
|
||||
window.addEventListener('kbfit', () => { chFit(); if (!chMounted() || !chq('.sgm')) return; const v = document.getElementById('view'); if (v) v.scrollTop = 0; const l = chq('#chmsgs'); if (l && document.activeElement && document.activeElement.id === 'chtext') l.scrollTop = l.scrollHeight; }); // (the keyboard came or went: refit, and keep the newest message in sight)
|
||||
window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit);
|
||||
setInterval(chFit, 700);
|
||||
+18
-3
@@ -13,12 +13,25 @@ const crMonday = s => crShift(s, -((new Date(s + 'T12:00:00').getDay() + 6) % 7)
|
||||
const crWeekLabel = w => { const f = new Date(w.from + 'T12:00:00'), t = new Date(w.to + 'T12:00:00'), o = { month: 'short', day: 'numeric' }; return (w.from === crMonday(crYmd(new Date())) ? 'This week · ' : '') + f.toLocaleDateString(undefined, o) + ' to ' + t.toLocaleDateString(undefined, o); };
|
||||
/** One person's week: points, how far to the next reward, the rewards reached (an admin marks each as handed over), and the chores they did. */
|
||||
function crWeekPerson(p) {
|
||||
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points);
|
||||
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
|
||||
${next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
|
||||
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points), goal = tiers.find(t => t.id === p.goal), canPick = tiers.length && (p.id === cr.data.me.id || admin);
|
||||
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick${t.id === p.goal ? ' goal' : ''}" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
|
||||
${goal ? `<small class="sub" style="display:block;margin:0 0 4px 52px">🎯 ${p.points >= goal.points ? esc(goal.title) + ': reached! 🎉' : (goal.points - p.points) + ' more for ' + esc(goal.title)}</small>` : next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
|
||||
${canPick ? `<button type="button" class="btn ghost crgoalbtn" data-goal="${esc(p.id)}">${goal ? 'Change goal' : 'Pick a goal'}</button>` : ''}
|
||||
${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')}
|
||||
${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`;
|
||||
}
|
||||
/** A child's own "aiming for" card at the top of Chores: the reward they chose and how far they have to go this week. */
|
||||
function crGoalCard() {
|
||||
const me = cr.data.me.id, p = cr.week.people.find(x => x.id === me); if (!p || !p.tiers.length) return '';
|
||||
const t = cr.week.tiers.find(x => x.id === p.goal), now = cr.week.from === crMonday(crYmd(new Date()));
|
||||
return `<div class="crgoal"><span class="crgi">🎯</span><span class="grow"><small>Aiming for</small><b>${t ? esc(t.title) : 'Pick a reward to aim for'}</b>${t && now ? `<small>${p.points >= t.points ? 'You did it! 🎉' : (t.points - p.points) + ' more points this week'}</small>` : ''}</span><button type="button" class="btn ${t ? 'ghost' : ''}" id="crgoal">${t ? 'Change' : 'Pick'}</button></div>`;
|
||||
}
|
||||
function crPickGoal(uid) {
|
||||
const p = cr.week.people.find(x => x.id === uid); if (!p) return; const tiers = cr.week.tiers.filter(t => p.tiers.includes(t.id));
|
||||
const el = openSheet('What are you aiming for?', uid === cr.data.me.id ? 'Pick a reward for this week. Chores shows how far you have to go. You pick again each Monday.' : 'Choose a goal for ' + p.name, `<div class="list">${tiers.map(t => `<button class="row" data-g="${esc(t.id)}"><span class="ic" style="background:var(--accent)">🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${t.points} points in a week${t.minutes ? ' · +' + t.minutes + ' min screen time' : ''}</small></span>${p.goal === t.id ? icon('check') : ''}</button>`).join('')}</div>${p.goal ? '<div class="bar"><button class="btn ghost" id="crgno" style="width:100%;justify-content:center">No goal for now</button></div>' : ''}`);
|
||||
const set = async r => { try { await crPost('/goal', { reward: r, user: uid, date: crYmd(new Date()) }); if (typeof inboxSettled === 'function') inboxSettled([{ tag: 'goal:' + crMonday(crYmd(new Date())) }]); closeSheet(); viewChores(); } catch (e) { toast(e.message); } };
|
||||
el.querySelectorAll('[data-g]').forEach(b => b.onclick = () => set(b.dataset.g)); const n = el.querySelector('#crgno'); if (n) n.onclick = () => set('');
|
||||
}
|
||||
/** After a change in the manage sheets: redraw the Chores page, or (when opened from Settings) reopen the manager. */
|
||||
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
|
||||
async function viewChores() {
|
||||
@@ -31,6 +44,7 @@ async function viewChores() {
|
||||
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>`; };
|
||||
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>
|
||||
${crGoalCard()}
|
||||
<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>` : ''}
|
||||
@@ -43,6 +57,7 @@ async function viewChores() {
|
||||
$('#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(); };
|
||||
$('#wkprev').onclick = () => { cr.wk = crShift(cr.week.from, -7); viewChores(); }; $('#wknext').onclick = () => { cr.wk = crShift(cr.week.from, 7); viewChores(); }; if ($('#wktoday')) $('#wktoday').onclick = () => { cr.wk = crYmd(new Date()); viewChores(); };
|
||||
v.querySelectorAll('[data-give]').forEach(b => b.onclick = async () => { const [u, r] = b.dataset.give.split('|'); try { await crPost('/give', { week: cr.week.from, user: u, reward: r, undo: !!b.dataset.undo }); viewChores(); } catch (e) { toast(e.message); } });
|
||||
if ($('#crgoal')) $('#crgoal').onclick = () => crPickGoal(cr.data.me.id); v.querySelectorAll('.crgoalbtn').forEach(b => b.onclick = () => crPickGoal(b.dataset.goal));
|
||||
if ($('#crman')) $('#crman').onclick = crManage;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ function ctShow(c) {
|
||||
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
|
||||
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
|
||||
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
|
||||
${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
|
||||
${line('birthday', esc(ctBday(c.birthday)))}${line('anniversary', esc(ctBday(c.anniversary)))}${line('gift ideas', esc(c.gifts || '').replace(/\n/g, '<br>'))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
|
||||
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
|
||||
el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); });
|
||||
if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); };
|
||||
@@ -63,12 +63,12 @@ function ctShow(c) {
|
||||
function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); }
|
||||
|
||||
function ctEdit(c, seed) {
|
||||
const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' };
|
||||
const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', anniversary: '', gifts: '', notes: '' };
|
||||
const rows = (kind, items, ph) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
|
||||
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
|
||||
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
|
||||
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
|
||||
<label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
|
||||
<label for="ctan">Anniversary</label><input type="date" id="ctan" value="${esc(x.anniversary && !x.anniversary.startsWith('--') ? x.anniversary : '')}"><label for="ctg">Gift ideas</label><textarea id="ctg" rows="3" placeholder="One idea per line">${esc(x.gifts || '')}</textarea><label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||
el.querySelector('#ctx').onclick = closeSheet;
|
||||
el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); });
|
||||
@@ -76,7 +76,8 @@ function ctEdit(c, seed) {
|
||||
el.querySelector('#ctf').onsubmit = async e => {
|
||||
e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = '';
|
||||
const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value;
|
||||
const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value };
|
||||
const keepAnn = x.anniversary && x.anniversary.startsWith('--') && !el.querySelector('#ctan').value;
|
||||
const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, anniversary: keepAnn ? x.anniversary : el.querySelector('#ctan').value, gifts: el.querySelector('#ctg').value, notes: el.querySelector('#ctt').value };
|
||||
try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; }
|
||||
};
|
||||
el.querySelector('#ctn').focus();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
// ---- Devotion (inside Bible): a verse for today, a read-through-the-Bible-in-a-year plan with a check-in each day, and a prayer list the whole family shares.
|
||||
const DV_VERSES = ['JHN 3:16', 'PSA 23:1-3', 'PRO 3:5-6', 'ROM 8:28', 'PHP 4:6-7', 'ISA 41:10', 'JER 29:11', 'MAT 6:33', 'PSA 46:1', 'JOS 1:9', 'ROM 12:2', 'GAL 5:22-23', '1CO 13:4-7', 'EPH 2:8-9', 'PSA 119:105', 'MAT 11:28-30', 'COL 3:23', 'PSA 27:1', '2TI 1:7', 'HEB 11:1', 'PSA 121:1-2', 'MIC 6:8', 'JHN 14:27', 'ROM 5:8', '1JN 4:19', 'PSA 37:4-5', 'LAM 3:22-23', 'ISA 40:31', 'MAT 5:14-16', 'JAS 1:5', '1PE 5:7', 'PSA 34:8', 'DEU 31:6', 'PRO 22:6', 'EPH 6:1-3', '1TH 5:16-18', 'PSA 139:13-14', 'JHN 15:12-13', 'ROM 15:13', 'NUM 6:24-26', 'PSA 103:1-2', 'HEB 13:5-6', 'PRO 16:3', '2CO 12:9', 'JHN 1:1-5', 'PSA 100:4-5', 'MAT 28:19-20', 'ZEP 3:17', 'PSA 51:10', 'COL 3:12-14'];
|
||||
const dvDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const dvDiff = (a, b) => Math.round((new Date(b + 'T12:00:00') - new Date(a + 'T12:00:00')) / 864e5);
|
||||
/** The text of verses v..ve in a chapter, footnote markers left out. */
|
||||
function dvText(book, c, v, ve) {
|
||||
const out = []; for (const b of book.chapters[c - 1]) if (b[0] === 'v' && b[1] >= v && b[1] <= (ve || v)) out.push(b[2].map(p => typeof p === 'string' ? p : Array.isArray(p) ? ' ' + p[1] + ' ' : ' ').join('').replace(/\s+/g, ' ').replace(/\s+([,.;:!?])/g, '$1').trim());
|
||||
return out.join(' ');
|
||||
}
|
||||
function dvVerseOfDay() {
|
||||
const now = new Date(), n = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 864e5), m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(DV_VERSES[n % DV_VERSES.length]), book = bbBook(m[1]);
|
||||
return book ? { ref: bbRef(m[1], +m[2], +m[3], m[4] ? +m[4] : 0), text: dvText(book, +m[2], +m[3], m[4] ? +m[4] : 0), link: `#/bible/${m[1]}/${m[2]}/${m[3]}` } : null;
|
||||
}
|
||||
/** Every chapter of the Bible in order, split into 365 daily readings. */
|
||||
function dvReadings(day) {
|
||||
const all = []; for (const b of bb.books) for (let c = 1; c <= b.chapters.length; c++) all.push([b.id, c]);
|
||||
const from = Math.floor(day * all.length / 365), to = Math.floor((day + 1) * all.length / 365); return all.slice(from, to);
|
||||
}
|
||||
async function bbToday() {
|
||||
const v = $('#view'); let plan = bbSt.plan || null, prayers = [], showAnswered = false;
|
||||
try { prayers = await bbApi('/prayers'); } catch {}
|
||||
const draw = () => {
|
||||
const vod = dvVerseOfDay(), today = dvDay(), idx = plan ? dvDiff(plan.start, today) : -1, day = Math.min(364, Math.max(0, idx)), done = new Set(plan ? plan.done : []);
|
||||
let streak = 0; if (plan) for (let d = Math.min(idx, 364) - (done.has(Math.min(idx, 364)) ? 0 : 1); d >= 0 && done.has(d); d--) streak++;
|
||||
const open = prayers.filter(p => !p.answered), answered = prayers.filter(p => p.answered);
|
||||
const prayRow = p => `<div class="row"><span class="grow"><span style="display:block;white-space:normal">${esc(p.text)}</span><small>${esc(p.byName)} · ${new Date(p.answered ? p.answeredAt : p.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}${p.answered ? ' · answered' : ''}</small></span><button class="ibtn" data-pa="${p.id}" data-on="${p.answered ? '' : '1'}" aria-label="${p.answered ? 'Mark as still praying' : 'Mark as answered'}" title="${p.answered ? 'Still praying' : 'Answered'}">${icon('check')}</button>${p.mine || (typeof ME !== 'undefined' && ME && ME.role === 'admin') ? `<button class="ibtn" data-pd="${p.id}" aria-label="Remove" title="Remove">${icon('trash')}</button>` : ''}</div>`;
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Devotion</h1><a class="btn ghost" href="#/bible">${icon('bible')}Bible</a></div>
|
||||
${vod ? `<h2>Verse for today</h2><a class="card dvverse" href="${vod.link}"><p>“${esc(vod.text)}”</p><small>${esc(vod.ref)} · BSB</small></a>` : ''}
|
||||
<h2>Reading plan</h2>${plan ? `<div class="card" style="max-width:none"><b>Day ${day + 1} of 365</b><span class="sub"> · ${done.size} day${done.size === 1 ? '' : 's'} done${streak > 1 ? ` · ${streak} in a row` : ''}</span>
|
||||
<div class="dvread">${idx < 0 ? `<p class="sub">Your plan starts ${esc(plan.start)}.</p>` : dvReadings(day).map(([b, c]) => `<a class="btn ghost" href="#/bible/${b}/${c}">${esc(bbBook(b).name)} ${c}</a>`).join('')}</div>
|
||||
${idx >= 0 ? `<div class="bar"><button class="btn${done.has(day) ? ' ghost' : ''}" id="dvck">${icon('check')}${done.has(day) ? 'Done today' : 'Mark today as read'}</button><button class="btn ghost" id="dvstop">Stop plan</button></div>` : ''}</div>`
|
||||
: `<div class="card" style="max-width:none"><p class="sub" style="margin-top:0">Read the whole Bible in a year, a few chapters a day, and check in each day.</p><div class="bar"><button class="btn" id="dvstart">Start today</button></div></div>`}
|
||||
<h2>Family prayer list</h2><form class="bar" id="dvpf" style="flex-wrap:nowrap"><input type="text" id="dvpt" placeholder="Add a prayer request" maxlength="500" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
|
||||
<div class="list">${open.map(prayRow).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what the family is praying about.</small></span></div>'}</div>
|
||||
${answered.length ? `<div class="bar"><button class="btn ghost" id="dvans">${showAnswered ? 'Hide' : 'Show'} answered (${answered.length})</button></div>${showAnswered ? `<div class="list">${answered.map(prayRow).join('')}</div>` : ''}` : ''}</div>`;
|
||||
const save = async (fn) => { try { await fn(); draw(); } catch (e) { toast(e.message); } };
|
||||
const st = $('#dvstart'); if (st) st.onclick = () => save(async () => { plan = (await bbApi('/plan', bbOpt('POST', { start: dvDay() }))).plan; bbSt.plan = plan; });
|
||||
const sp = $('#dvstop'); if (sp) sp.onclick = () => confirmSheet('Stop the reading plan?', 'Your check-ins are cleared.', 'Stop plan', () => save(async () => { await bbApi('/plan', bbOpt('POST', { stop: true })); plan = bbSt.plan = null; }));
|
||||
const ck = $('#dvck'); if (ck) ck.onclick = () => save(async () => { plan = (await bbApi('/plan/check', bbOpt('POST', { day, on: !done.has(day) }))).plan; bbSt.plan = plan; });
|
||||
$('#dvpf').onsubmit = e => { e.preventDefault(); const t = $('#dvpt').value.trim(); if (t) save(async () => { prayers = await bbApi('/prayers', bbOpt('POST', { text: t })); }); };
|
||||
v.querySelectorAll('[data-pa]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/answer', bbOpt('POST', { id: b.dataset.pa, on: !!b.dataset.on })); }));
|
||||
v.querySelectorAll('[data-pd]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/delete', bbOpt('POST', { id: b.dataset.pd })); }));
|
||||
const an = $('#dvans'); if (an) an.onclick = () => { showAnswered = !showAnswered; draw(); };
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -16,6 +16,21 @@ function viewFamily() {
|
||||
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);
|
||||
}
|
||||
// ---- Keep the page fresh while it stays open (a tablet on the counter): every minute, and when the app comes back to the front. It waits while a sheet is open or someone is typing an item.
|
||||
const fmMounted = () => !!document.querySelector('#view .fmwrap');
|
||||
function fmRefresh() {
|
||||
if (!fmMounted() || document.hidden) return;
|
||||
const sheet = document.getElementById('sheet'); if (sheet && sheet.classList.contains('on')) return;
|
||||
const a = document.activeElement, typing = document.getElementById('fmshi'); if ((a && a.closest && a.closest('.fmwrap') && /^(INPUT|TEXTAREA)$/.test(a.tagName) && a.type !== 'checkbox') || (typing && typing.value)) return;
|
||||
const h = new Date().getHours(), head = document.querySelector('.fmwrap .homehead');
|
||||
if (head) { const t = head.querySelector('h1'), d = head.querySelector('.sub'), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; if (t) t.textContent = greet + (ME ? ', ' + ME.name.split(' ')[0] : ''); if (d) d.textContent = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); }
|
||||
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||
if (typeof inboxCount === 'function') inboxCount();
|
||||
for (const [id, load] of [['fmcal', fmEvents], ['fmch', fmChores], ['fmsh', fmShopping], ['fmnt', fmNotes], ['fmct', fmChat], ['fmtk', fmTasks]]) if (document.getElementById(id)) load().catch(() => {}); // (a failed refresh keeps what is already shown)
|
||||
}
|
||||
setInterval(fmRefresh, 60000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) fmRefresh(); });
|
||||
|
||||
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;
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
// ---- 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 inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); if (typeof pushResync === 'function') pushResync(); }
|
||||
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
|
||||
/** What a read or cleared notification stands for, on this device: its banner on the lock screen goes, and an announcement stops showing on Home. (The server marks the chats and mail read.) */
|
||||
async function inboxSettled(items, all) {
|
||||
for (const i of items) if (i.ref && i.ref.announce) { try { localStorage.setItem('ch_ann_gone:' + (typeof ME !== 'undefined' && ME ? ME.id : ''), i.ref.announce); } catch {} const a = document.getElementById('annc'); if (a) a.innerHTML = ''; }
|
||||
try { const reg = await navigator.serviceWorker.ready, tags = items.map(i => i.tag).filter(Boolean); for (const n of await reg.getNotifications()) if (all || tags.includes(n.tag)) n.close(); } catch {}
|
||||
}
|
||||
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' }); };
|
||||
@@ -14,11 +19,11 @@ async function inboxOpen() {
|
||||
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(); }));
|
||||
l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; inboxSettled(items.filter(i => i.id === 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); } };
|
||||
el.querySelector('#inboxall').onclick = async () => { try { inboxSettled(items.filter(i => !i.read), true); 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 { inboxSettled(items.filter(i => !i.read), true); await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
}
|
||||
+59
-55
@@ -52,60 +52,64 @@
|
||||
<div id="main"><div id="navbar" hidden></div><div id="subnav" class="seg" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="/webauthn.js"></script>
|
||||
<script src="/links.js"></script>
|
||||
<script src="/palette.js"></script>
|
||||
<script src="/planner.js"></script>
|
||||
<script src="/reminders.js"></script>
|
||||
<script src="/file-history.js"></script>
|
||||
<script src="/music-extras.js"></script>
|
||||
<script src="/music-more.js"></script>
|
||||
<script src="/screentime-view.js"></script>
|
||||
<script src="/calendar-views.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/search-view.js"></script>
|
||||
<script src="/today.js"></script>
|
||||
<script src="/calendar.js"></script>
|
||||
<script src="/tasks.js"></script>
|
||||
<script src="/audiobooks.js"></script>
|
||||
<script src="/music.js"></script>
|
||||
<script src="/video.js"></script>
|
||||
<script src="/office.js"></script>
|
||||
<script src="/player.js"></script>
|
||||
<script src="/offline.js"></script>
|
||||
<script src="/podcast-view.js"></script>
|
||||
<script src="/reader.js"></script>
|
||||
<script src="/audiobooks-view.js"></script>
|
||||
<script src="/librivox-view.js"></script>
|
||||
<script src="/radio-view.js"></script>
|
||||
<script src="/store-view.js"></script>
|
||||
<script src="/recipes-view.js"></script>
|
||||
<script src="/contacts-view.js"></script>
|
||||
<script src="/bible-view.js"></script>
|
||||
<script src="/save-to-files.js"></script>
|
||||
<script src="/vbv-view.js"></script>
|
||||
<script src="/lyrics-view.js"></script>
|
||||
<script src="/visualizer.js"></script>
|
||||
<script src="/settings.js"></script>
|
||||
<script src="/files.js"></script>
|
||||
<script src="/files-extra.js"></script>
|
||||
<script src="/sheets.js"></script>
|
||||
<script src="/file-actions.js"></script>
|
||||
<script src="/signal-view.js"></script>
|
||||
<script src="/chat-view.js"></script>
|
||||
<script src="/notes-view.js"></script>
|
||||
<script src="/chores-view.js"></script>
|
||||
<script src="/family-view.js"></script>
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/share.js"></script>
|
||||
<script src="/cast.js"></script>
|
||||
<script src="/rows.js"></script>
|
||||
<script src="/keys.js"></script>
|
||||
<script src="/inbox-view.js"></script>
|
||||
<script src="/device-ui.js"></script>
|
||||
<script src="/settings-family.js"></script>
|
||||
<script src="/simple-mode.js"></script>
|
||||
<script src="/boot.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
<script defer src="/webauthn.js"></script>
|
||||
<script defer src="/links.js"></script>
|
||||
<script defer src="/palette.js"></script>
|
||||
<script defer src="/planner.js"></script>
|
||||
<script defer src="/reminders.js"></script>
|
||||
<script defer src="/file-history.js"></script>
|
||||
<script defer src="/music-extras.js"></script>
|
||||
<script defer src="/music-more.js"></script>
|
||||
<script defer src="/screentime-view.js"></script>
|
||||
<script defer src="/calendar-views.js"></script>
|
||||
<script defer src="/app.js"></script>
|
||||
<script defer src="/search-view.js"></script>
|
||||
<script defer src="/today.js"></script>
|
||||
<script defer src="/calendar.js"></script>
|
||||
<script defer src="/tasks.js"></script>
|
||||
<script defer src="/audiobooks.js"></script>
|
||||
<script defer src="/music.js"></script>
|
||||
<script defer src="/offline-queue.js"></script>
|
||||
<script defer src="/video.js"></script>
|
||||
<script defer src="/office.js"></script>
|
||||
<script defer src="/player.js"></script>
|
||||
<script defer src="/offline.js"></script>
|
||||
<script defer src="/podcast-view.js"></script>
|
||||
<script defer src="/reader.js"></script>
|
||||
<script defer src="/audiobooks-view.js"></script>
|
||||
<script defer src="/librivox-view.js"></script>
|
||||
<script defer src="/radio-view.js"></script>
|
||||
<script defer src="/store-view.js"></script>
|
||||
<script defer src="/recipes-view.js"></script>
|
||||
<script defer src="/contacts-view.js"></script>
|
||||
<script defer src="/bible-view.js"></script>
|
||||
<script defer src="/devotion-view.js"></script>
|
||||
<script defer src="/save-to-files.js"></script>
|
||||
<script defer src="/vbv-view.js"></script>
|
||||
<script defer src="/lyrics-view.js"></script>
|
||||
<script defer src="/visualizer.js"></script>
|
||||
<script defer src="/settings.js"></script>
|
||||
<script defer src="/files.js"></script>
|
||||
<script defer src="/files-extra.js"></script>
|
||||
<script defer src="/sheets.js"></script>
|
||||
<script defer src="/file-actions.js"></script>
|
||||
<script defer src="/signal-view.js"></script>
|
||||
<script defer src="/chat-view.js"></script>
|
||||
<script defer src="/notes-view.js"></script>
|
||||
<script defer src="/chores-view.js"></script>
|
||||
<script defer src="/family-view.js"></script>
|
||||
<script defer src="/mail.js"></script>
|
||||
<script defer src="/share.js"></script>
|
||||
<script defer src="/cast.js"></script>
|
||||
<script defer src="/rows.js"></script>
|
||||
<script defer src="/keys.js"></script>
|
||||
<script defer src="/inbox-view.js"></script>
|
||||
<script defer src="/device-ui.js"></script>
|
||||
<script defer src="/settings-family.js"></script>
|
||||
<script defer src="/simple-mode.js"></script>
|
||||
<script defer src="/themes.js"></script>
|
||||
<script defer src="/automations-view.js"></script>
|
||||
<script defer src="/boot.js"></script>
|
||||
<script defer src="/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -85,9 +85,10 @@ async function lvSheet(id, after, known = new Map()) {
|
||||
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
||||
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button>${allowed('chat') ? `<button class="btn ghost" id="lvh">${icon('send')}Share</button>` : ''}</div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
||||
el.querySelector('#lvp').onclick = () => go();
|
||||
if (el.querySelector('#lvh')) el.querySelector('#lvh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: b.title, sub: b.author || '', link: '#/audio/book/lv:' + id }); }; // (no picture: LibriVox covers live on another site)
|
||||
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
||||
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
|
||||
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
||||
|
||||
@@ -81,7 +81,7 @@ function lyrOpen() {
|
||||
const t = cur(); if (!t || lyr.open) return;
|
||||
lyr.open = true; document.body.classList.add('lyropen');
|
||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||
document.body.appendChild(el); lyrShell(); lyrReload();
|
||||
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||
}
|
||||
function lyrClose() {
|
||||
|
||||
+40
-10
@@ -25,7 +25,7 @@ const mailWhen = iso => {
|
||||
if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' });
|
||||
};
|
||||
const mailFolderName = id => (mail.folders.find(f => f.id === id) || {}).name || (/~inbox$/.test(id) || id === 'inbox' ? 'Inbox' : 'Folder');
|
||||
const mailFolderName = id => id === 'unified' ? 'All inboxes' : (mail.folders.find(f => f.id === id) || {}).name || (/~inbox$/.test(id) || id === 'inbox' ? 'Inbox' : 'Folder');
|
||||
|
||||
/** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */
|
||||
function mailDoc(html, images) {
|
||||
@@ -176,9 +176,10 @@ async function viewMail(args = []) {
|
||||
const [kind, key] = args;
|
||||
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
|
||||
const want = idAcct(kind === 'f' ? key : kind === 'm' ? key : '') || (mail.accts.some(a => a.id === mail.acct) ? mail.acct : mail.accts[0].id);
|
||||
if (want !== mail.acct) { mail.acct = want; mail.folders = []; mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; }
|
||||
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key;
|
||||
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
|
||||
const keepUni = mail.folder === 'unified' && kind === 'm'; // reading a message from the all-inboxes list keeps that list behind it
|
||||
if (want !== mail.acct) { mail.acct = want; mail.folders = []; if (!keepUni) { mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; } }
|
||||
if (kind === 'f' && key) mail.folder = key === 'unified' ? 'unified' : idAcct(key) ? key : mail.acct + '~' + key;
|
||||
else if (mail.folder !== 'unified' && !String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
|
||||
if (!mail.folders.length) { const f = mcGet('f:' + mail.acct); if (f) mail.folders = f.folders; }
|
||||
mailShell(kind === 'm' && key ? 'read' : 'list');
|
||||
const acct = mail.acct;
|
||||
@@ -189,8 +190,8 @@ async function viewMail(args = []) {
|
||||
mailPoll().then(() => { if (mail.st && location.hash.startsWith('#/mail')) mailRefreshFolders(); });
|
||||
}
|
||||
/** One button per account with its own unread count, so you can see which one has new mail without switching. */
|
||||
const mailChips = () => `<div class="maccts" role="tablist" aria-label="Accounts">${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct}" class="${a.id === mail.acct ? 'on' : ''}${a.unread ? ' new' : ''}"><span class="mal">${esc(a.name || a.address)}</span><small>${esc(a.name ? a.address : '')}</small>${a.unread ? `<i class="nbadge" style="position:static">${a.unread > 99 ? '99+' : a.unread}</i>` : ''}</button>`).join('')}</div>`;
|
||||
function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === mail.acct) return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
|
||||
const mailChips = () => `<div class="maccts" role="tablist" aria-label="Accounts">${mail.accts.length > 1 ? `<button type="button" role="tab" data-a="unified" aria-selected="${mail.folder === 'unified'}" class="${mail.folder === 'unified' ? 'on' : ''}"><span class="mal">All inboxes</span><small>every account</small></button>` : ''}${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct && mail.folder !== 'unified'}" class="${a.id === mail.acct && mail.folder !== 'unified' ? 'on' : ''}${a.unread ? ' new' : ''}"><span class="mal">${esc(a.name || a.address)}</span><small>${esc(a.name ? a.address : '')}</small>${a.unread ? `<i class="nbadge" style="position:static">${a.unread > 99 ? '99+' : a.unread}</i>` : ''}</button>`).join('')}</div>`;
|
||||
function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === 'unified') { mail.q = ''; location.hash = '#/mail/f/unified'; return; } if (b.dataset.a === mail.acct && mail.folder !== 'unified') return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
|
||||
function mailDrawChips() { if (mail.accts.length < 2) return; document.querySelectorAll('.maccts').forEach(el => el.outerHTML = mailChips()); mailBindChips(); }
|
||||
/** Redraw the folder pickers (names and unread counts) without touching the list or the open message. */
|
||||
function mailRefreshFolders() {
|
||||
@@ -426,7 +427,7 @@ document.addEventListener('touchstart', e => { const a = e.target.closest && e.t
|
||||
// What this device has just done to a message (read, unread), kept for a minute: a list that was already on its way from the server
|
||||
// (it started before the change) must not put the old state back.
|
||||
const mailMine = new Map();
|
||||
const mailInInbox = () => /(^|~)inbox$/.test(mail.folder);
|
||||
const mailInInbox = () => /(^|~)inbox$|^unified$/.test(mail.folder);
|
||||
function mailSetRead(id, read) {
|
||||
mailMine.set(id, { read, t: Date.now() });
|
||||
const it = mail.items.find(x => x.id === id); if (it) it.read = read;
|
||||
@@ -467,14 +468,15 @@ function mailDrawReader() {
|
||||
<p class="mto"><small>To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></p>
|
||||
<div class="mact">
|
||||
<button class="btn" data-a="reply">Reply</button><button class="btn ghost" data-a="replyAll">Reply all</button><button class="btn ghost" data-a="forward">Forward</button>
|
||||
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
|
||||
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
|
||||
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="snooze">Snooze</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
|
||||
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>${allowed('files') ? `<button class="btn ghost" data-att="${esc(a.id)}" aria-label="Save ${esc(a.name)} to Files" title="Save to Files">${icon('files')}</button>` : ''}`).join('')}</div>` : ''}
|
||||
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
|
||||
<iframe class="mbody" title="Message" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
|
||||
if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
|
||||
mailFit(rd.querySelector('.mbody'));
|
||||
if (typeof ctMailHook === 'function') ctMailHook(rd, m);
|
||||
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
|
||||
rd.querySelectorAll('[data-att]').forEach(b => b.onclick = () => { const at = m.attachments.find(x => x.id === b.dataset.att); if (at) saveToFiles(at.name, { kind: 'mail-attachment', id: m.id, aid: at.id }); });
|
||||
}
|
||||
|
||||
async function mailAction(a) {
|
||||
@@ -492,6 +494,7 @@ async function mailAction(a) {
|
||||
try {
|
||||
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
|
||||
if (a === 'archive') return away('move', { to: 'archive' }, 'Archived');
|
||||
if (a === 'snooze') return mailSnoozeSheet(m, async until => { await post('snooze', { until, subject: m.subject, from: mailFrom(m.from) }); toast('Snoozed until ' + new Date(until).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })); back(); });
|
||||
if (a === 'unread') { await post('update', { read: false }); m.read = false; mailSetRead(m.id, false); toast('Marked unread'); if (mailInInbox()) { mailUnread++; renderNav(); } return back(true); }
|
||||
if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); }
|
||||
if (a === 'delete') {
|
||||
@@ -510,11 +513,12 @@ function mailCompose(o = {}) {
|
||||
${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `${mail.accts.length > 1 ? `<label for="mfrom">From</label><select id="mfrom">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`}
|
||||
${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div>
|
||||
<label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`}
|
||||
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
|
||||
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><div id="mtpl" class="bar" style="margin:0 0 6px;justify-content:flex-start"></div><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
|
||||
${reply ? '' : `<div class="bar" style="margin:8px 0 0;justify-content:flex-start"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('clip')}Attach<input type="file" id="mfile" multiple hidden></label><small id="mfl" style="color:var(--muted)"></small></div>`}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="msend" style="flex:1;justify-content:center">Send</button></div><div class="err" id="mer"></div></form>`);
|
||||
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
|
||||
if (o.to && el.querySelector('#mto')) el.querySelector('#mto').value = o.to;
|
||||
mailTemplates(el);
|
||||
if (typeof ctSuggest === 'function') for (const id of ['#mto', '#mcc2', '#mbcc']) ctSuggest(el.querySelector(id));
|
||||
const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; };
|
||||
const fi = el.querySelector('#mfile');
|
||||
@@ -534,3 +538,29 @@ function mailCompose(o = {}) {
|
||||
};
|
||||
setTimeout(() => { const f = el.querySelector('#mto') || el.querySelector('#mbd'); if (f) f.focus(); }, 60);
|
||||
}
|
||||
|
||||
/** Pick when a message comes back to the inbox. */
|
||||
function mailSnoozeSheet(m, go) {
|
||||
const at = (days, h) => { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(h, 0, 0, 0); return d.getTime(); }, now = new Date(), sat = (6 - now.getDay() + 7) % 7 || 7, mon = (8 - now.getDay()) % 7 || 7;
|
||||
const opts = [...(now.getHours() < 17 ? [['Later today', at(0, 17)]] : []), ['Tomorrow morning', at(1, 8)], ['This weekend', at(sat, 9)], ['Next week', at(mon, 8)]];
|
||||
const el = openSheet('Snooze', m.subject, `<div class="list">${opts.map(([l, t], i) => `<a href="#" class="row" data-i="${i}"><span class="grow"><b>${l}</b><small>${new Date(t).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</small></span></a>`).join('')}</div>
|
||||
<form id="msf" class="bar" style="flex-wrap:nowrap;margin-top:12px"><input type="datetime-local" id="mst" style="flex:1" aria-label="Pick a date and time"><button class="btn ghost">Pick</button></form><div class="err" id="mse"></div>`);
|
||||
const run = async t => { try { closeSheet(); await go(t); } catch (e) { toast(e.message); } };
|
||||
el.querySelectorAll('[data-i]').forEach(a => a.onclick = e => { e.preventDefault(); run(opts[+a.dataset.i][1]); });
|
||||
el.querySelector('#msf').onsubmit = e => { e.preventDefault(); const t = Date.parse(el.querySelector('#mst').value); if (!(t > Date.now())) { el.querySelector('#mse').textContent = 'Pick a time in the future.'; return; } run(t); };
|
||||
}
|
||||
/** Templates in a new message: insert one, or save what is typed as a template. */
|
||||
function mailTemplates(el) {
|
||||
const bar = el.querySelector('#mtpl'); if (!bar) return;
|
||||
const draw = list => {
|
||||
bar.innerHTML = `<button type="button" class="btn ghost" id="mtp">Templates${list.length ? ' (' + list.length + ')' : ''}</button><button type="button" class="btn ghost" id="mts">Save as template</button>`;
|
||||
bar.querySelector('#mtp').onclick = () => { if (!list.length) return toast('No templates yet. Write a message, then Save as template.');
|
||||
const sh = openSheet('Templates', 'Tap one to use it', `<div class="list">${list.map(t => `<div class="row"><a href="#" class="grow" data-use="${esc(t.id)}"><b>${esc(t.name)}</b><small>${esc(t.subject || t.body.slice(0, 60))}</small></a><button class="ibtn" data-del="${esc(t.id)}" aria-label="Delete ${esc(t.name)}">${icon('trash')}</button></div>`).join('')}</div>`);
|
||||
sh.querySelectorAll('[data-use]').forEach(a => a.onclick = e => { e.preventDefault(); const t = list.find(x => x.id === a.dataset.use); closeSheet(); const sb = el.querySelector('#msb'), bd = el.querySelector('#mbd'); if (sb && t.subject && !sb.value) sb.value = t.subject; bd.value = bd.value ? bd.value + '\n' + t.body : t.body; bd.focus(); });
|
||||
sh.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { try { const l = await jfetch('/api/mail/templates/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.del }) }); closeSheet(); draw(l); toast('Template deleted'); } catch (e) { toast(e.message); } }); };
|
||||
bar.querySelector('#mts').onclick = () => { const body = el.querySelector('#mbd').value.trim(); if (!body) return toast('Write the message first');
|
||||
const sh = openSheet('Save as template', '', `<form id="mtf"><label for="mtn">Name</label><input type="text" id="mtn" autocomplete="off" required><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||
sh.querySelector('#mtf').onsubmit = async e => { e.preventDefault(); try { const l = await jfetch('/api/mail/templates', { method: 'POST', body: JSON.stringify({ name: sh.querySelector('#mtn').value, subject: (el.querySelector('#msb') || {}).value || '', body }) }); closeSheet(); draw(l); toast('Template saved'); } catch (er) { toast(er.message); } }; };
|
||||
};
|
||||
jfetch('/api/mail/templates').then(draw).catch(() => {});
|
||||
}
|
||||
+7
-3
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
|
||||
const jfetch = async (path, opts) => {
|
||||
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
|
||||
let r; try { r = await fetch(path, opts); } catch (e) { const q = typeof offQueued === 'function' && offQueued(path, opts); if (q) return q; throw e; } // (a few kinds of change are kept for later when there is no signal)
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
|
||||
return j;
|
||||
};
|
||||
@@ -155,8 +156,9 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
if (kind === 'artist') {
|
||||
const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || '';
|
||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
|
||||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a></div>
|
||||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a>${allowed('chat') ? `<button class="btn ghost" id="ash">${icon('send')}Share</button>` : ''}</div>
|
||||
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
|
||||
if ($('#ash')) $('#ash').onclick = () => shareMedia({ kind: 'artist', title: mc.title2 || mc.parentTitle || 'Artist', art: px.art((am.Metadata || [])[0] && am.Metadata[0].thumb, 200), link: '#/music/artist/' + key });
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' || kind === 'playlist') {
|
||||
@@ -171,7 +173,7 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
||||
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
||||
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
||||
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
||||
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
||||
@@ -182,6 +184,8 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
||||
}
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||
markPlaying();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
// ---- Changes made with no signal: adding a task, ticking one off, adding to the shopping list and writing a note are kept on this device and sent when the connection is back.
|
||||
// Only these few safe changes are queued (nothing that could clash with what someone else did meanwhile); anything else says it needs a connection. Tasks show the waiting changes.
|
||||
const OFFQ_RULES = [[/^\/api\/tasks$/, b => !b.id && String(b.list || '').startsWith('local:')], [/^\/api\/tasks\/toggle$/, b => String(b.list || '').startsWith('local:')], [/^\/api\/recipes\/shopping\/add$/, () => true], [/^\/api\/notes\/$/, b => !b.id]];
|
||||
const offqKey = () => 'offq:' + (typeof ME !== 'undefined' && ME ? ME.id : '');
|
||||
const offqRead = () => { try { const a = JSON.parse(localStorage.getItem(offqKey())); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const offqWrite = l => { try { if (l.length) localStorage.setItem(offqKey(), JSON.stringify(l)); else localStorage.removeItem(offqKey()); } catch { /* storage full or blocked */ } offqNote(); };
|
||||
const offPending = () => offqRead().length;
|
||||
function offqNote() { const n = offPending(); let el = document.getElementById('offnote'); if (!el && n) { el = Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); document.body.appendChild(el); } if (!el) return; if (n || !navigator.onLine) { el.hidden = false; el.textContent = (navigator.onLine ? '' : 'You are offline. Showing what was last saved. ') + (n ? n + ' change' + (n === 1 ? '' : 's') + ' waiting to send.' : 'Changes need a connection.'); } else el.hidden = true; }
|
||||
/** Called by jfetch when a request failed on the network: keep it for later if it is one of the safe kinds. Returns the answer to give, or null (the error stands). */
|
||||
function offQueued(path, opts) {
|
||||
const method = String((opts && opts.method) || 'GET').toUpperCase(); if (method !== 'POST' || typeof (opts && opts.body) !== 'string') return null;
|
||||
let b; try { b = JSON.parse(opts.body); } catch { return null; } const rule = OFFQ_RULES.find(r => r[0].test(path)); if (!rule || !b || !rule[1](b)) return null;
|
||||
const l = offqRead(); if (l.length >= 100) return null; l.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), path, body: opts.body, t: Date.now() }); offqWrite(l);
|
||||
toast('Saved on this device. It sends when you are back online.'); return { ok: true, queued: true };
|
||||
}
|
||||
let offqBusy = false;
|
||||
/** Send what is waiting, oldest first; stops at the first network failure, and drops a change the server refuses (saying so). */
|
||||
async function offqFlush() {
|
||||
if (offqBusy || !navigator.onLine) return; offqBusy = true; let sent = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const l = offqRead(); if (!l.length) break; const it = l[0];
|
||||
let r; try { r = await fetch(it.path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: it.body }); } catch { break; }
|
||||
if (!r.ok && r.status >= 500) break; // (the server is having trouble: try again later)
|
||||
offqWrite(offqRead().filter(x => x.id !== it.id)); if (r.ok) sent++; else { const j = await r.json().catch(() => ({})); toast('A change made offline was not accepted: ' + (j.error || r.status)); }
|
||||
}
|
||||
} finally { offqBusy = false; }
|
||||
if (sent) { toast(sent + ' change' + (sent === 1 ? '' : 's') + ' sent'); if (/^#\/(tasks|planner)/.test(location.hash) && typeof route === 'function') route(); }
|
||||
}
|
||||
/** What the Tasks list should show while changes are waiting: queued ticks applied, and queued new tasks added (marked as waiting). */
|
||||
function offApplyTasks(tasks) {
|
||||
const q = offqRead(); if (!q.length) return tasks; const out = tasks.map(t => ({ ...t }));
|
||||
for (const it of q) { let b; try { b = JSON.parse(it.body); } catch { continue; }
|
||||
if (/toggle$/.test(it.path)) { const t = out.find(x => x.id === b.id); if (t) t.done = !!b.done; }
|
||||
else if (it.path === '/api/tasks' && b.fields) out.push({ id: 'queued:' + it.id, list: b.list, title: b.fields.title, notes: b.fields.notes || '', due: b.fields.due || '', dueTime: b.fields.dueTime || '', done: !!b.fields.done, parent: '', repeat: 'none', _ln: 'waiting to send' }); }
|
||||
return out;
|
||||
}
|
||||
addEventListener('online', () => { offqNote(); setTimeout(offqFlush, 800); }); addEventListener('offline', offqNote); setTimeout(offqFlush, 4000); setInterval(offqFlush, 60e3);
|
||||
+2
-2
@@ -112,7 +112,7 @@ function trackSheet(t, extra) {
|
||||
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
|
||||
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
|
||||
{ label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) },
|
||||
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareToChat('♪ ' + t.title + ' — ' + t.artist + ' ' + location.origin + '/#/music/album/' + t.albumKey) }] : []),
|
||||
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareMedia({ kind: 'song', title: t.title, sub: t.artist, art: px.art(t.thumb, 200), link: '#/music/album/' + t.albumKey }) }] : []),
|
||||
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
|
||||
] : []),
|
||||
...(extra || []),
|
||||
@@ -296,4 +296,4 @@ function openNow() {
|
||||
updateNow(); syncUI();
|
||||
}
|
||||
// Tell the page how tall the player is, so buttons that float at the bottom (Home, Ask) sit above it instead of covering it.
|
||||
{ const pe = $('#player'); if (pe && window.ResizeObserver) new ResizeObserver(() => document.documentElement.style.setProperty('--playerh', (pe.hidden ? 0 : pe.offsetHeight) + 'px')).observe(pe); }
|
||||
if (typeof ResizeObserver === 'function' && typeof document !== 'undefined') { const pe = document.getElementById('player'); if (pe) new ResizeObserver(() => document.documentElement.style.setProperty('--playerh', (pe.hidden ? 0 : pe.offsetHeight) + 'px')).observe(pe); }
|
||||
@@ -114,7 +114,7 @@ async function pdShow(id) {
|
||||
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
||||
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
||||
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
||||
${allowed('chat') ? `<button class="ibtn" data-share aria-label="Share to Chat" title="Share to Chat">${icon('send')}</button>` : ''}<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
|
||||
@@ -131,6 +131,7 @@ async function pdShow(id) {
|
||||
document.querySelectorAll('.pdep').forEach(r => {
|
||||
const e = byId.get(r.dataset.id);
|
||||
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
||||
const shb = r.querySelector('[data-share]'); if (shb) shb.onclick = () => shareMedia({ kind: 'podcastep', title: pdF(e, 'title'), sub: pdF(show, 'title'), art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) });
|
||||
const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; };
|
||||
paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); };
|
||||
r.querySelector('[data-seen]').onclick = async () => {
|
||||
@@ -139,9 +140,10 @@ async function pdShow(id) {
|
||||
});
|
||||
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
|
||||
$('#pdmore').onclick = () => {
|
||||
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||
const el = openSheet(pdF(show, 'title'), '', `<div class="list">${allowed('chat') ? `<button class="row" id="pdsh"><span class="grow"><b>Share to Chat</b></span></button>` : ''}<button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
||||
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
|
||||
const sh = el.querySelector('#pdsh'); if (sh) sh.onclick = () => { closeSheet(); shareMedia({ kind: 'podcast', title: pdF(show, 'title'), sub: pdF(show, 'author') || '', art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) }); };
|
||||
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
|
||||
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
|
||||
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
|
||||
|
||||
+18
-2
@@ -77,11 +77,26 @@ async function rdLibrary() {
|
||||
$('#rdhint').insertAdjacentHTML('beforeend', ' Press and hold, or right-click, a book for more.');
|
||||
}
|
||||
|
||||
function rdBookMenu(b) {
|
||||
const rdOffKey = id => `/api/read/book/${id}/file`;
|
||||
const rdSaved = async id => { try { return !!(await (await caches.open('hub-offline')).match(rdOffKey(id))); } catch { return false; } };
|
||||
/** Keep a book (and what the reader needs to open it) on this device, so it opens with no signal. */
|
||||
async function rdSaveOffline(b) {
|
||||
if (!('caches' in window)) return toast('This browser cannot save for offline');
|
||||
try {
|
||||
toast('Saving “' + b.title + '”…'); const c = await caches.open('hub-offline'), r = await fetch(rdOffKey(b.id) + '?inline=1'); if (!r.ok) throw new Error('Could not download the book (' + r.status + ')');
|
||||
await c.put(rdOffKey(b.id), r); if (b.cover) { try { const cv = await fetch(`/api/read/book/${b.id}/cover`); if (cv.ok) await c.put(`/api/read/book/${b.id}/cover`, cv); } catch { /* the cover is a nicety */ } }
|
||||
await rdLibs(rdKind(b.ext)).catch(() => {}); // (so the reader's own scripts are in the cache too)
|
||||
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); toast('Saved on this device. It opens with no signal.');
|
||||
} catch (e) { try { await (await caches.open('hub-offline')).delete(rdOffKey(b.id)); } catch {} toast(e.message || 'Could not save'); }
|
||||
}
|
||||
async function rdRemoveOffline(b) { try { const c = await caches.open('hub-offline'); await c.delete(rdOffKey(b.id)); await c.delete(`/api/read/book/${b.id}/cover`); toast('Removed from this device'); } catch { /* nothing saved */ } }
|
||||
async function rdBookMenu(b) {
|
||||
if (!b) return;
|
||||
const saved = await rdSaved(b.id);
|
||||
actionSheet(b.title, b.author || b.ext.toUpperCase(), [
|
||||
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
|
||||
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) },
|
||||
saved ? { icon: 'dl', label: 'Remove from this device', run: () => rdRemoveOffline(b) } : { icon: 'dl', label: 'Save on this device (read offline)', run: () => rdSaveOffline(b) },
|
||||
...(allowed('files') ? [{ icon: 'files', label: 'Save to Files', run: () => saveToFiles(b.title, { kind: 'book', id: b.id }) }] : []),
|
||||
{ icon: 'dl', label: 'Download', run: () => { const a = document.createElement('a'); a.href = `/api/read/book/${b.id}/file`; a.download = b.title + '.' + b.ext; document.body.appendChild(a); a.click(); a.remove(); } },
|
||||
{ icon: 'trash', label: 'Delete from library', danger: true, run: () => confirmSheet('Delete this book?', `"${b.title}" and your bookmarks, notes and highlights in it are removed.`, 'Delete', async () => {
|
||||
@@ -362,11 +377,12 @@ class RdReader {
|
||||
<b style="white-space:normal">${n.type === 'mark' ? icon('bookmark') + ' Bookmark' : `<span class="rddot" style="background:${RD_COLORS[n.color] || RD_COLORS.yellow}"></span>${n.type === 'note' ? 'Note' : 'Highlight'}`} <small style="display:inline">· ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}</small></b>
|
||||
${n.text ? `<small style="white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden">${esc(n.text)}</small>` : ''}${n.note ? `<span style="white-space:pre-wrap;display:block;margin-top:2px">${esc(n.note)}</span>` : ''}</button>
|
||||
${n.type === 'mark' ? '' : `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>`}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="rdexp">${icon('copy')}Copy as text</button></div>` : '<p class="empty">Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.</p>';
|
||||
<div class="bar"><button class="btn ghost" id="rdexp">${icon('copy')}Copy as text</button><button class="btn ghost" id="rdexf">Download</button></div>` : '<p class="empty">Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.</p>';
|
||||
box.querySelectorAll('.rdnote').forEach(r => { const n = this.notes.find(x => x.id === r.dataset.id);
|
||||
r.querySelector('.rdnj').onclick = () => { closeSheet(); this.A.goto(n.loc, null, n); };
|
||||
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => { closeSheet(); this.noteDialog(n); };
|
||||
r.querySelector('[data-d]').onclick = async () => { await this.deleteNote(n); draw(); }; });
|
||||
const exf = box.querySelector('#rdexf'); if (exf) exf.onclick = () => downloadText((this.book.title || 'book').replace(/[^\w .-]+/g, '').trim().slice(0, 60) + ' - notes.md', `# ${this.book.title}${this.book.author ? '\n\n' + this.book.author : ''}\n\n` + l.map(n => n.type === 'mark' ? `- Bookmark, ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}` : `> ${String(n.text || '').replace(/\n+/g, ' ')}\n${n.note ? '\n' + n.note + '\n' : ''}\n*${n.type === 'note' ? 'Note' : 'Highlight'}, ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}*`).join('\n\n') + '\n');
|
||||
const ex = box.querySelector('#rdexp'); if (ex) ex.onclick = () => {
|
||||
const txt = `${this.book.title}${this.book.author ? ' - ' + this.book.author : ''}\n\n` + l.map(n => (n.type === 'mark' ? '[Bookmark] ' + (n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%') : `"${n.text}"${n.note ? '\n Note: ' + n.note : ''}`)).join('\n\n');
|
||||
navigator.clipboard?.writeText(txt).then(() => toast('Copied'), () => toast('Could not copy'));
|
||||
|
||||
+56
-2
@@ -4,7 +4,7 @@
|
||||
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
|
||||
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
||||
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
|
||||
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
||||
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
||||
let rcQuery = '', rcPage = 1;
|
||||
|
||||
async function viewRecipes(args = []) {
|
||||
@@ -13,6 +13,7 @@ async function viewRecipes(args = []) {
|
||||
try {
|
||||
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
||||
if (args[0] === 'shop') return await rcShop();
|
||||
if (args[0] === 'plan') return await rcPlan(args[1]);
|
||||
return await rcHome();
|
||||
} catch (e) {
|
||||
if (!location.hash.startsWith('#/recipes')) return;
|
||||
@@ -59,7 +60,7 @@ async function rcOne(slug) {
|
||||
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
||||
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
||||
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
||||
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
||||
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
||||
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
||||
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
||||
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
||||
@@ -77,6 +78,7 @@ async function rcOne(slug) {
|
||||
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
||||
paint();
|
||||
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
|
||||
$('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id);
|
||||
$('#rcadd').onclick = async e => {
|
||||
const b = e.currentTarget; b.disabled = true;
|
||||
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
|
||||
@@ -102,3 +104,55 @@ async function rcShop() {
|
||||
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
|
||||
await draw(await rcApi('/shopping'));
|
||||
}
|
||||
|
||||
// ---- Meal plan: a week at a time, shared by the family. Pick a recipe for a day, then add the week's ingredients to the shopping list in one tap.
|
||||
const rcYmd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const rcMonday = d => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x; };
|
||||
const RC_MEALS = [['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner']];
|
||||
async function rcPlan(start) {
|
||||
const mon = start && /^\d{4}-\d{2}-\d{2}$/.test(start) ? rcMonday(new Date(start + 'T12:00:00')) : rcMonday(new Date()), days = [...Array(7)].map((_, i) => { const d = new Date(mon); d.setDate(d.getDate() + i); return d; });
|
||||
const from = rcYmd(days[0]), to = rcYmd(days[6]), plan = await rcApi(`/plan?from=${from}&to=${to}`); if (!location.hash.startsWith('#/recipes')) return;
|
||||
const shift = n => { const d = new Date(mon); d.setDate(d.getDate() + n); return '#/recipes/plan/' + rcYmd(d); }, today = rcYmd(new Date()), count = Object.values(plan).reduce((n, l) => n + l.length, 0);
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${rcTabs('plan')}
|
||||
<div class="bar" style="justify-content:space-between;align-items:center"><a class="btn ghost" href="${shift(-7)}" aria-label="Previous week">${icon('back')}</a><b>${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</b><a class="btn ghost" href="${shift(7)}" aria-label="Next week">${icon('chev')}</a></div>
|
||||
<div class="list">${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal));
|
||||
return `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
|
||||
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
|
||||
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
|
||||
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button><button class="btn ghost" id="rcpantry">Pantry</button></div><div id="rcphave"></div></div>`;
|
||||
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
|
||||
document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } });
|
||||
$('#rcpantry').onclick = () => rcPantry(() => rcPlan(rcYmd(mon)));
|
||||
if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `<p class="sub" style="white-space:normal">You may already have ${esc(names.slice(0, 8).join(', '))}${names.length > 8 ? '…' : ''} (${r.items.length} ingredient${r.items.length === 1 ? '' : 's'} this week). They still go on the shopping list; tick them off there.</p>`; }).catch(() => {});
|
||||
$('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; };
|
||||
}
|
||||
/** Choose the day and meal for a recipe (from a recipe page), or the recipe for a day (from the plan). */
|
||||
function rcPlanPick(slug, name, rid, date) {
|
||||
const el = openSheet(slug ? 'Plan ' + name : 'Add a meal', '', `<form id="rcpf">${slug ? '' : '<input type="search" id="rcpq" placeholder="Search your recipes" autocomplete="off" aria-label="Search recipes"><div class="list" id="rcpr" style="margin:8px 0"></div>'}
|
||||
<label for="rcpd">Day</label><input type="date" id="rcpd" value="${esc(date || rcYmd(new Date()))}" required><label for="rcpm">Meal</label><select id="rcpm">${RC_MEALS.map(m => `<option value="${m[0]}"${m[0] === 'dinner' ? ' selected' : ''}>${m[1]}</option>`).join('')}</select><div class="err" id="rcpe"></div>
|
||||
<div class="bar">${slug ? '<button class="btn" style="flex:1;justify-content:center">Add to plan</button>' : ''}</div></form>`);
|
||||
const save = async (sl, nm, id) => { try { await rcApi('/plan/add', rcOpt('POST', { date: el.querySelector('#rcpd').value, slug: sl, name: nm, rid: id, meal: el.querySelector('#rcpm').value })); closeSheet(); toast('Added to the meal plan', { label: 'Open', run: () => { location.hash = '#/recipes/plan/' + el.querySelector('#rcpd').value; } }); if (location.hash.startsWith('#/recipes/plan')) rcPlan(el.querySelector('#rcpd').value); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||
el.querySelector('#rcpf').onsubmit = e => { e.preventDefault(); if (slug) save(slug, name, rid); };
|
||||
if (slug) return;
|
||||
let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
|
||||
}
|
||||
|
||||
/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */
|
||||
async function rcPantry(onClose) {
|
||||
let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); }
|
||||
const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', '<div id="pyl"></div>');
|
||||
const draw = () => {
|
||||
el.querySelector('#pyl').innerHTML = `<form id="pyf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pyn" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="pyq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="pyw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
|
||||
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}</small></span>
|
||||
${x.isLow ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen.</small></span></div>'}</div>
|
||||
<div class="bar"><button class="btn" id="pydone" style="flex:1;justify-content:center">Done</button></div>`;
|
||||
const go = async (p, o) => { try { items = (await rcApi(p, rcOpt('POST', o))).items; draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#pyf').onsubmit = e => { e.preventDefault(); go('/pantry/item', { name: el.querySelector('#pyn').value, qty: el.querySelector('#pyq').value, low: el.querySelector('#pyw').value }); };
|
||||
el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/pantry/adjust', { id, delta: +d }); });
|
||||
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/pantry/delete', { id: b.dataset.x }));
|
||||
el.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
|
||||
el.querySelector('#pydone').onclick = () => { closeSheet(); if (onClose) onClose(); };
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -3,6 +3,17 @@ const remSupported = () => 'serviceWorker' in navigator && 'PushManager' in wind
|
||||
const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); };
|
||||
const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
|
||||
/** Each time the app opens: if this device has a push subscription, tell the server again (it forgets a device when the push service says it is gone, and the time zone can change). */
|
||||
let pushResynced = false;
|
||||
async function pushResync() {
|
||||
if (pushResynced) return; pushResynced = true;
|
||||
try {
|
||||
if (!remSupported() || Notification.permission !== 'granted') return;
|
||||
const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]), s = reg && await reg.pushManager.getSubscription(); if (!s) return;
|
||||
await jfetch('/api/push/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON(), tz: myTz() }) });
|
||||
} catch { /* try again next time the app opens */ }
|
||||
}
|
||||
|
||||
function remindersHtml() {
|
||||
return `<div id="sec-rem"><h2>Reminders</h2><p class="sub">Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.</p>
|
||||
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
|
||||
|
||||
+9
-2
@@ -86,7 +86,7 @@ function viewSettings(args) {
|
||||
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2>${installHtml()}</div>
|
||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><div id="autoset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
|
||||
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
||||
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
||||
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
||||
@@ -111,7 +111,8 @@ function viewSettings(args) {
|
||||
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
|
||||
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
|
||||
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. To restore, choose a backup file below.</p>
|
||||
<div class="bar"><label class="btn ghost" for="bkfile" style="cursor:pointer">Restore from a backup…</label><input type="file" id="bkfile" accept=".gz,.tgz,application/gzip" hidden></div><p class="sub" id="bkmsg">Replaces the hub's accounts and data with the backup's. The current data is kept in a .pre-restore folder, and the hub restarts.</p>` : ''}
|
||||
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
||||
</div></div>`;
|
||||
remindersInit();
|
||||
@@ -153,6 +154,7 @@ function viewSettings(args) {
|
||||
const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min';
|
||||
$('#health').innerHTML =
|
||||
(h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false))
|
||||
+ (h.services || []).map(x => row(esc(x.name), x.ok ? 'Answering in ' + x.ms + ' ms' : esc(x.error || 'Not answering'), !x.ok)).join('')
|
||||
+ (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false))
|
||||
+ (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '')
|
||||
+ row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30)
|
||||
@@ -200,6 +202,11 @@ function viewSettings(args) {
|
||||
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
|
||||
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
||||
if (typeof famSettings === 'function') famSettings($('#famset'));
|
||||
if (typeof themeSettings === 'function') themeSettings($('#themeset'));
|
||||
if (typeof autoSettings === 'function') autoSettings($('#autoset'));
|
||||
const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => {
|
||||
const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…';
|
||||
try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); };
|
||||
$('#invpk').onclick = async () => {
|
||||
try {
|
||||
const { code, minutes } = await authPost('/api/auth/invite');
|
||||
|
||||
+41
-3
@@ -5,13 +5,45 @@ function openSheet(title, sub, bodyHtml) {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'sheet';
|
||||
el.innerHTML = `<div class="scrim"></div><div class="sheet" role="dialog" aria-modal="true" aria-label="${esc(title)}">
|
||||
<div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
|
||||
<button type="button" class="ibtn sheetx" aria-label="Close">${icon('x')}</button><div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('.scrim').onclick = closeSheet;
|
||||
el.querySelector('.scrim').onclick = dismissSheet; el.querySelector('.sheetx').onclick = dismissSheet;
|
||||
swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') });
|
||||
requestAnimationFrame(() => el.classList.add('on'));
|
||||
// keyboard: focus moves into the dialog (not into a field, so a phone's keyboard does not pop up), Tab stays inside it, and closing gives focus back to what opened it
|
||||
el._from = document.activeElement; const box = el.querySelector('.sheet'); box.tabIndex = -1; try { box.focus({ preventScroll: true }); } catch { /* old browser */ }
|
||||
el.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Tab') return; const f = [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]):not([type=hidden]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(x => x.offsetParent !== null || x === document.activeElement); if (!f.length) return;
|
||||
const first = f[0], last = f[f.length - 1]; if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
});
|
||||
return el;
|
||||
}
|
||||
function closeSheet() { document.getElementById('sheet')?.remove(); }
|
||||
const sheetGone = s => { const from = s && s._from; if (from && from.isConnected && from !== document.body && !document.getElementById('sheet')) { try { from.focus({ preventScroll: true }); } catch { /* it cannot take focus */ } } };
|
||||
function closeSheet() { const s = document.getElementById('sheet'); if (s) { s.remove(); sheetGone(s); } }
|
||||
/** Close with the slide-down (the close button, a tap outside); closeSheet() is the instant one other code calls before opening something else. */
|
||||
function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) { s.remove(); sheetGone(s); } }, 230); }
|
||||
/** Swipe down to dismiss: `el` follows the finger and `close` runs when it is let go far enough or fast enough. Touches starting in `handle` always count; elsewhere only when nothing under the finger is scrolled down, and never on sliders or text fields. On a phone-sized screen only, unless `always` (full-screen views). */
|
||||
function swipeToDismiss(el, close, { handle = '', scrim = null, always = false } = {}) {
|
||||
let y0 = 0, x0 = 0, t0 = 0, on = false, dead = true;
|
||||
const reset = () => { el.style.transition = 'transform .2s ease'; el.style.transform = ''; if (scrim) { scrim.style.transition = 'opacity .2s'; scrim.style.opacity = ''; } setTimeout(() => { el.style.transition = ''; if (scrim) scrim.style.transition = ''; }, 220); };
|
||||
el.addEventListener('touchstart', e => {
|
||||
on = false; dead = true; if (e.touches.length !== 1 || (!always && !matchMedia('(max-width: 767px)').matches)) return;
|
||||
const t = e.target; if (t.closest && t.closest('input, textarea, select, [contenteditable], [data-nodrag]')) return;
|
||||
if (!(handle && t.closest && t.closest(handle))) for (let n = t; n; n = n.parentElement) { if (n.scrollTop > 0) return; if (n === el) break; }
|
||||
y0 = e.touches[0].clientY; x0 = e.touches[0].clientX; t0 = Date.now(); dead = false;
|
||||
}, { passive: true });
|
||||
el.addEventListener('touchmove', e => {
|
||||
if (dead) return; const dy = e.touches[0].clientY - y0, dx = e.touches[0].clientX - x0;
|
||||
if (!on) { if (dy > 10 && dy > Math.abs(dx) * 1.5) { on = true; el.style.transition = 'none'; if (scrim) scrim.style.transition = 'none'; } else if (Math.abs(dx) > 10 || dy < -6) { dead = true; return; } else return; }
|
||||
if (e.cancelable) e.preventDefault();
|
||||
const d = Math.max(0, dy); el.style.transform = 'translateY(' + d + 'px)'; if (scrim) scrim.style.opacity = String(Math.max(0, 1 - d / 420));
|
||||
}, { passive: false });
|
||||
const end = e => {
|
||||
if (!on) return; on = false; dead = true; const dy = (e.changedTouches && e.changedTouches[0] ? e.changedTouches[0].clientY : y0) - y0, fast = dy / Math.max(1, Date.now() - t0) > 0.6;
|
||||
if (e.type !== 'touchcancel' && (dy > 110 || (fast && dy > 30))) { el.style.transition = 'transform .18s ease'; el.style.transform = 'translateY(105%)'; if (scrim) { scrim.style.transition = 'opacity .18s'; scrim.style.opacity = '0'; } setTimeout(close, 170); } else reset();
|
||||
};
|
||||
el.addEventListener('touchend', end); el.addEventListener('touchcancel', end);
|
||||
}
|
||||
addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
|
||||
|
||||
function actionSheet(title, sub, actions) {
|
||||
@@ -42,3 +74,9 @@ function confirmSheet(title, sub, okLabel, onOk) {
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#so').onclick = () => { closeSheet(); onOk(); };
|
||||
}
|
||||
|
||||
/** Save text as a file on this device (a Markdown export of notes and highlights, say). */
|
||||
function downloadText(name, text, type = 'text/markdown') {
|
||||
const a = document.createElement('a'), url = URL.createObjectURL(new Blob([text], { type: type + ';charset=utf-8' }));
|
||||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}
|
||||
@@ -68,3 +68,22 @@
|
||||
else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- iPhone keyboard: iOS shrinks only the visual viewport when the keyboard opens and pans the whole page to reveal the field, so the app used to slide up with it.
|
||||
// Pin the shell to what is really visible while typing and tuck the bottom bar away; chat and mail size themselves again on 'kbfit'.
|
||||
(function () {
|
||||
const vv = window.visualViewport; if (!vv || !matchMedia('(pointer: coarse)').matches) return;
|
||||
const root = document.documentElement; let raf = 0;
|
||||
const typing = () => { const a = document.activeElement; return !!a && (a.tagName === 'TEXTAREA' || a.isContentEditable || (a.tagName === 'INPUT' && !/^(checkbox|radio|button|submit|reset|range|file|color|image)$/.test(a.type))); };
|
||||
function apply() {
|
||||
raf = 0;
|
||||
const kb = typing() && window.innerHeight - vv.height > 120 && window.innerWidth < 768;
|
||||
document.body.classList.toggle('kb', kb);
|
||||
if (kb) { root.style.setProperty('--vvh', vv.height + 'px'); root.style.setProperty('--vvt', vv.offsetTop + 'px'); if (window.scrollY) window.scrollTo(0, 0); }
|
||||
else { root.style.removeProperty('--vvh'); root.style.removeProperty('--vvt'); }
|
||||
window.dispatchEvent(new Event('kbfit'));
|
||||
}
|
||||
const soon = () => { if (!raf) raf = requestAnimationFrame(apply); };
|
||||
vv.addEventListener('resize', soon); vv.addEventListener('scroll', soon);
|
||||
document.addEventListener('focusin', soon); document.addEventListener('focusout', () => setTimeout(soon, 80));
|
||||
})();
|
||||
+20
-6
@@ -8,7 +8,8 @@ const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2];
|
||||
const stSize = b => fmtSize(+b || 0);
|
||||
const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : '';
|
||||
const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
|
||||
let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all';
|
||||
let stItems = [], stInstalled = [], stSeen = {}, stAdmin = false, stFilter = 'all';
|
||||
const stUpdated = it => it.kind === 'extension' && stInstalled.includes(it.id) && stSeen[it.id] !== undefined && (it.updated || 0) > stSeen[it.id]; // a new version of an extension you have (one installed before this was tracked is not flagged)
|
||||
|
||||
/** The sections this person may open (each needs its own tab's permission, except My Store). */
|
||||
const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]);
|
||||
@@ -28,7 +29,7 @@ async function viewStore(args = []) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- My Store
|
||||
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; }
|
||||
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
|
||||
async function stMine(el) {
|
||||
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||
const draw = () => {
|
||||
@@ -154,15 +155,17 @@ async function stRunExt(el, id) {
|
||||
const it = stItems.find(x => x.id === id && x.kind === 'extension');
|
||||
if (!it) { el.innerHTML = '<p class="empty">That extension is not in the store any more.</p>'; return; }
|
||||
if (!stInstalled.includes(id)) { el.innerHTML = `<p class="empty">Install “${esc(it.title)}” from My Store first.</p>`; return; }
|
||||
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b></div>
|
||||
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}${it.version ? ` <small style="color:var(--muted)">v${esc(it.version)}</small>` : ''}</b><button class="btn ghost" id="stperm">Permissions</button></div>
|
||||
${stUpdated(it) ? `<div class="card" id="stupd" style="max-width:none;margin:0 0 8px"><b>Updated</b> ${esc(new Date(it.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }))}${it.version ? ' to version ' + esc(it.version) : ''}.${it.description ? ' ' + esc(it.description.split('\n')[0].slice(0, 160)) : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="stupdx">Got it</button></div></div>` : ''}
|
||||
<iframe id="stframe" title="${esc(it.title)}" sandbox="allow-scripts allow-forms allow-popups allow-modals" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>`;
|
||||
const frame = el.querySelector('#stframe'); let data = null, timer;
|
||||
if (stUpdated(it)) { stApi(`/ext/${id}/seen`, stOpt('POST', {})).then(r => { stSeen = r.seen || stSeen; }).catch(() => {}); const x = el.querySelector('#stupdx'); if (x) x.onclick = () => el.querySelector('#stupd').remove(); }
|
||||
// (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's)
|
||||
fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `<p class="err">${esc(e.message)}</p>`; });
|
||||
const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({})));
|
||||
const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); };
|
||||
// What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed.
|
||||
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar' };
|
||||
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar', 'notes.read': 'see your notes and lists', 'notes.write': 'add notes', 'recipes.read': 'search and read your recipes', 'music.read': 'see what music is playing' };
|
||||
let grants = null; const refused = new Set();
|
||||
const allowedTo = async perm => {
|
||||
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
|
||||
@@ -172,10 +175,16 @@ async function stRunExt(el, id) {
|
||||
if (!yes) { refused.add(perm); return false; }
|
||||
grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true;
|
||||
};
|
||||
// The permissions screen: everything this extension may reach, each one switched off or on (turning one off just means it is asked again next time it needs it)
|
||||
el.querySelector('#stperm').onclick = async () => {
|
||||
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
|
||||
const sh = openSheet('Permissions', it.title, `<p class="sub" style="margin-top:0">What “${esc(it.title)}” may reach in the hub. Switch one off and it has to ask again.</p><div class="list">${Object.entries(WHY).map(([k, w]) => `<label class="row" style="cursor:pointer"><span class="grow"><b>${esc(w[0].toUpperCase() + w.slice(1))}</b><small>${esc(k)}</small></span><input type="checkbox" data-p="${esc(k)}"${grants.includes(k) ? ' checked' : ''}></label>`).join('')}</div>`);
|
||||
sh.querySelectorAll('[data-p]').forEach(c => c.onchange = async () => { const next = c.checked ? [...new Set([...grants, c.dataset.p])] : grants.filter(x => x !== c.dataset.p); try { await stApi(`/ext/${id}/grants`, stOpt('POST', { grants: next })); grants = next; refused.delete(c.dataset.p); } catch (e) { c.checked = !c.checked; toast(e.message); } });
|
||||
};
|
||||
const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
|
||||
const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : '';
|
||||
const hubCall = async (method, a = {}) => {
|
||||
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method];
|
||||
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write', 'notes.list': 'notes.read', 'notes.add': 'notes.write', 'recipes.search': 'recipes.read', 'recipes.get': 'recipes.read', 'music.nowPlaying': 'music.read' }[method];
|
||||
if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {};
|
||||
if (!(await allowedTo(need))) throw new Error('The person did not allow that');
|
||||
const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; };
|
||||
@@ -185,6 +194,11 @@ async function stRunExt(el, id) {
|
||||
if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; }
|
||||
if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday }));
|
||||
if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; }
|
||||
if (method === 'notes.list') return (await jf('/api/notes/')).notes.slice(0, 200).map(n => ({ id: n.id, type: n.type, title: n.title, body: n.type === 'note' ? txt(n.body, 5000) : '', items: n.type === 'list' ? (n.items || []).map(i => ({ text: i.text, done: !!i.done })) : [] }));
|
||||
if (method === 'notes.add') { if (!txt(a.title, 120) && !txt(a.body, 10)) throw new Error('Give the note a title or some text'); await jf('/api/notes/', pj('POST', { type: 'note', title: txt(a.title, 120), body: txt(a.body, 20000), share: 'private' })); return { ok: true }; }
|
||||
if (method === 'recipes.search') return (await jf('/api/recipes/?page=1&q=' + encodeURIComponent(txt(a.q, 100)))).items.slice(0, 24).map(r => ({ slug: r.slug, name: r.name, minutes: r.minutes || 0 }));
|
||||
if (method === 'recipes.get') { const r = await jf('/api/recipes/r/' + encodeURIComponent(txt(a.slug, 200))); return { name: r.name, description: txt(r.description, 1000), servings: r.servings || '', ingredients: r.ingredients.map(i => i.text || i.title), steps: r.steps.map(x => x.text) }; }
|
||||
if (method === 'music.nowPlaying') { const t = typeof cur === 'function' && cur(); return t ? { title: t.title || '', artist: t.artist || t.sub || '', album: t.album || '', playing: !pl.a.paused } : null; }
|
||||
if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t });
|
||||
return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); }
|
||||
if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
||||
@@ -202,7 +216,7 @@ async function stRunExt(el, id) {
|
||||
else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; }
|
||||
else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; }
|
||||
else if (method === 'user') result = { name: ME ? ME.name : '' };
|
||||
else if (method.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]);
|
||||
else if (/^(tasks|contacts|calendar|notes|recipes|music)\./.test(method)) result = await hubCall(method, args[0]);
|
||||
else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; }
|
||||
else throw new Error('Unknown request');
|
||||
} catch (x) { error = x.message; }
|
||||
|
||||
+28
-1
@@ -123,6 +123,8 @@ label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px
|
||||
padding: 8px 16px calc(var(--sab) + 16px); transform: translateY(100%); transition: transform .22s ease; max-height: 85dvh; overflow-y: auto; }
|
||||
#sheet.on .scrim { opacity: 1; }
|
||||
#sheet.on .sheet { transform: none; }
|
||||
#sheet .sheetx { position: sticky; top: 0; float: right; z-index: 2; width: 36px; height: 36px; margin: 0 -6px 0 8px; background: var(--panel2); border-radius: 50%; } #sheet .sheetx svg { width: 18px; height: 18px; }
|
||||
#sheet .sheet { overscroll-behavior: contain; }
|
||||
#sheet .grip { width: 40px; height: 4px; border-radius: 2px; background: var(--line); margin: 4px auto 12px; }
|
||||
#sheet h3 { margin: 0 0 4px; font-size: 18px; word-break: break-all; }
|
||||
#sheet .list { background: var(--panel2); border: 0; margin-top: 8px; }
|
||||
@@ -264,6 +266,14 @@ body { -webkit-touch-callout: none; }
|
||||
button, a, .row { -webkit-user-select: none; user-select: none; }
|
||||
input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* iPhone keyboard (shell.js adds body.kb while a field is focused and the keyboard is up): the shell is exactly the visible area, with no bottom bar in the way */
|
||||
@media (max-width: 767px) {
|
||||
body.kb { overflow: hidden; }
|
||||
body.kb #shell { height: var(--vvh, 100dvh); transform: translateY(var(--vvt, 0px)); }
|
||||
body.kb #nav, body.kb #player, body.kb #upd, body.kb #toast { display: none; }
|
||||
body.kb .sgm { min-height: 0; }
|
||||
}
|
||||
|
||||
/* "A new version is available" notice */
|
||||
#upd { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--nav-h) + var(--sab) + 70px); z-index: 30; display: flex; align-items: center; gap: 12px;
|
||||
background: var(--text); color: var(--bg); padding: 8px 8px 8px 16px; border-radius: 14px; box-shadow: 0 6px 24px rgba(0,0,0,.35); max-width: 92vw; }
|
||||
@@ -763,7 +773,7 @@ body.nav-mini .ngrp span { display: none; }
|
||||
.vdeckc { position: relative; flex: none; }
|
||||
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
|
||||
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
|
||||
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; }
|
||||
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { width: auto; flex: 0 1 auto; min-height: 40px; max-width: 48vw; }
|
||||
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
|
||||
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
|
||||
.vdeckc .vcard.wide { width: 250px; }
|
||||
@@ -1015,6 +1025,9 @@ body.vizopen { overflow: hidden; }
|
||||
.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
|
||||
.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
|
||||
.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
|
||||
.sgshare { display: flex; align-items: center; gap: 10px; margin: 2px 0 4px; padding: 8px; min-width: 220px; border-radius: 12px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgshare.off { opacity: .75; }
|
||||
.sgshare-art { flex: none; width: 56px; height: 56px; border-radius: 8px; object-fit: cover; background: rgba(127,127,127,.25); display: grid; place-items: center; font-size: 26px; font-style: normal; }
|
||||
.sgshare-t { min-width: 0; flex: 1; } .sgshare-t small, .sgshare-t b, .sgshare-t em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: normal; } .sgshare-t small { opacity: .7; } .sgshare-t em { opacity: .8; font-size: .9em; } .sgshare-t .sgshare-off { opacity: 1; color: var(--danger); font-weight: 600; margin-top: 2px; } .sgshare-go { font-size: 22px; opacity: .6; }
|
||||
.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
|
||||
.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
|
||||
.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
|
||||
@@ -1062,6 +1075,9 @@ body.vizopen { overflow: hidden; }
|
||||
#devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; }
|
||||
.crtick.goal { width: 4px; background: var(--accent); opacity: 1; }
|
||||
.crgoal { display: flex; align-items: center; gap: 12px; margin: 8px 0 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); } .crgoal .crgi { font-size: 26px; } .crgoal .grow { min-width: 0; } .crgoal b, .crgoal small { display: block; } .crgoal small { color: var(--muted); }
|
||||
.crgoalbtn { margin: 4px 0 0 52px; min-height: 36px; font-size: 14px; }
|
||||
.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; }
|
||||
|
||||
#kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px + var(--playerh, 0px)); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; }
|
||||
@@ -1079,3 +1095,14 @@ body.simple #shell { flex-direction: column; }
|
||||
body.simple #view { padding-bottom: 110px; }
|
||||
/* while a sheet, the lyrics or the visualizer covers the screen, the floating Home and Ask buttons step aside so they never sit on its controls */
|
||||
body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simplehome, body:has(#lyr) #kidreq, body.vizopen #simplehome, body.vizopen #kidreq { display: none !important; }
|
||||
|
||||
.swatches { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0; } .swatches button { width: 40px; height: 40px; min-height: 0; padding: 0; border-radius: 50%; border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--line); } .swatches button.on { box-shadow: 0 0 0 3px var(--text); }
|
||||
.dvverse { display: block; max-width: none; } .dvverse p { font-family: Georgia, 'Times New Roman', serif; font-size: 19px; line-height: 1.5; margin: 0 0 8px; } .dvverse small { color: var(--muted); } .dvread { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
.lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); }
|
||||
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
|
||||
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
|
||||
.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; }
|
||||
.vnrow { gap: 10px; } .vnart { width: 44px; flex: none; aspect-ratio: 2/3; border-radius: 6px; overflow: hidden; background: var(--panel2); display: grid; place-items: center; } .vnart img { width: 100%; height: 100%; object-fit: cover; } .vnvote { min-height: 36px; padding: 0 12px; flex: none; }
|
||||
html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
|
||||
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
|
||||
+39
-2
@@ -1,5 +1,6 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v86';
|
||||
const CACHE = 'hub-v122';
|
||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
@@ -19,7 +20,7 @@ async function precache() {
|
||||
}
|
||||
self.addEventListener('install', e => { e.waitUntil(precache()); self.skipWaiting(); });
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
|
||||
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE && !KEEP.test(k)).map(k => caches.delete(k))))); // (the app shell is replaced on a new version; saved episodes, shares and each person's saved data stay)
|
||||
self.clients.claim();
|
||||
});
|
||||
// Podcast episodes saved on this device (see offline.js): answer from them, including the byte ranges an audio player asks for; otherwise use the network as normal.
|
||||
@@ -32,6 +33,32 @@ async function offlineAudio(req) {
|
||||
if (a > b || a >= size) return new Response(null, { status: 416, headers: { 'content-range': 'bytes */' + size } });
|
||||
return new Response(blob.slice(a, b + 1), { status: 206, headers: { 'content-type': type, 'content-length': b - a + 1, 'content-range': `bytes ${a}-${b}/${size}`, 'accept-ranges': 'bytes' } });
|
||||
}
|
||||
// Saved data for offline use: the lists a person is likely to want with no signal (tasks, notes, recipes, calendar, contacts, chores). Network first; when the network fails the last
|
||||
// copy is used. Kept per person (the page tells us who is signed in), so a shared tablet never shows one person's data to another. Reading only: changes need a connection.
|
||||
const DATA_OK = /^\/api\/(read\/(list|book\/\w+\/notes)|tasks(\/(upcoming|summary|lists|due))?|notes|recipes(\/(r\/[\w-]+|shopping))?|cal\/(events|list)|contacts|chores(\/\w+)?|bible\/(state))\/?$/;
|
||||
const whoNow = async () => { try { const r = await (await caches.open('hub-who')).match('/who'); return r ? await r.text() : ''; } catch { return ''; } };
|
||||
self.addEventListener('message', e => {
|
||||
const d = e.data || {}; if (d.type !== 'who') return;
|
||||
const id = String(d.id || '').replace(/[^\w-]/g, '');
|
||||
e.waitUntil((async () => { await (await caches.open('hub-who')).put('/who', new Response(id)); for (const k of await caches.keys()) if (k.startsWith('hub-data-') && k !== 'hub-data-' + id) await caches.delete(k); })());
|
||||
});
|
||||
async function dataFirstNetwork(req) {
|
||||
const who = await whoNow();
|
||||
try {
|
||||
const r = await fetch(req);
|
||||
if (r.ok && who) { const copy = r.clone(); caches.open('hub-data-' + who).then(c => c.put(req.url, copy)); }
|
||||
return r;
|
||||
} catch (err) {
|
||||
const hit = who && await (await caches.open('hub-data-' + who)).match(req.url);
|
||||
if (hit) return hit;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// A book saved on this device (see reader.js): answered from the saved copy whatever the address's query, otherwise the network as normal.
|
||||
async function offlineBook(req) {
|
||||
const hit = await (await caches.open('hub-offline')).match(req.url, { ignoreSearch: true });
|
||||
return hit || fetch(req);
|
||||
}
|
||||
// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
|
||||
async function takeShare(request) {
|
||||
try {
|
||||
@@ -46,7 +73,9 @@ self.addEventListener('fetch', e => {
|
||||
const u = new URL(e.request.url);
|
||||
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
|
||||
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request));
|
||||
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/read\/book\/\w+\/(file|cover)$/.test(u.pathname)) return e.respondWith(offlineBook(e.request)); // (books saved on this device)
|
||||
if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device)
|
||||
if (e.request.method === 'GET' && u.origin === location.origin && DATA_OK.test(u.pathname)) return e.respondWith(dataFirstNetwork(e.request));
|
||||
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
|
||||
e.respondWith(fetch(e.request).then(r => {
|
||||
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
|
||||
@@ -59,6 +88,14 @@ self.addEventListener('push', e => {
|
||||
let d = {}; try { d = e.data.json(); } catch {}
|
||||
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
|
||||
});
|
||||
// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving.
|
||||
self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
|
||||
try {
|
||||
const k = await (await fetch('/api/push/key', { credentials: 'same-origin' })).json();
|
||||
const s = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: k.publicKey });
|
||||
await fetch('/api/push/subscribe', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON() }) });
|
||||
} catch { /* signed out or offline: the app re-registers when it is next opened */ }
|
||||
})()));
|
||||
self.addEventListener('notificationclick', e => {
|
||||
e.notification.close();
|
||||
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
|
||||
|
||||
+20
-18
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes
|
||||
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
|
||||
const dueInfo = (d, tm) => { if (!d) return null; const at = tm ? ' · ' + new Date('2000-01-01T' + tm).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return d < t ? { text: 'Overdue · ' + label + at, color: 'var(--danger)' } : d === t ? { text: 'Today' + at, color: 'var(--accent)' } : { text: label + at, color: 'var(--muted)' }; };
|
||||
const taskStore = []; // tasks on screen, so a tap can open the right one
|
||||
let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" opens with its own list's options
|
||||
|
||||
@@ -13,11 +13,11 @@ async function viewTasks() {
|
||||
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
|
||||
if (!location.hash.startsWith('#/tasks')) return;
|
||||
taskLists = lists;
|
||||
let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
|
||||
const list = cur === 'all' ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
|
||||
let cur = store.get('task_list'); if (!['all', 'today', 'upcoming'].includes(cur) && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
|
||||
const list = ['all', 'today', 'upcoming'].includes(cur) ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
|
||||
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
|
||||
<div class="seg">${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
||||
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${cur === 'all' ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
||||
<div class="seg"><a href="#" data-l="today" class="${cur === 'today' ? 'on' : ''}">Today</a><a href="#" data-l="upcoming" class="${cur === 'upcoming' ? 'on' : ''}">Upcoming</a>${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
||||
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${['all', 'today', 'upcoming'].includes(cur) ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
||||
<div id="tl"><p class="empty">Loading…</p></div>
|
||||
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
||||
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
|
||||
@@ -26,7 +26,7 @@ async function viewTasks() {
|
||||
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
|
||||
$('#qa').onsubmit = async e => {
|
||||
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: cur === 'today' ? ymd(new Date()) : '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
|
||||
catch (err) { toast(err.message); }
|
||||
};
|
||||
loadTaskList(cur, list);
|
||||
@@ -36,27 +36,28 @@ async function viewTasks() {
|
||||
async function loadTaskList(cur, list) {
|
||||
const box = $('#tl'); if (!box) return;
|
||||
try {
|
||||
const all = cur === 'all', byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
|
||||
const tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
|
||||
const smart = cur === 'today' || cur === 'upcoming', all = cur === 'all' || smart, today = ymd(new Date()), byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
|
||||
let tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
|
||||
.flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
|
||||
if (!$('#tl')) return;
|
||||
if (typeof offApplyTasks === 'function') tasks = offApplyTasks(tasks); // (changes made offline and not sent yet)
|
||||
taskStore.length = 0;
|
||||
const ids = new Set(tasks.map(t => t.id)), byParent = {};
|
||||
tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); });
|
||||
const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title);
|
||||
const open = tasks.filter(t => top(t) && !t.done).sort(byDue);
|
||||
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
|
||||
const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || (a.dueTime || '').localeCompare(b.dueTime || '') || a.title.localeCompare(b.title);
|
||||
const open = tasks.filter(t => top(t) && !t.done && (cur === 'today' ? t.due && t.due <= today : cur === 'upcoming' ? t.due && t.due > today : true)).sort(byDue);
|
||||
const done = smart ? [] : tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
|
||||
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
|
||||
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
|
||||
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due, t.dueTime), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
|
||||
const extra = [all && t._ln ? t._ln : '', t.by && t.list === 'local:family' ? 'Added by ' + t.by : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
|
||||
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
|
||||
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
|
||||
const showDone = store.get('task_show_done', false);
|
||||
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : '<p class="empty">Nothing to do. Add a task above.</p>')
|
||||
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : (cur === 'today' ? '<p class="empty">Nothing due today. Enjoy the day.</p>' : cur === 'upcoming' ? '<p class="empty">Nothing coming up with a due date.</p>' : '<p class="empty">Nothing to do. Add a task above.</p>'))
|
||||
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : ''}` : '');
|
||||
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
|
||||
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
|
||||
const t = taskStore[+b.dataset.t]; b.disabled = true;
|
||||
const t = taskStore[+b.dataset.t]; if (String(t.id).startsWith('queued:')) return toast('Waiting to send; it can be ticked once it has been sent.'); b.disabled = true;
|
||||
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: t.list, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
|
||||
catch (err) { b.disabled = false; toast(err.message); }
|
||||
});
|
||||
@@ -68,18 +69,19 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
|
||||
const redraw = () => loadTaskList(viewCur || cur, viewList || list);
|
||||
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
|
||||
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
|
||||
${list.local ? `<label for="tdt">Remind me at</label><input type="time" id="tdt" value="${esc(t.dueTime || '')}"><p class="sub" style="margin:4px 0 0">Optional. Needs a due date; you get a notification at that time.</p>` : ''}
|
||||
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
|
||||
${list.local ? `<label for="trep">Repeat</label><select id="trep">${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `<option value="${v}" ${(t.repeat || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<p class="sub" style="margin:4px 0 0">A repeating task moves to its next date when you complete it.</p>` : ''}
|
||||
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
|
||||
${list.local && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
|
||||
${(list.local || list.icloud) && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
|
||||
<div style="display:flex;gap:8px;margin-top:6px"><input type="text" id="tsub" placeholder="Add a subtask" autocomplete="off" style="flex:1"><button type="button" class="btn ghost" id="tsubadd">Add</button></div>` : ''}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div>
|
||||
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; const tt = el.querySelector('#tdt'); if (tt) tt.value = ''; };
|
||||
el.querySelector('#tf').onsubmit = async e => {
|
||||
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, ...(el.querySelector('#tdt') ? { dueTime: el.querySelector('#tdt').value } : {}), done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
|
||||
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
|
||||
};
|
||||
if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
// ---- Themes: each person picks an accent colour and Auto, Light, Dark or AMOLED black (true black, easy on phone batteries). Kept in their synced settings, so it follows them between devices.
|
||||
const THEME_ACCENTS = [['blue', 'Blue', '#3d6bf0', '#6a8cff'], ['teal', 'Teal', '#0f8f8a', '#3cc7c0'], ['green', 'Green', '#2f9e6e', '#4cc98d'], ['orange', 'Orange', '#d9741a', '#f09a45'], ['red', 'Red', '#d4392f', '#ff6b60'], ['pink', 'Pink', '#c9407d', '#f26aa4'], ['purple', 'Purple', '#7b4fe0', '#a688ff']];
|
||||
const THEME_PERSON = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d'];
|
||||
const THEME_MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark'], ['amoled', 'AMOLED']];
|
||||
const THEME_VARS = {
|
||||
light: { '--bg': '#f5f6f8', '--panel': '#fff', '--panel2': '#eef0f4', '--text': '#14171c', '--muted': '#6a7280', '--line': '#e0e3e9' },
|
||||
dark: { '--bg': '#0f1115', '--panel': '#181b21', '--panel2': '#20242c', '--text': '#eceef2', '--muted': '#8b93a3', '--line': '#272c35' },
|
||||
amoled: { '--bg': '#000', '--panel': '#0b0b0d', '--panel2': '#16171b', '--text': '#eceef2', '--muted': '#8b93a3', '--line': '#1f2127' },
|
||||
};
|
||||
function themeApply() {
|
||||
const root = document.documentElement, mode = store.get('theme_mode', 'auto'), acc = THEME_ACCENTS.find(a => a[0] === store.get('theme_accent', 'blue')) || THEME_ACCENTS[0];
|
||||
const dark = mode === 'dark' || mode === 'amoled' || (mode === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
for (const k of Object.keys(THEME_VARS.light)) { if (mode === 'auto') root.style.removeProperty(k); else root.style.setProperty(k, THEME_VARS[mode][k]); }
|
||||
const hex = dark ? acc[3] : acc[2];
|
||||
if (acc[0] === 'blue' && mode === 'auto') { root.style.removeProperty('--accent'); root.style.removeProperty('--accent-t'); }
|
||||
else { const n = parseInt(hex.slice(1), 16), lum = (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; root.style.setProperty('--accent', hex); root.style.setProperty('--accent-t', lum > .6 ? '#0b1020' : '#fff'); }
|
||||
const hc = !!store.get('a11y_contrast', false), size = +store.get('a11y_text', 1) || 1;
|
||||
const HC = dark ? { '--text': '#ffffff', '--muted': '#d3d8e2', '--line': '#7a8294' } : { '--text': '#000000', '--muted': '#33383f', '--line': '#6c7482' };
|
||||
for (const k of Object.keys(HC)) { if (hc) root.style.setProperty(k, HC[k]); else if (mode === 'auto') root.style.removeProperty(k); else root.style.setProperty(k, THEME_VARS[mode][k]); }
|
||||
root.classList.toggle('hc', hc); root.classList.toggle('rm', !!store.get('a11y_motion', false));
|
||||
const view = document.getElementById('view'); if (view) view.style.zoom = size === 1 ? '' : String(size); // larger text and controls for the page itself
|
||||
root.style.colorScheme = mode === 'auto' ? '' : dark ? 'dark' : 'light';
|
||||
const m = document.querySelector('meta[name=theme-color]'); if (m) m.content = dark ? (mode === 'amoled' ? '#000000' : '#0f1115') : (mode === 'light' ? '#f5f6f8' : m.content);
|
||||
}
|
||||
function themeStart() { themeApply(); try { matchMedia('(prefers-color-scheme: dark)').addEventListener('change', themeApply); } catch {} }
|
||||
function themeSettings(el) {
|
||||
if (!el) return;
|
||||
const draw = () => {
|
||||
const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue');
|
||||
el.innerHTML = `<h2>Look and reading</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
|
||||
<div class="seg" role="radiogroup" aria-label="Brightness">${THEME_MODES.map(([k, l]) => `<a href="#" role="radio" aria-checked="${mode === k}" data-m="${k}" class="${mode === k ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>
|
||||
<h3 class="sub" style="margin:14px 0 0">Reading</h3>
|
||||
<div class="seg" role="radiogroup" aria-label="Text size">${[[1, 'Normal'], [1.15, 'Large'], [1.3, 'Extra large']].map(([v, l]) => `<a href="#" role="radio" aria-checked="${+store.get('a11y_text', 1) === v}" data-ts="${v}" class="${+store.get('a11y_text', 1) === v ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>High contrast</b><small>Stronger text and borders</small></span><input type="checkbox" id="a11yhc"${store.get('a11y_contrast', false) ? ' checked' : ''}></label>
|
||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Reduce motion</b><small>Fewer animations, whatever the device says</small></span><input type="checkbox" id="a11yrm"${store.get('a11y_motion', false) ? ' checked' : ''}></label></div>
|
||||
<h3 class="sub" style="margin:14px 0 0">My colour on shared calendars</h3><div class="swatches" role="radiogroup" aria-label="My colour on shared calendars">${(typeof ME !== 'undefined' && ME ? THEME_PERSON : []).map(c => `<button type="button" role="radio" aria-checked="${ME.color === c}" aria-label="${c}" data-pc="${c}" class="${ME.color === c ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
|
||||
el.querySelectorAll('[data-ts]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('a11y_text', +a.dataset.ts); themeApply(); draw(); });
|
||||
el.querySelector('#a11yhc').onchange = e => { store.set('a11y_contrast', e.target.checked); themeApply(); };
|
||||
el.querySelector('#a11yrm').onchange = e => { store.set('a11y_motion', e.target.checked); themeApply(); };
|
||||
el.querySelectorAll('[data-pc]').forEach(b => b.onclick = async () => { try { const r = await jfetch('/api/me/color', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ color: b.dataset.pc }) }); ME.color = r.color; try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} draw(); } catch (e) { toast(e.message); } });
|
||||
el.querySelectorAll('[data-m]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('theme_mode', a.dataset.m); themeApply(); draw(); });
|
||||
el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { store.set('theme_accent', b.dataset.a); themeApply(); draw(); });
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -28,3 +28,21 @@ async function homeResume(el) {
|
||||
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
||||
}
|
||||
const today = { books: [] };
|
||||
|
||||
// Home: how much you listened this week (podcasts and audiobooks), the day streak, and a yearly goal for audiobooks finished. The goal is kept in your synced settings.
|
||||
const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : ''); };
|
||||
async function homeListening(after) {
|
||||
if (!after || !(allowed('podcast') || allowed('audio'))) return;
|
||||
let s; try { s = await jfetch('/api/listening/stats'); } catch { return; }
|
||||
const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected) return;
|
||||
const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening';
|
||||
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>books finished this year</small></span></div>
|
||||
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
|
||||
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}
|
||||
<div id="lsfam" class="lsfam"></div><div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
|
||||
after.insertAdjacentElement('afterend', card);
|
||||
jfetch('/api/listening/family').then(f => { const box = card.querySelector('#lsfam'); if (!box) return; if (!f.goal && !f.finished) { box.innerHTML = `<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">Set a family reading challenge</button>`; } else box.innerHTML = `<div class="lsrow" style="margin-top:12px"><span><b>${f.finished}${f.goal ? ' of ' + f.goal : ''}</b><small>family books finished in ${f.year}${f.people.length ? ' · ' + esc(f.people.map(p => p.name + ' ' + p.n).join(', ')) : ''}</small></span></div>${f.goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(f.finished / f.goal * 100))}%"></i></div>` : ''}<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">${f.goal ? 'Change the family goal' : 'Set a family goal'}</button>`;
|
||||
const b = box.querySelector('#lsfg'); if (b) b.onclick = () => { const el = openSheet('Family reading challenge', 'One goal for everyone: how many books the family finishes this year (audiobooks and e-books together).', `<form id="lsff"><label for="lsfn">Books</label><input type="number" id="lsfn" min="0" max="1000" value="${f.goal || ''}" placeholder="40" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsff').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/listening/family', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ goal: +el.querySelector('#lsfn').value || 0 }) }); closeSheet(); card.remove(); homeListening(after); } catch (er) { toast(er.message); } }; };
|
||||
}).catch(() => {});
|
||||
card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; };
|
||||
}
|
||||
+91
-13
@@ -73,6 +73,42 @@ async function vdRemove(m, quiet) {
|
||||
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
|
||||
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
|
||||
}
|
||||
// ---- Watch stats: what you watched lately, counted from the "last watched" dates Plex keeps for you
|
||||
/** items: Plex movies or episodes with lastViewedAt (seconds) and duration (ms). Counts and minutes for the last 7, 30 and 365 days, a 14-day strip, and the top genres. */
|
||||
function vdAgg(items, now = Date.now()) {
|
||||
const day = 864e5, out = { week: { n: 0, min: 0 }, month: { n: 0, min: 0 }, year: { n: 0, min: 0 }, days: Array(14).fill(0), genres: [] }, g = {};
|
||||
for (const m of items) {
|
||||
const t = (m.lastViewedAt || 0) * 1000; if (!t || t > now + day) continue; const age = now - t, mins = Math.round((m.duration || 0) / 60000), add = b => { b.n++; b.min += mins; };
|
||||
if (age < 7 * day) add(out.week); if (age < 30 * day) add(out.month); if (age < 365 * day) { add(out.year); for (const x of m.Genre || []) g[x.tag] = (g[x.tag] || 0) + 1; }
|
||||
const di = Math.floor(age / day); if (di >= 0 && di < 14) out.days[13 - di]++;
|
||||
}
|
||||
out.genres = Object.entries(g).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5); return out;
|
||||
}
|
||||
async function vdStatsSheet(cur) {
|
||||
const isMovie = cur.type === 'movie', el = openSheet('Your watching', cur.title, '<p class="empty">Counting…</p>');
|
||||
try {
|
||||
const r = await px.get(`/library/sections/${cur.key}/all?type=${isMovie ? 1 : 4}&sort=lastViewedAt:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=600`), a = vdAgg((r.Metadata || []).filter(m => m.lastViewedAt)), hrs = m => m < 60 ? m + ' min' : (m / 60).toFixed(m < 600 ? 1 : 0) + ' h', max = Math.max(1, ...a.days), what = isMovie ? 'movies' : 'episodes';
|
||||
el.querySelector('.empty').outerHTML = `<div class="lsrow">${[['week', 'this week'], ['month', 'last 30 days'], ['year', 'this year']].map(([k, l]) => `<span><b>${a[k].n}</b><small>${what} ${l}${a[k].min ? ' · ' + hrs(a[k].min) : ''}</small></span>`).join('')}</div>
|
||||
<div class="lsbars" role="img" aria-label="${what} watched each of the last 14 days">${a.days.map((n, i) => `<i style="height:${Math.max(3, Math.round(n / max * 100))}%" title="${n} ${what}"></i>`).join('')}</div>
|
||||
${a.genres.length ? `<h3 class="sub" style="margin:12px 0 4px">Favourite genres this year</h3><div class="seg" style="flex-wrap:wrap">${a.genres.map(([t, n]) => `<a href="#" style="pointer-events:none">${esc(t)} · ${n}</a>`).join('')}</div>` : ''}
|
||||
${a.year.n ? '' : '<p class="empty">Nothing watched in this library yet.</p>'}<p class="sub" style="margin-top:12px">Counts each title once, on the day you last watched it.</p>`;
|
||||
} catch (e) { el.querySelector('.empty').textContent = e.message; }
|
||||
}
|
||||
// ---- Favorites (yours) and Movie night (the family's list to vote on)
|
||||
const vfApi = (p, o) => jfetch('/api/moviefam' + p, o), vfOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const vfCard = x => ({ ratingKey: x.id, type: x.type, title: x.title, year: x.year || undefined, thumb: x.thumb || undefined });
|
||||
function vdFamilyRows() {
|
||||
const drawNight = items => {
|
||||
const el = $('#vnight'); if (!el) return;
|
||||
el.innerHTML = items.length ? `<h2>Movie night</h2><div class="list">${items.map((x, i) => `<div class="row vnrow"><a class="vnart" href="#/video/item/${esc(x.id)}">${x.thumb ? `<img loading="lazy" alt="" src="${vdArt(x.thumb, 90, 135)}" onerror="this.remove()">` : icon('film')}</a>
|
||||
<a class="grow" href="#/video/item/${esc(x.id)}" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc([x.year || '', 'added by ' + x.byName].filter(Boolean).join(' · '))}${x.voters.length ? ' · ' + esc(x.voters.join(', ')) : ''}</small></a>
|
||||
<button class="btn${x.voted ? '' : ' ghost'} vnvote" data-v="${esc(x.id)}" aria-pressed="${x.voted}" aria-label="${x.voted ? 'Take back your vote for ' : 'Vote for '}${esc(x.title)}">${i === 0 && x.votes > 1 ? '★ ' : ''}${x.votes} ${x.votes === 1 ? 'vote' : 'votes'}</button><button class="ibtn" data-r="${esc(x.id)}" aria-label="Remove ${esc(x.title)}" title="Remove">${icon('x')}</button></div>`).join('')}</div>` : '';
|
||||
el.querySelectorAll('[data-v]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/vote', vfOpt({ id: b.dataset.v }))).items); } catch (e) { toast(e.message); } });
|
||||
el.querySelectorAll('[data-r]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/remove', vfOpt({ id: b.dataset.r }))).items); } catch (e) { toast(e.message); } });
|
||||
};
|
||||
vfApi('/night').then(r => drawNight(r.items)).catch(() => {});
|
||||
vfApi('/favs').then(r => { const el = $('#vfavr'); if (el) el.innerHTML = r.items.length ? `<h2>Favorites</h2><div class="vrow">${r.items.slice(0, 30).map(x => vdCard(vfCard(x), false)).join('')}</div>` : ''; }).catch(() => {});
|
||||
}
|
||||
async function videoHome(st, libKey) {
|
||||
const v = $('#view');
|
||||
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
|
||||
@@ -81,8 +117,8 @@ async function videoHome(st, libKey) {
|
||||
store.set('video_lib', cur.key);
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
||||
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
||||
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||||
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
||||
<div id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||||
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
||||
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
||||
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
||||
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
||||
@@ -97,31 +133,40 @@ async function videoHome(st, libKey) {
|
||||
}).catch(() => {});
|
||||
const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
|
||||
const type = cur.type === 'movie' ? 1 : 2, size = 120;
|
||||
// Mixes: a handful of ways to pick something to watch, from what you have not seen yet
|
||||
{ const MIX = [['shuffle', 'Surprise me', 'sort=random', x => true], ['family', 'Family night', 'sort=random&contentRating=' + vdEnc('G,PG,TV-Y,TV-G,TV-PG'), x => true], ...(type === 1 ? [['short', 'Under 95 minutes', 'sort=random', x => x.duration && x.duration <= 95 * 60000]] : []), ['classic', 'Classics', 'sort=random', x => x.year && x.year <= 1979]];
|
||||
$('#vmix').innerHTML = MIX.map(m => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('') + '<a href="#" id="vstats">Your stats</a>';
|
||||
$('#vstats').onclick = e => { e.preventDefault(); vdStatsSheet(cur); };
|
||||
$('#vmix').querySelectorAll('[data-mix]').forEach(a => a.onclick = async e => { e.preventDefault(); const m = MIX.find(x => x[0] === a.dataset.mix), el = openSheet(m[1], 'Unwatched, picked at random. Tap the same mix again for a new set.', '<p class="empty">Looking…</p>');
|
||||
try { const r = await px.get(`/library/sections/${cur.key}/all?type=${type}&unwatched=1&${m[2]}&X-Plex-Container-Start=0&X-Plex-Container-Size=${m[0] === 'shuffle' || m[0] === 'family' ? 24 : 200}`), items = (r.Metadata || []).filter(m[3]).slice(0, 24);
|
||||
el.querySelector('.empty').outerHTML = items.length ? `<div class="vgrid">${items.map(x => vdCard(x, false)).join('')}</div>` : '<p class="empty">Nothing matches. Try another mix.</p>'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet()));
|
||||
} catch (er) { el.querySelector('.empty').textContent = er.message; } }); }
|
||||
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
|
||||
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
|
||||
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
|
||||
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
|
||||
}).catch(() => {});
|
||||
}
|
||||
vdFamilyRows();
|
||||
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||
// sort and filter the library below
|
||||
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
|
||||
$('#vsort').value = fs; $('#vunwc').checked = fu;
|
||||
$('#vsort').value = fs; $('#vwatch').value = fu ? '1' : '';
|
||||
px.get(`/library/sections/${cur.key}/decade`).then(mc => { const g = $('#vdec'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).sort((a, b) => b.key - a.key).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||
px.get(`/library/sections/${cur.key}/contentRating`).then(mc => { const g = $('#vcr'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||
px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||
let start = 0, total = 0, all = [], gen = 0;
|
||||
const load = async () => {
|
||||
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked || $('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
|
||||
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
|
||||
if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
|
||||
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.remove());
|
||||
$('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : '';
|
||||
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
|
||||
if (!all.length) { $('#vgrid').innerHTML = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vunwc').checked || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vunwc').checked = false; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
|
||||
if (!all.length) { $('#vgrid').innerHTML = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vwatch').value || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vwatch').value = ''; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
|
||||
};
|
||||
const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = '<i class="vsk"></i>'.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
|
||||
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr', '#vwatch']) $(id).onchange = () => reload();
|
||||
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vwatch').onchange = () => { store.set('video_unw', $('#vwatch').value === '1' ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr']) $(id).onchange = () => reload();
|
||||
reload();
|
||||
// press and hold (or right-click) a card: quick actions
|
||||
const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up)
|
||||
@@ -170,7 +215,7 @@ async function videoItem(id) {
|
||||
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
||||
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
||||
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
||||
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||
${allowed('chat') && m.type !== 'collection' ? `<button class="btn ghost" id="vshare">${icon('send')}Share</button>` : ''}${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||
<p class="vsum">${esc(m.summary || '')}</p>
|
||||
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||||
<div id="vkids"></div></div>`;
|
||||
@@ -189,6 +234,15 @@ async function videoItem(id) {
|
||||
actionSheet('Trailers', m.title, [...list.slice(0, 8).map(x => ({ label: x.title || 'Trailer', icon: 'play', run: () => go(x) })), { label: 'Search YouTube instead', icon: 'search', run: yt }]);
|
||||
};
|
||||
});
|
||||
if (m.type === 'movie' || m.type === 'show') { // Favorite, and Movie night (the family's shared list to vote on)
|
||||
const mc = { id: String(id), title: m.title, year: m.year || 0, type: m.type, thumb: m.thumb || '' };
|
||||
Promise.all([vfApi('/favs'), vfApi('/night')]).then(([f, n]) => {
|
||||
const box = $('#vact'); if (!box || $('#vfav')) return; const fav = f.items.some(x => x.id === mc.id), night = n.items.some(x => x.id === mc.id);
|
||||
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vfav">${fav ? '♥ Favorite' : '♡ Favorite'}</button><button class="btn ghost" id="vmn">${night ? '✓ On movie night' : '+ Movie night'}</button>`);
|
||||
$('#vfav').onclick = async () => { try { await vfApi('/fav', vfOpt({ ...mc, on: !fav })); toast(fav ? 'Removed from Favorites' : 'Added to Favorites'); route(); } catch (e) { toast(e.message); } };
|
||||
$('#vmn').onclick = async () => { try { if (night) { location.hash = '#/video'; return; } await vfApi('/night/add', vfOpt(mc)); toast('Added to movie night. Everyone can vote on it from Movies & TV.'); route(); } catch (e) { toast(e.message); } };
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
|
||||
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
|
||||
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
|
||||
@@ -196,6 +250,8 @@ async function videoItem(id) {
|
||||
}).catch(() => {});
|
||||
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
||||
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
||||
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
||||
@@ -305,7 +361,14 @@ function videoBindMedia(el) {
|
||||
el.addEventListener('playing', () => { msg(''); const s = $('#vspin'); if (s) s.hidden = true; vdReport('playing'); });
|
||||
el.addEventListener('waiting', () => { const s = $('#vspin'); if (s) s.hidden = false; vp.waitSince = Date.now(); if (vp.mode === 'hls') setTimeout(() => { if (vp && vp.el === el && vp.waitSince && Date.now() - vp.waitSince > 9000 && el.readyState < 3) { msg('Taking a while… restarting from here'); videoStart(vp.base + el.currentTime); } }, 9500); });
|
||||
el.addEventListener('canplay', () => { const s = $('#vspin'); if (s) s.hidden = true; if (vp) vp.waitSince = 0; });
|
||||
el.addEventListener('ended', () => videoEnded());
|
||||
el.addEventListener('ended', () => { // a converted stream can report "ended" when it has only caught up with what Plex has made so far: carry on from there instead of stopping
|
||||
const at = vp ? vp.base + el.currentTime : 0, total = vp && vp.m && vp.m.duration ? vp.m.duration / 1000 : 0;
|
||||
if (vp && vp.el === el && total && at < total - 45 && videoRescue('ended early', at)) return; videoEnded(); });
|
||||
if (!el._wd) el._wd = setInterval(() => { // nothing has moved for a while although it should be playing: start again from the same place
|
||||
if (!vp || vp.el !== el) { clearInterval(el._wd); el._wd = 0; return; }
|
||||
if (el.paused || el.ended || el.seeking || vp.opening || document.visibilityState !== 'visible') { vp.still = 0; vp.lastT = el.currentTime; return; }
|
||||
if (Math.abs(el.currentTime - (vp.lastT || 0)) < 0.2) { if ((vp.still = (vp.still || 0) + 1) >= 3) { vp.still = 0; videoRescue('stalled', vp.base + el.currentTime); } } else vp.still = 0;
|
||||
vp.lastT = el.currentTime; }, 4000);
|
||||
el.addEventListener('error', () => { if (vp && vp.el === el && vp.mode === 'direct') { msg('The browser cannot play this file directly. Converting it…'); vp.q = 'auto'; vp.forceHls = true; videoStart(vp.pos); } else msg('Could not play this. Try a lower quality in Quality & tracks.'); });
|
||||
}
|
||||
/** Refresh whatever controls are on screen (the player page's, and the mini player's) from the video's state. */
|
||||
@@ -417,6 +480,11 @@ function videoFullscreen() {
|
||||
} catch { fallback(); }
|
||||
}
|
||||
document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; });
|
||||
/** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */
|
||||
function videoRescue(why, at) {
|
||||
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
|
||||
vp.rescues = (vp.rescues || 0) + 1; vp.rescueAt = now; const e = $('#vmsg'); if (e) e.textContent = 'Connection hiccup. Picking up where it stopped…'; videoStart(at); return true;
|
||||
}
|
||||
/** Start (or restart) playing from `sec` with the current quality and tracks. */
|
||||
async function videoStart(sec) {
|
||||
if (!vp) return; const el = vp.el, { m, media, part } = vp; sec = Math.max(0, sec || 0); if (m.duration && sec * 1000 > m.duration - 15000) sec = 0;
|
||||
@@ -432,12 +500,22 @@ async function videoStart(sec) {
|
||||
const o = q[2] || QUALITIES[0][2], p = new URLSearchParams({ path: '/library/metadata/' + vp.id, mediaIndex: 0, partIndex: 0, protocol: 'hls', fastSeek: 1, directPlay: 0, directStream: 1, subtitleSize: 100, audioBoost: 100, offset: Math.floor(sec), session: vp.session, maxVideoBitrate: o.kbps, 'X-Plex-Platform': 'Chrome' });
|
||||
if (o.res) p.set('videoResolution', o.res); if (vp.audio) p.set('audioStreamID', vp.audio); if (vp.sub) { p.set('subtitleStreamID', vp.sub); p.set('subtitles', 'burn'); }
|
||||
const url = '/plex/video/:/transcode/universal/start.m3u8?' + p; const t = $('#vmsg'); if (t) t.textContent = 'Preparing the stream…';
|
||||
if (el.canPlayType('application/vnd.apple.mpegurl')) { el.src = url; }
|
||||
else { try { const Hls = await loadHls(); if (!vp || vp.el !== el) return; if (!Hls.isSupported()) throw new Error('This browser cannot play converted video'); const h = vp.hls = new Hls({ maxBufferLength: 40, enableWorker: true }); h.loadSource(url); h.attachMedia(el);
|
||||
h.on(Hls.Events.ERROR, (e, d) => { if (d.fatal) { if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { const tt = $('#vmsg'); if (tt) tt.textContent = 'Connection problem. Retrying…'; h.startLoad(); } else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) h.recoverMediaError(); else { const tt = $('#vmsg'); if (tt) tt.textContent = 'Could not play this. Try a lower quality in Quality & tracks.'; } } }); }
|
||||
catch (e) { const tt = $('#vmsg'); if (tt) tt.textContent = e.message; return; } }
|
||||
// hls.js first wherever the browser can run it (iPad included): it lets us keep a long buffer, so a short drop in the connection does not stop the picture, and it
|
||||
// starts as soon as the first piece arrives. The built-in player (which decides its own small buffer) is the fallback.
|
||||
let Hls = null; try { Hls = await loadHls(); } catch {} if (!vp || vp.el !== el) return;
|
||||
const native = !!el.canPlayType('application/vnd.apple.mpegurl');
|
||||
if (Hls && Hls.isSupported() && !vp.noJs) {
|
||||
const h = vp.hls = new Hls({ maxBufferLength: 120, maxMaxBufferLength: 600, maxBufferSize: 120 * 1e6, backBufferLength: 30, startFragPrefetch: true, testBandwidth: false, enableWorker: true, maxBufferHole: 0.5 });
|
||||
h.loadSource(url); h.attachMedia(el);
|
||||
h.on(Hls.Events.ERROR, (e, d) => { if (!d.fatal) return; const tt = $('#vmsg');
|
||||
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { if (tt) tt.textContent = 'Connection problem. Retrying…'; h.startLoad(); }
|
||||
else if (d.type === Hls.ErrorTypes.MEDIA_ERROR && Date.now() - (vp.recovered || 0) > 5000) { vp.recovered = Date.now(); h.recoverMediaError(); }
|
||||
else if (native && vp && vp.el === el) { vp.noJs = true; videoStart(vp.base + el.currentTime); } // (the built-in player takes over)
|
||||
else if (tt) tt.textContent = 'Could not play this. Try a lower quality in Quality & tracks.'; });
|
||||
} else if (native) { el.src = url; }
|
||||
else { const tt = $('#vmsg'); if (tt) tt.textContent = 'This browser cannot play converted video'; return; }
|
||||
}
|
||||
el.play().catch(() => {});
|
||||
el.play().catch(e => { const t = $('#vmsg'); if (t && e && e.name === 'NotAllowedError') t.textContent = 'Tap the play button to start'; }); // (the browser only lets a tap start the picture)
|
||||
}
|
||||
const vdReport = state => { if (!vp) return; const pos = (vp.base + (vp.el ? vp.el.currentTime : 0)) * 1000; tlSend(state, vp.id, pos, vp.m.duration || 0); };
|
||||
function videoSettings() {
|
||||
|
||||
@@ -169,7 +169,7 @@ function vizOpen() {
|
||||
const el = viz.el = document.createElement('div'); el.id = 'viz'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Visualizer');
|
||||
el.innerHTML = `<canvas id="vizc"></canvas><div class="viztop"><div class="grow"><b id="vizti"></b><small id="vizar"></small><small id="vizst"></small></div><button class="btn ghost" id="vizm"></button><button class="ibtn" id="vizx" aria-label="Close" title="Close (Esc)">${icon('x')}</button></div>
|
||||
<div class="vizfoot"><button class="ibtn" id="vizpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="vizt" aria-label="Play or pause"></button><button class="ibtn" id="viznx" aria-label="Next">${icon('next')}</button></div>`;
|
||||
document.body.appendChild(el); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
|
||||
document.body.appendChild(el); swipeToDismiss(el, vizClose, { handle: '.viztop', always: true }); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
|
||||
const m = el.querySelector('#vizm'); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1];
|
||||
m.onclick = e => { e.stopPropagation(); const i = VIZ_MODES.findIndex(x => x[0] === viz.mode); viz.mode = VIZ_MODES[(i + 1) % VIZ_MODES.length][0]; vizSetPref(vizKind(cur()), viz.mode); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; viz.parts = []; viz.rings = []; };
|
||||
el.querySelector('#vizx').onclick = e => { e.stopPropagation(); vizClose(); }; el.querySelector('#vizt').onclick = e => { e.stopPropagation(); toggle(); }; el.querySelector('#vizpv').onclick = e => { e.stopPropagation(); prev(); }; el.querySelector('#viznx').onclick = e => { e.stopPropagation(); next(); };
|
||||
|
||||
@@ -10,6 +10,7 @@ const tasks = require('./tasks');
|
||||
const cal = require('./cal');
|
||||
const mail = require('./mail');
|
||||
const inbox = require('./inbox');
|
||||
const chores = require('./chores');
|
||||
|
||||
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); });
|
||||
@@ -36,26 +37,37 @@ function localNow(tz, at = new Date()) {
|
||||
}
|
||||
|
||||
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();
|
||||
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag, ref: payload.ref }); // (reminders also land in the Notifications list)
|
||||
keys(); const { ref, ...wire } = payload;
|
||||
let changed = false;
|
||||
await Promise.all(st.subs.map(async s => {
|
||||
try { await webpush.sendNotification(s.sub, JSON.stringify(payload), { TTL: 3600 }); }
|
||||
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); }
|
||||
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); }
|
||||
}));
|
||||
if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); }
|
||||
}
|
||||
|
||||
/** A child with no reward goal for this week gets one reminder (in the Notifications list, and on their devices if they have any), in the daytime by their own clock. */
|
||||
async function goalReminder(u, dir, st, now) {
|
||||
const lt = localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now); if (lt.hm < '08:00' || lt.hm >= '20:00') return;
|
||||
const wk = chores.goalNeeded(u.id, lt.day); if (!wk) return;
|
||||
chores.goalReminded(u.id, wk);
|
||||
await send(dir, st, u.id, { title: 'Pick your reward for this week', body: 'What are you aiming for? Choose a reward on the Chores page.', url: '#/chores', tag: 'goal:' + wk });
|
||||
}
|
||||
|
||||
let lastEvents = 0;
|
||||
let lastDigest = 0;
|
||||
let lastDigest = 0, lastEps = 0;
|
||||
/** One pass over everyone: runs every minute. */
|
||||
async function tick(now = new Date(), force = false) {
|
||||
if (now.getTime() - lastDigest >= 10 * 60e3) { lastDigest = now.getTime(); try { require('./activity').weeklyDigest(now); } catch (e) { console.warn('[push] weekly summary failed:', e.message); } } // (each child's week, to the parents, on Monday morning)
|
||||
if (now.getTime() - lastEps >= 30 * 60e3) { lastEps = now.getTime(); try { await require('./automations').checkEpisodes(n => require('./podcast').recentEpisodes(n)); } catch (e) { console.warn('[push] new episodes check failed:', e.message); } }
|
||||
const checkEvents = force || now.getTime() - lastEvents >= 4.5 * 60e3;
|
||||
if (checkEvents) lastEvents = now.getTime();
|
||||
for (const u of users.all()) {
|
||||
if (u.disabled) continue;
|
||||
const dir = users.dirOf(u.id), st = load(dir);
|
||||
await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message));
|
||||
await require('./automations').tickUser(u, localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now)).catch(e => console.warn('[push] automations failed:', e.message)); // (daily rules)
|
||||
if (!st.subs.length) continue;
|
||||
await users.runAs(u, async () => {
|
||||
const lt = localNow(st.tz, now); let dirty = false;
|
||||
@@ -69,6 +81,27 @@ async function tick(now = new Date(), force = false) {
|
||||
}
|
||||
} catch (e) { console.warn('[push] tasks check failed:', e.message); }
|
||||
}
|
||||
if (checkEvents && st.tasks && users.can(u, 'tasks')) { // a task with a due time: one reminder when that time comes (within two hours of it), once
|
||||
try {
|
||||
for (const t of (await tasks.allTasks()).filter(x => !x.done && x.due === lt.day && x.dueTime && x.dueTime <= lt.hm)) {
|
||||
const mins = (+lt.hm.slice(0, 2) - +t.dueTime.slice(0, 2)) * 60 + (+lt.hm.slice(3) - +t.dueTime.slice(3)), key = `taskdue:${t.id}:${t.due}:${t.dueTime}`;
|
||||
if (mins > 120 || st.sent.includes(key)) continue;
|
||||
st.sent.push(key); st.sent = st.sent.slice(-300); dirty = true;
|
||||
await send(dir, st, u.id, { title: t.title, body: 'Due now (' + t.dueTime + ')', url: '#/tasks', tag: key });
|
||||
}
|
||||
} catch (e) { console.warn('[push] task times failed:', e.message); }
|
||||
}
|
||||
if (checkEvents && users.can(u, 'mail')) { // snoozed mail whose time has come is back in the inbox: say so
|
||||
try { const due = mail.snoozeDue(u.id); if (due.length) await send(dir, st, u.id, { title: due.length === 1 ? 'Back in your inbox: ' + (due[0].subject || 'a message') : due.length + ' snoozed messages are back', body: due.length === 1 ? due[0].from || '' : due.slice(0, 3).map(x => x.subject).filter(Boolean).join(', '), url: '#/mail', tag: 'snooze' }); }
|
||||
catch (e) { console.warn('[push] snooze check failed:', e.message); }
|
||||
}
|
||||
if (st.tasks && users.can(u, 'contacts') && st.datesDay !== lt.day && lt.hm >= st.tasksAt) { // birthdays and anniversaries from Contacts, with the gift ideas, with the morning summary
|
||||
st.datesDay = lt.day; dirty = true;
|
||||
try {
|
||||
const todays = require('./contacts').birthdayEvents({ fd: lt.day, td: lt.day }, u.id);
|
||||
if (todays.length) await send(dir, st, u.id, { title: todays.length === 1 ? todays[0].title : todays.length + ' birthdays and anniversaries today', body: todays.length === 1 ? 'Open Contacts for gift ideas' : todays.map(e => e.title).join(', '), url: '#/contacts/c/' + todays[0].contactId, tag: 'dates:' + lt.day });
|
||||
} catch (e) { console.warn('[push] dates check failed:', e.message); }
|
||||
}
|
||||
if (u.role === 'admin' && checkEvents) { // the Microsoft client secret (for Mail) is about to expire
|
||||
const ss = mail.secretStatus();
|
||||
if (ss && ss.days <= 30) {
|
||||
@@ -87,7 +120,8 @@ 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, 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' });
|
||||
for (const m of fresh.slice(0, 10)) require('./automations').fire(u.id, 'mail.new', { from: m.from || '', subject: m.subject || '' }).catch(() => {});
|
||||
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } });
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('[push] mail check failed:', e.message); }
|
||||
|
||||
+60
-3
@@ -99,6 +99,31 @@ async function sync() {
|
||||
return { ...out, list: tl.id };
|
||||
}
|
||||
|
||||
// ---- Meal plan: which recipe is on which day (shared by the whole family), kept in DATA_DIR/mealplan.json
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/, MEALS = ['breakfast', 'lunch', 'dinner'], planFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'mealplan.json');
|
||||
const readPlan = () => { try { const d = JSON.parse(fs.readFileSync(planFile(), 'utf8')); return { days: d && d.days && typeof d.days === 'object' ? d.days : {} }; } catch { return { days: {} }; } };
|
||||
const writePlan = d => atomicWrite(planFile(), JSON.stringify(d), { mode: 0o600 });
|
||||
const planBetween = (from, to) => { const out = {}; for (const [k, v] of Object.entries(readPlan().days)) if (k >= from && k <= to) out[k] = v; return out; };
|
||||
// The pantry: what the family keeps in stock, one shared list. Each item has a name, how many are left (blank when you do not count it) and the level it counts as
|
||||
// running low at (blank: never). The meal plan points out planned ingredients you have; an item going low can start an automation and goes on the shopping list in a tap.
|
||||
const pantryFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pantry.json');
|
||||
const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100);
|
||||
const readPantry = () => { try { const a = JSON.parse(fs.readFileSync(pantryFile(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null } : x).filter(x => x && x.name); } catch { return []; } };
|
||||
const writePantry = l => atomicWrite(pantryFile(), JSON.stringify(l), { mode: 0o600 });
|
||||
const isLow = x => x.qty != null && x.low != null && x.qty <= x.low;
|
||||
const pantryView = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, low: x.low, isLow: isLow(x) })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name));
|
||||
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
|
||||
const inPantry = (text, items) => { items = items.map(x => typeof x === 'string' ? x : x.name); const words = new Set(String(text).toLowerCase().match(/[a-z]+/g) || []), stems = new Set([...words].map(stem)); return items.filter(it => { const ws = (String(it).toLowerCase().match(/[a-z]+/g) || []).map(stem); return ws.length && ws.every(w => stems.has(w)); }); };
|
||||
/** An item has just gone low: tell the automations of everyone who asked to hear about it. */
|
||||
function pantryLow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[recipes] pantry rules failed:', e.message); } }
|
||||
/** A recipe's ingredients onto Mealie's shopping list, `times` over (Mealie adds up what is already there). */
|
||||
async function addRecipe(slug, times) {
|
||||
const r = await mealie('/api/recipes/' + (slugOk(slug) ? slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +times || 1));
|
||||
try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
|
||||
catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
|
||||
return r;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
|
||||
try {
|
||||
@@ -127,11 +152,43 @@ async function handle(req, res) {
|
||||
}
|
||||
if (p === '/shopping' && req.method === 'GET') return json(res, 200, await readList());
|
||||
if (p === '/shopping/add-recipe' && req.method === 'POST') { // the recipe's ingredients go on the list; Mealie adds up what is already there
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await mealie('/api/recipes/' + (slugOk(b.slug) ? b.slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +b.scale || 1));
|
||||
try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
|
||||
catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await addRecipe(b.slug, +b.scale || 1);
|
||||
const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name });
|
||||
}
|
||||
if (p === '/plan' && req.method === 'GET') { const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); return json(res, 200, planBetween(from, to)); }
|
||||
if (p === '/plan/add' && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.date || '') || !slugOk(b.slug)) throw fail(400, 'Pick a day and a recipe');
|
||||
const d = readPlan(), id = crypto.randomBytes(5).toString('hex'), cut = new Date(Date.now() - 90 * 864e5).toISOString().slice(0, 10);
|
||||
for (const k of Object.keys(d.days)) if (k < cut) delete d.days[k];
|
||||
(d.days[b.date] = d.days[b.date] || []).push({ id, slug: b.slug, name: String(b.name || b.slug).slice(0, 160), rid: idOk(b.rid) ? b.rid : '', meal: MEALS.includes(b.meal) ? b.meal : 'dinner', by: (users.current() || {}).id || '' });
|
||||
if (d.days[b.date].length > 12) throw fail(400, 'That day is full'); writePlan(d); return json(res, 200, { ok: true, id });
|
||||
}
|
||||
if (p === '/plan/remove' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), d = readPlan(); for (const k of Object.keys(d.days)) { d.days[k] = d.days[k].filter(e => e.id !== b.id); if (!d.days[k].length) delete d.days[k]; } writePlan(d); return json(res, 200, { ok: true }); }
|
||||
if (p === '/pantry' && req.method === 'GET') return json(res, 200, { items: pantryView(readPantry()) });
|
||||
if (p === '/pantry/item' && req.method === 'POST') { // add an item, or change one
|
||||
const b = JSON.parse(await readBody(req, 2e3) || '{}'), l = readPantry(), name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name');
|
||||
const i = b.id ? l.findIndex(x => x.id === b.id) : l.findIndex(x => x.name.toLowerCase() === name.toLowerCase()); if (b.id && i < 0) throw fail(404, 'That item is gone'); if (i < 0 && l.length >= 400) throw fail(400, 'The pantry is full');
|
||||
const before = i >= 0 ? isLow(l[i]) : false, it = { id: i >= 0 ? l[i].id : crypto.randomBytes(4).toString('hex'), name: i >= 0 && !b.id ? l[i].name : name, qty: num(b.qty), low: num(b.low) }; if (i >= 0) l[i] = it; else l.push(it); writePantry(l);
|
||||
if (isLow(it) && !before) pantryLow(it);
|
||||
return json(res, 200, { items: pantryView(l) });
|
||||
}
|
||||
if (p === '/pantry/adjust' && req.method === 'POST') { // one more or one less
|
||||
const b = JSON.parse(await readBody(req, 1e3) || '{}'), l = readPantry(), it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone');
|
||||
const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + (+b.delta || 0)) * 100) / 100); writePantry(l); if (isLow(it) && !before) pantryLow(it); return json(res, 200, { items: pantryView(l) });
|
||||
}
|
||||
if (p === '/pantry/delete' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e3) || '{}'); const l = readPantry().filter(x => x.id !== b.id); writePantry(l); return json(res, 200, { items: pantryView(l) }); }
|
||||
if (p === '/plan/pantry' && req.method === 'GET') { // which planned ingredients the pantry already covers
|
||||
const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); const items = readPantry().filter(x => x.qty !== 0), out = []; if (!items.length) return json(res, 200, { items: [] });
|
||||
for (const sl of [...new Set(Object.values(planBetween(from, to)).flat().map(e => e.slug))]) { try { const r = full(await mealie('/api/recipes/' + sl)); for (const i of r.ingredients) { const hit = i.text && inPantry(i.text, items); if (hit && hit.length) out.push({ recipe: r.name, text: i.text, have: hit[0] }); } } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } }
|
||||
return json(res, 200, { items: out });
|
||||
}
|
||||
if (p === '/plan/shop' && req.method === 'POST') { // every planned meal in the range goes on the shopping list (a recipe planned twice counts twice)
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.from || '') || !DAY.test(b.to || '')) throw fail(400, 'Bad dates');
|
||||
const count = {}; for (const es of Object.values(planBetween(b.from, b.to))) for (const e of es) count[e.slug] = (count[e.slug] || 0) + 1;
|
||||
const slugs = Object.keys(count); if (!slugs.length) throw fail(400, 'Nothing is planned in those days'); let added = 0;
|
||||
for (const sl of slugs) { try { await addRecipe(sl, count[sl]); added++; } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } }
|
||||
const s = await sync(); return json(res, 200, { ok: true, ...s, recipes: added });
|
||||
}
|
||||
if (p === '/shopping/add' && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy');
|
||||
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 0, checked: false } }); rememberWho(text, users.current().name); await sync(); return json(res, 200, { ok: true });
|
||||
|
||||
+5
-1
@@ -6,7 +6,7 @@ const settings = require('./settings'), users = require('./users'), books = requ
|
||||
|
||||
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
|
||||
const MAX_FILE = 500 * 1024 * 1024;
|
||||
const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'librivox-book': 'audio', 'librivox-chapter': 'audio' };
|
||||
const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'mail-attachment': 'mail', 'librivox-book': 'audio', 'librivox-chapter': 'audio' };
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const safe = (s, max = 100) => String(s || '').replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').replace(/^[. ]+|[. ]+$/g, '').slice(0, max).trim() || 'Untitled';
|
||||
@@ -44,6 +44,10 @@ async function plan(me, b) {
|
||||
out.push({ folder: ['Books'], name: safe(f.author ? `${f.author} - ${f.title}` : f.title, 120) + '.' + f.ext, load: async () => fs.promises.readFile(f.path) });
|
||||
return { label: f.title, out };
|
||||
}
|
||||
if (kind === 'mail-attachment') {
|
||||
const f = await require('./mail').attachmentOf(String(b.id || ''), String(b.aid || '')); if (f.buffer.length > MAX_FILE) throw fail(413, 'That attachment is too large to save');
|
||||
out.push({ folder: ['Mail attachments'], name: safe(f.name, 120), load: async () => f.buffer }); return { label: f.name, out };
|
||||
}
|
||||
if (kind === 'librivox-book' || kind === 'librivox-chapter') {
|
||||
const bk = await librivox.book(String(b.id || '')), only = kind === 'librivox-chapter' ? +b.n : -1;
|
||||
bk.chapters.forEach(c => { if (only >= 0 && c.n !== only) return; out.push({ folder: ['Audiobooks', safe(bk.title)], name: `${num(c.n, bk.chapters.length)} - ${safe(c.title, 80)}.mp3`, load: () => download(c.url, librivox.allowed, 'NE-Hub/1.0') }); });
|
||||
|
||||
@@ -48,6 +48,8 @@ const contacts = require('./contacts'); // each person's address book (mail su
|
||||
const activity = require('./activity'), alertParents = activity.alertParents;
|
||||
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
|
||||
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
|
||||
const automations = require('./automations'); // when this happens, do that (per person)
|
||||
const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list
|
||||
const chores = require('./chores'); // chores for the family, with points
|
||||
const notes = require('./notes'); // shared and private notes and checklists
|
||||
const chat = require('./chat'); // Family Chat: a messenger for the people with an account here
|
||||
@@ -584,6 +586,7 @@ const ACCESS = [
|
||||
[/^\/api\/bible\//, ['bible']],
|
||||
[/^\/api\/read\//, ['read']],
|
||||
[/^\/api\/radio\//, ['radio']],
|
||||
[/^\/api\/moviefam(\/|$)/, ['video']],
|
||||
[/^\/api\/chores(\/|$)/, ['chores']],
|
||||
[/^\/api\/notes(\/|$)/, ['notes']],
|
||||
[/^\/api\/chat(\/|$)/, ['chat']],
|
||||
@@ -651,6 +654,7 @@ async function handleRequest(req, res) {
|
||||
const rule = ACCESS.find(([re]) => re.test(pathname));
|
||||
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
|
||||
if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
|
||||
if (pathname === '/api/me/color' && req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); users.update(user.id, { color: b.color || null }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ color: users.colorOf(users.get(user.id)) })); }
|
||||
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
|
||||
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
|
||||
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
|
||||
@@ -660,7 +664,10 @@ async function handleRequest(req, res) {
|
||||
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
||||
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
||||
if (pathname === '/api/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); }
|
||||
if (pathname === '/api/listening/stats') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').stats())); }
|
||||
if (pathname === '/api/export') return backup.handle(req, res, user.name);
|
||||
if (pathname === '/api/backup' || pathname === '/api/backup/restore') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
||||
if (pathname === '/api/kidsearch' || pathname.startsWith('/api/kidsearch/')) return require('./kidsearch').handle(req, res);
|
||||
if (pathname === '/api/ddg') { if (user.kid && user.kid.on && user.role !== 'admin') return forbidden(res, 'Web search is not available on a child account'); return ddgAnswer(req, res); }
|
||||
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
||||
@@ -673,6 +680,11 @@ async function handleRequest(req, res) {
|
||||
const ps = plexLoad();
|
||||
out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })();
|
||||
try { out.mail = await mail.health(); } catch { out.mail = []; }
|
||||
// the other services the hub leans on: reachable at all? (any answer under 500 counts, since most want a sign-in)
|
||||
const probe = async (name, url) => { const t = Date.now(); try { const r = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(4000) }); return r.status < 500 ? { name, ok: true, ms: Date.now() - t } : { name, ok: false, error: 'Answered with an error (' + r.status + ')' }; } catch (e) { return { name, ok: false, error: e.name === 'TimeoutError' ? 'No answer within 4 seconds' : 'Cannot be reached' }; } };
|
||||
const checks = [probe('Seafile (Files)', SEAFILE_URL + '/api2/ping/'), probe('OnlyOffice', ONLYOFFICE_URL + '/healthcheck'), probe('Audiobooks', (process.env.OPENAUDIBLE_URL || AUDIOBOOKS_URL).replace(/\/$/, '') + '/'), probe('Podcasts (Podgrab)', PODCAST_URL)];
|
||||
if (recipes.enabled()) checks.push(probe('Recipes (Mealie)', (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '') + '/api/app/about'));
|
||||
out.services = await Promise.all(checks);
|
||||
out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status();
|
||||
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
|
||||
}
|
||||
@@ -704,6 +716,8 @@ async function handleRequest(req, res) {
|
||||
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
|
||||
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
|
||||
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
|
||||
if (pathname === '/api/auto' || pathname.startsWith('/api/auto/')) return automations.handle(req, res);
|
||||
if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res);
|
||||
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
|
||||
if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res);
|
||||
if (pathname.startsWith('/api/chat/')) return chat.handle(req, res);
|
||||
|
||||
@@ -44,9 +44,10 @@ function parseTodos(text) {
|
||||
else if (l.name === 'STATUS') cur.status = l.value.toUpperCase();
|
||||
else if (l.name === 'PERCENT-COMPLETE') cur.percent = parseInt(l.value, 10);
|
||||
else if (l.name === 'COMPLETED') cur.completed = true;
|
||||
else if (l.name === 'RELATED-TO' && !/^(CHILD|SIBLING)$/i.test((l.params && l.params.RELTYPE) || '')) cur.parent = cal.unesc(l.value);
|
||||
else if (l.name === 'DUE') { const d = cal.parseDate(l.value, l.params); if (d) cur.due = `${d.y}-${String(d.mo).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`; }
|
||||
}
|
||||
return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed }));
|
||||
return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed, parent: t.parent || '' }));
|
||||
}
|
||||
|
||||
// ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it.
|
||||
@@ -81,6 +82,7 @@ function validParent(d, raw, parent, selfId) {
|
||||
if (selfId && d.tasks.some(x => x.parent === selfId)) throw new Error('A task that has subtasks cannot become a subtask');
|
||||
return parent;
|
||||
}
|
||||
const cleanTime = v => /^([01]\d|2[0-3]):[0-5]\d$/.test(String(v || '')) ? String(v) : ''; // a time of day on the due date, for a reminder at that time
|
||||
const fieldsOf = t => ({ title: t.title, notes: t.notes, due: t.due, done: t.done });
|
||||
|
||||
async function handle(req, res) {
|
||||
@@ -107,7 +109,7 @@ async function handle(req, res) {
|
||||
const list = q.get('list');
|
||||
if (isLocal(list)) {
|
||||
const raw = list.slice(6);
|
||||
return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' })));
|
||||
return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' })));
|
||||
}
|
||||
if (caldav.todoLists().some(l => l.id === list)) {
|
||||
const rows = await caldav.listTodoRaw(list), tasks = [];
|
||||
@@ -125,27 +127,33 @@ async function handle(req, res) {
|
||||
if (b.id) {
|
||||
const t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
|
||||
const wasDone = !!t.done, repeat = f.repeat === undefined ? t.repeat || 'none' : cleanRepeat(f.repeat);
|
||||
Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', repeat, updated: Date.now() });
|
||||
Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', dueTime: f.due ? cleanTime(f.dueTime) : '', repeat, updated: Date.now() });
|
||||
if (f.parent !== undefined) t.parent = validParent(d, raw, f.parent, t.id) || undefined;
|
||||
const rolled = finishLocal(t, !!f.done);
|
||||
if (wasDone && f.done) { t.done = true; t.completedAt = t.completedAt || Date.now(); }
|
||||
if (b.moveTo && isLocal(b.moveTo) && !t.parent) { const to = b.moveTo.slice(6); d.tasks.filter(x => x.parent === t.id).forEach(x => { x.list = to; }); t.list = to; }
|
||||
save(d); return json(res, 200, { ok: true, rolled });
|
||||
}
|
||||
d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); // (who added it, so a shared list shows whose it was)
|
||||
d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', dueTime: f.due ? cleanTime(f.dueTime) : '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); // (who added it, so a shared list shows whose it was)
|
||||
save(d); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive
|
||||
const cur = await caldav.getResource(b.list, b.href);
|
||||
await caldav.putEvent(b.list, null, ics.patchTodo(cur.ics, f), { href: b.href, etag: cur.etag });
|
||||
} else { const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }])); }
|
||||
} else {
|
||||
if (f.parent) { // a subtask goes under a main task of the same list (and a subtask cannot have subtasks)
|
||||
const mine = []; for (const r of await caldav.listTodoRaw(b.list)) mine.push(...parseTodos(r.ics)); const par = mine.find(x => x.id === f.parent);
|
||||
if (!par || par.parent) throw new Error('A subtask can only go under a main task in the same list'); }
|
||||
const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }]));
|
||||
}
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/tasks/toggle' && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req));
|
||||
if (isLocal(b.list)) {
|
||||
const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
|
||||
const rolled = finishLocal(t, !!b.done); save(d); return json(res, 200, { ok: true, rolled });
|
||||
const was = !!t.done, rolled = finishLocal(t, !!b.done); save(d); if (b.done && !was) require('./automations').fire(users.current().id, 'task.done', { title: t.title }).catch(() => {});
|
||||
return json(res, 200, { ok: true, rolled });
|
||||
}
|
||||
const cur = await caldav.getResource(b.list, b.href);
|
||||
const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task');
|
||||
@@ -182,11 +190,16 @@ async function handle(req, res) {
|
||||
/** Every task from every list (NE Hub and iCloud), with its list name. */
|
||||
async function allTasks() {
|
||||
const d = load(), out = [];
|
||||
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
|
||||
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
|
||||
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Open tasks on the shared Family list, soonest first, for the family board. */
|
||||
const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 40).map(x => ({ id: x.id, title: x.title, due: x.due || '', by: x.by || '' })); };
|
||||
module.exports = { familyOpen, handle, parseTodos, allTasks, nextDue, stepDate };
|
||||
/** Add a task to one of the current person's own lists by name (the first list when none is given or found). Used by automations. */
|
||||
function addLocalTask(listName, title) {
|
||||
const d = load(), n = String(listName || '').toLowerCase(), l = (n && d.lists.find(x => x.id !== FAMILY && x.name.toLowerCase() === n)) || d.lists[0];
|
||||
d.tasks.push({ id: crypto.randomUUID(), list: l.id, title: String(title).slice(0, 200), notes: 'From an automation', due: '', done: false, completedAt: null, repeat: 'none', created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); save(d);
|
||||
}
|
||||
module.exports = { addLocalTask, familyOpen, handle, parseTodos, allTasks, nextDue, stepDate };
|
||||
@@ -0,0 +1,42 @@
|
||||
// Automations: rules are checked when saved, run when their trigger happens (and only then), keep to a daily cap, and belong to one person.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-auto-'));
|
||||
const users = require('../users'), auto = require('../automations');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['tasks'], handle: 'ZP' });
|
||||
const tasksOf = u => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'tasks.json'), 'utf8')).tasks; } catch { return []; } };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => auto.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/auto', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = who => fetch(base + '/', { headers: who ? { 'x-user': who } : {} });
|
||||
try {
|
||||
// validation
|
||||
assert.equal((await post('/', { when: { type: 'nope' }, then: { type: 'task', text: 'x' } })).status, 400); assert.equal((await post('/', { when: { type: 'time', at: '25:00' }, then: { type: 'task', text: 'x' } })).status, 400, 'a real time of day');
|
||||
assert.equal((await post('/', { when: { type: 'mail.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'mail rules need something to match, so it is not every email');
|
||||
assert.equal((await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: ' ' } })).status, 400, 'and something to do');
|
||||
// a daily rule runs once a day, at its time
|
||||
let r = await j(await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: 'Water the plants' } })); assert.equal(r.rules.length, 1); const daily = r.rules[0];
|
||||
await auto.tickUser(ann, { day: '2026-11-02', hm: '07:59' }); assert.equal(tasksOf(ann).length, 0, 'not before its time');
|
||||
await auto.tickUser(ann, { day: '2026-11-02', hm: '08:00' }); assert.deepEqual(tasksOf(ann).map(t => t.title), ['Water the plants']);
|
||||
await auto.tickUser(ann, { day: '2026-11-02', hm: '08:01' }); assert.equal(tasksOf(ann).length, 1, 'once a day'); await auto.tickUser(ann, { day: '2026-11-03', hm: '08:00' }); assert.equal(tasksOf(ann).length, 2, 'and again tomorrow');
|
||||
await auto.tickUser(ann, { day: '2026-11-04', hm: '15:00' }); assert.equal(tasksOf(ann).length, 2, 'a time that passed hours ago is not caught up');
|
||||
// a finished task starts matching rules, with the title filled in; the shopping action goes to the Shopping list
|
||||
await post('/', { when: { type: 'task.done', match: 'milk' }, then: { type: 'task', text: 'Buy more after {title}' } }); await post('/', { when: { type: 'task.done', match: '' }, then: { type: 'notify', text: 'Nice, {title} is done' }, name: 'Cheer' });
|
||||
await auto.fire(ann.id, 'task.done', { title: 'Buy bread' }); assert.ok(!tasksOf(ann).some(t => /Buy more/.test(t.title)), 'a rule that does not match does nothing');
|
||||
await auto.fire(ann.id, 'task.done', { title: 'Buy milk' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy more after Buy milk'));
|
||||
const inbox = require('../inbox').load(ann.id).filter(x => x.title === 'Cheer'); assert.ok(inbox.length >= 1 && /Nice, Buy (bread|milk) is done/.test(inbox[0].body), 'notify lands in the Notifications list');
|
||||
// mail
|
||||
await post('/', { when: { type: 'mail.new', match: 'invoice' }, then: { type: 'shopping', text: 'Pay {subject}' } }); await auto.fire(ann.id, 'mail.new', { from: 'Bob', subject: 'Lunch?' }); assert.ok(!tasksOf(ann).some(t => /^Pay /.test(t.title)));
|
||||
await auto.fire(ann.id, 'mail.new', { from: 'Acme', subject: 'Your invoice 42' }); assert.ok(tasksOf(ann).some(t => t.title === 'Pay Your invoice 42'));
|
||||
// off, private, capped
|
||||
r = await j(await get()); assert.equal(r.rules.length, 4); assert.equal((await j(await get('bo'))).rules.length, 0, 'rules are personal'); assert.equal((await post('/delete', { id: daily.id }, 'bo')).status, 404, 'and cannot be touched by someone else');
|
||||
const cheer = r.rules.find(x => x.name === 'Cheer'); await post('/toggle', { id: cheer.id, on: false }); const n0 = tasksOf(ann).length; await auto.fire(ann.id, 'task.done', { title: 'x' }); assert.equal(tasksOf(ann).length, n0, 'a rule that is off does not run');
|
||||
await post('/', { when: { type: 'task.done', match: 'spam' }, then: { type: 'task', text: 'Again' } }); for (let i = 0; i < 30; i++) await auto.fire(ann.id, 'task.done', { title: 'spam' }); assert.equal(tasksOf(ann).filter(t => t.title === 'Again').length, 20, 'a rule runs at most 20 times a day');
|
||||
// the pantry going low, and new podcast episodes (for everyone's rules; the first look only notes what is there)
|
||||
assert.equal((await post('/', { when: { type: 'podcast.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'a show must be named');
|
||||
await post('/', { when: { type: 'pantry.low', match: 'flour' }, then: { type: 'shopping', text: 'Buy {title}' } }); await post('/', { when: { type: 'podcast.new', match: 'daily' }, then: { type: 'task', text: 'Listen: {title} ({show})' } });
|
||||
const n1 = tasksOf(ann).length; await auto.fireAll('pantry.low', { title: 'Milk' }); assert.equal(tasksOf(ann).length, n1, 'another item does not match'); await auto.fireAll('pantry.low', { title: 'Flour' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy Flour'), 'a low item starts the rule');
|
||||
let eps = [{ id: '1', title: 'Old', show: 'The Daily' }]; const recent = async () => eps; assert.equal(await auto.checkEpisodes(recent), 0, 'the first look only notes what is there'); assert.ok(!tasksOf(ann).some(t => /^Listen/.test(t.title)));
|
||||
eps = [{ id: '2', title: 'Big news', show: 'The Daily' }, { id: '3', title: 'Other', show: 'Cooking Hour' }, ...eps]; assert.equal(await auto.checkEpisodes(recent), 2); assert.deepEqual(tasksOf(ann).filter(t => /^Listen/.test(t.title)).map(t => t.title), ['Listen: Big news (The Daily)'], 'only the show asked for'); assert.equal(await auto.checkEpisodes(recent), 0, 'and not again');
|
||||
assert.equal((await j(await post('/delete', { id: daily.id }))).rules.some(x => x.id === daily.id), false);
|
||||
console.log('automations test ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Backup restore and personal export: a good backup is swapped in (the old data kept aside), unsafe archives are refused, and an export holds only your own data without sign-in tokens.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http'), { execFileSync } = require('child_process');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-backup-')); process.env.DATA_DIR = dir; process.env.NO_RESTART_AFTER_RESTORE = '1';
|
||||
const users = require('../users'), backup = require('../backup');
|
||||
const a = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b = users.create({ name: 'Bo', role: 'user', handle: 'ZP' });
|
||||
fs.writeFileSync(path.join(users.dirOf(a.id), 'tasks.json'), '{"mine":1}'); fs.writeFileSync(path.join(users.dirOf(a.id), 'mail.json'), '{"token":"secret"}'); fs.writeFileSync(path.join(users.dirOf(b.id), 'tasks.json'), '{"bo":1}');
|
||||
const hub = http.createServer((req, res) => users.runAs(a, () => backup.handle(req, res, 'Ann'))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port;
|
||||
const mk = (name, fn) => { const w = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-bk-src-')); fn(w); const f = path.join(os.tmpdir(), name); execFileSync('tar', ['-czf', f, '-C', w, '.']); return fs.readFileSync(f); };
|
||||
try {
|
||||
// export: only my data, no tokens
|
||||
const ex = Buffer.from(await (await fetch(base + '/api/export')).arrayBuffer()), ef = path.join(os.tmpdir(), 'nehub-ex.tar.gz'); fs.writeFileSync(ef, ex);
|
||||
const list = execFileSync('tar', ['-tzf', ef]).toString(); assert.match(list, /me\/tasks\.json/); assert.ok(!/mail\.json/.test(list), 'sign-in tokens are left out'); assert.ok(!list.includes(b.id), 'nobody else\'s data');
|
||||
// a good backup is swapped in; the old data is kept
|
||||
const good = mk('good.tar.gz', w => { fs.writeFileSync(path.join(w, 'secret.json'), '{"restored":true}'); fs.mkdirSync(path.join(w, 'users')); });
|
||||
let r = await fetch(base + '/api/backup/restore', { method: 'POST', body: good }); let j = await r.json(); assert.equal(r.status, 200, JSON.stringify(j)); assert.ok(j.restarting);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'secret.json'), 'utf8')).restored, true); assert.ok(fs.existsSync(path.join(dir, j.kept, 'users')), 'the old accounts are kept aside');
|
||||
assert.ok(!fs.existsSync(path.join(dir, '.restore')) && !fs.existsSync(path.join(dir, '.restore.tar.gz')), 'the staging files are cleaned up');
|
||||
// unsafe or foreign archives are refused and change nothing
|
||||
const evil = mk('evil.tar.gz', w => { fs.writeFileSync(path.join(w, 'secret.json'), '{"evil":true}'); fs.symlinkSync('/etc/passwd', path.join(w, 'users')); });
|
||||
r = await fetch(base + '/api/backup/restore', { method: 'POST', body: evil }); assert.equal(r.status, 400); assert.match((await r.json()).error, /links/); assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'secret.json'), 'utf8')).restored, true, 'nothing changed');
|
||||
const foreign = mk('foreign.tar.gz', w => fs.writeFileSync(path.join(w, 'readme.txt'), 'hi')); r = await fetch(base + '/api/backup/restore', { method: 'POST', body: foreign }); assert.equal(r.status, 400); assert.match((await r.json()).error, /NE Hub backup/);
|
||||
r = await fetch(base + '/api/backup/restore', { method: 'POST', body: Buffer.from('not a tar') }); assert.equal(r.status, 400);
|
||||
console.log('backup test ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close();
|
||||
});
|
||||
+13
-2
@@ -6,15 +6,26 @@ const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }),
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h });
|
||||
try {
|
||||
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [] });
|
||||
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [], plan: null });
|
||||
await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins');
|
||||
assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3);
|
||||
r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1);
|
||||
r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }]));
|
||||
assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3);
|
||||
r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted');
|
||||
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [] }, "each person's place and marks are their own");
|
||||
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [], plan: null }, "each person's place and marks are their own");
|
||||
assert.equal((await fetch(base + '/nope')).status, 404);
|
||||
// devotion: a reading plan with check-ins, and the shared prayer list
|
||||
r = await j(await post('/plan', { start: '2026-01-01' })); assert.equal(r.plan.start, '2026-01-01'); assert.equal((await post('/plan', { start: 'nope' })).status, 400);
|
||||
r = await j(await post('/plan/check', { day: 3, on: true })); await post('/plan/check', { day: 1, on: true }); r = await j(await post('/plan/check', { day: 3, on: true })); assert.deepEqual(r.plan.done, [1, 3], 'check-ins are kept in order, once each');
|
||||
assert.equal((await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } }))).plan, null, 'each person has their own plan');
|
||||
r = await j(await post('/prayers', { text: 'For Grandma' })); assert.equal(r.length, 1); const pid = r[0].id; assert.equal((await post('/prayers', { text: ' ' })).status, 400);
|
||||
assert.equal((await j(await fetch(base + '/prayers', { headers: { 'x-user': 'b' } }))).length, 1, 'the whole family sees the list');
|
||||
r = await j(await post('/prayers/answer', { id: pid, on: true }, { 'x-user': 'b' })); assert.equal(r[0].answered, true);
|
||||
assert.equal((await post('/prayers/delete', { id: pid }, { 'x-user': 'b' })).status, 403, 'only the author or an admin removes a request'); assert.equal((await j(await post('/prayers/delete', { id: pid }))).length, 0);
|
||||
// every verse of the day exists in the text
|
||||
{ const code = fs.readFileSync(path.join(__dirname, '..', 'public', 'devotion-view.js'), 'utf8'), list = JSON.parse(/const DV_VERSES = (\[.*?\]);/.exec(code)[1].replace(/'/g, '"')), dd = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8'));
|
||||
for (const ref of list) { const m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(ref), bk = dd.books.find(b => b.id === m[1]); assert.ok(bk && bk.chapters[+m[2] - 1] && bk.chapters[+m[2] - 1].some(x => x[0] === 'v' && x[1] === +m[3]), ref + ' is in the Bible text'); } }
|
||||
// the bundled text
|
||||
const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189);
|
||||
const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there');
|
||||
|
||||
@@ -102,5 +102,13 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
|
||||
ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233');
|
||||
r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400);
|
||||
|
||||
// invitations and each person's colour
|
||||
r = await call(ann, '/api/cal/people'); ok('the calendar lists who can be invited (not yourself)', r.status === 200 && r.json.map(x => x.name).sort().join() === 'Bob,Cy' && /^#[0-9a-f]{6}$/.test(r.json[0].color), r.json);
|
||||
r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night', '2026-11-05'), invite: [bob.id, ann.id, 'nobody'] }); ok('inviting adds the event for the invited people only', r.status === 200 && r.json.invited === 1, r.json);
|
||||
ok('and it is on their calendar', (await titles(bob)).includes('Movie night') && !(await titles(cy)).includes('Movie night'));
|
||||
r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night 2', '2026-11-05'), invite: [bob.id] }); r = await call(bob, '/api/cal/event', { cal: 'local', fields: F('Bob only', '2026-11-05'), invite: [] }); ok('no invite list, nobody invited', r.json.invited === 0);
|
||||
{ const fe = await call(bob, '/api/cal/event', { cal: 'family~local', fields: F('Family dinner', '2026-11-04') }); const ev = (await call(cy, win)).json.events.find(e => e.title === 'Family dinner'); ok('a family event shows in the colour of whoever added it', ev && ev.color === users.colorOf(users.get(bob.id)), ev); }
|
||||
r = await call(bob, '/api/cal/people'); ok('and nobody sees a disabled or calendar-less person', !r.json.some(x => x.id === bob.id));
|
||||
|
||||
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
@@ -83,6 +83,19 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot & Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
|
||||
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
|
||||
|
||||
// ---- share cards: a song, film or book from the hub, with a picture and a link back
|
||||
const card = { kind: 'song', title: 'Blue Moon', sub: 'Sinatra', art: '/plex/photo/:/transcode?width=300&url=%2Flibrary%2Fa', link: '#/music/album/123' };
|
||||
for (const bad of [{ ...card, kind: 'virus' }, { ...card, title: ' ' }, { ...card, link: 'https://evil.example/x' }, { ...card, link: 'javascript:alert(1)' }, { ...card, link: '#/music/<b>' }, { ...card, art: 'https://evil.example/a.jpg' }, { ...card, art: '//evil.example/a.jpg' }, { ...card, art: '/plex/../etc/passwd' }]) assert.equal((await post('/send', { conv: g2.id, share: bad }, 'a')).status, 400, 'a bad card is refused: ' + JSON.stringify(bad));
|
||||
assert.equal((await post('/send', { conv: g2.id, share: card }, 'c')).status, 200, 'sharing needs no words'); await post('/read', { id: g2.id }, 'b');
|
||||
const sh = (await j(await post('/send', { conv: g2.id, text: 'Listen to this', share: { ...card, title: 'T'.repeat(500), sub: 'S'.repeat(500) } }, 'a'))).msg; assert.equal(sh.share.title.length, 120, 'long words are cut'); assert.equal(sh.share.sub.length, 120); assert.equal(sh.text, 'Listen to this', 'with a note');
|
||||
assert.equal((await j(await call('/convs', {}, 'b'))).unread, 1, 'a share counts as unread (the earlier one was read)'); assert.match((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, /^Ann: 🎵 TTT/, 'listed by its title');
|
||||
const sh2 = (await j(await post('/send', { conv: g2.id, share: { kind: 'podcastep', title: 'Ep 4', link: '#/podcast/show/x' } }, 'a'))).msg; assert.equal(sh2.share.art, '', 'the picture is optional'); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share.link, '#/podcast/show/x');
|
||||
assert.equal(chat.cleanShare({ kind: 'movie', title: 'Up', link: '#/video/item/9' }).title, 'Up');
|
||||
assert.ok(require('../inbox').load(P.b.id).some(i => i.body === 'Ann: 🎙️ Shared a podcast episode: Ep 4' && i.url.startsWith('#/chat/')), 'the Notifications list says what was shared');
|
||||
assert.equal((await j(await call('/search?q=sinatra', {}, 'b'))).results.length, 1, 'search finds a card by its subtitle'); assert.equal((await j(await call('/search?q=ep%204', {}, 'b'))).results.length, 1);
|
||||
const fws = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: sh.id }, 'a'))).msg; assert.equal(fws.share.link, '#/music/album/123', 'a forwarded card stays a card');
|
||||
assert.equal((await post('/delete', { conv: g2.id, msg: sh2.id }, 'a')).status, 200); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share, undefined, 'a deleted card shows nothing');
|
||||
|
||||
|
||||
// ---- family extras: announcement and quiet hours
|
||||
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
|
||||
|
||||
+24
-1
@@ -1,7 +1,8 @@
|
||||
// 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.TZ = 'UTC'; // (the reminder goes out in the daytime by the server's clock)
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chores-'));
|
||||
const users = require('../users'), chores = require('../chores');
|
||||
const users = require('../users'), chores = require('../chores'), push = require('../push'), inbox = require('../inbox');
|
||||
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' }) };
|
||||
users.update(P.b.id, { kid: { on: true } }); users.update(P.c.id, { kid: { on: true } }); // (points and rewards are for child accounts)
|
||||
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chores.handle(req, res))).listen(0, async () => {
|
||||
@@ -47,6 +48,28 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
assert.equal((await j(await call('/week?date=' + chores.addDays(today, -7), {}, 'a'))).people.every(x => x.points === 0), true, 'last week had no points: they start again each week');
|
||||
const rc = await j(await post('/reward', { title: 'Bake together', points: 3, for: [P.c.id] })); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.ok(wk.people.find(x => x.name === 'Cy').earned.includes(rc.id), 'a reward just for Cy: Cy earns it'); assert.ok(!wk.people.find(x => x.name === 'Bob').tiers.includes(rc.id), 'and it is not on Bob\'s list'); assert.equal((await post('/reward', { title: 'x', points: 3, for: [] })).status, 400, 'someone has to be chosen'); await post('/reward', { id: rc.id, remove: true });
|
||||
assert.equal((await post('/give', { user: P.b.id, reward: r5.id, undo: true })).status, 200); assert.equal((await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === 'Bob').given.length, 0);
|
||||
// a child chooses the reward they are aiming for
|
||||
const rcy = await j(await post('/reward', { title: 'Cy only', points: 4, for: [P.c.id] }));
|
||||
const goalOf = async who => (await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === who).goal;
|
||||
assert.equal((await post('/goal', { reward: r50.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r50.id, 'Bob is aiming for the trip');
|
||||
assert.equal((await post('/goal', { reward: rcy.id }, 'b')).status, 400, 'not one that is only for Cy'); assert.equal((await post('/goal', { reward: 'nope' }, 'b')).status, 400);
|
||||
assert.equal((await post('/goal', { reward: r5.id, user: P.c.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r5.id, 'a child only ever sets their own'); assert.equal(await goalOf('Cy'), null);
|
||||
assert.equal((await post('/goal', { reward: rcy.id, user: P.c.id }, 'a')).status, 200); assert.equal(await goalOf('Cy'), rcy.id, 'an admin can choose for a child');
|
||||
assert.equal((await post('/goal', { reward: r5.id }, 'a')).status, 400, 'goals are for children');
|
||||
const tmp = await j(await post('/reward', { title: 'Temp', points: 9 })); await post('/goal', { reward: tmp.id }, 'b'); assert.equal(await goalOf('Bob'), tmp.id); await post('/reward', { id: tmp.id, remove: true }); assert.equal(await goalOf('Bob'), null, 'a removed reward is no longer anyone\'s goal');
|
||||
await post('/goal', { reward: r5.id }, 'b'); assert.equal((await post('/goal', { reward: '' }, 'b')).status, 200); assert.equal(await goalOf('Bob'), null, 'no reward clears it');
|
||||
assert.equal((await j(await call('/', {}, 'b'))).me.kid, true, 'the page knows who is a child');
|
||||
// a goal is for one week and starts empty each Monday; a child with none is reminded once
|
||||
const wkGoal = async (who, date) => (await j(await call('/week?date=' + date, {}, 'a'))).people.find(x => x.name === who).goal, thisMon = (await j(await call('/week?date=' + today, {}, 'a'))).from;
|
||||
await post('/goal', { reward: r5.id }, 'b'); assert.equal(await wkGoal('Bob', today), r5.id); assert.equal(await wkGoal('Bob', chores.addDays(today, 7)), null, 'next week starts empty'); assert.equal(await wkGoal('Bob', chores.addDays(today, -7)), null, 'and last week had none');
|
||||
assert.equal(chores.goalNeeded(P.b.id, today), null, 'a goal is chosen: no reminder'); await post('/goal', { reward: '' }, 'b');
|
||||
assert.equal(chores.goalNeeded(P.b.id, today), thisMon, 'no goal yet: a reminder is due'); assert.equal(chores.goalNeeded(P.a.id, today), null, 'not for adults');
|
||||
const later = chores.addDays(thisMon, 14), at = new Date(Date.parse(later + 'T10:00:00Z')), mine = () => inbox.load(P.b.id).filter(i => i.tag === 'goal:' + later);
|
||||
await push.tick(at, true); assert.equal(mine().length, 1, 'a child with no goal is reminded in the daytime'); assert.equal(inbox.load(P.a.id).some(i => /^goal:/.test(i.tag)), false, 'not an adult');
|
||||
await push.tick(new Date(at.getTime() + 60e3), true); assert.equal(mine()[0].count, 1, 'only once a week');
|
||||
await post('/goal', { reward: '', user: P.c.id }); await push.tick(new Date(Date.parse(thisMon + 'T10:00:00Z') + 2 * 864e5), true); const cyRem = () => inbox.load(P.c.id).filter(i => i.tag === 'goal:' + thisMon); assert.equal(cyRem().length, 1); assert.equal(cyRem()[0].read, false);
|
||||
await post('/goal', { reward: rcy.id }, 'c'); assert.equal(cyRem()[0].read, true, 'choosing a goal settles the reminder');
|
||||
const early = new Date(Date.parse(chores.addDays(later, 7) + 'T03:00:00Z')); await push.tick(early, true); assert.equal(inbox.load(P.b.id).some(i => i.tag === 'goal:' + chores.addDays(later, 7)), false, 'not in the middle of the night');
|
||||
// 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);
|
||||
|
||||
@@ -22,6 +22,9 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
|
||||
const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back');
|
||||
// calendar birthdays
|
||||
const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday");
|
||||
// anniversaries and gift ideas
|
||||
{ const r = await post('/', { name: 'Ann Iversary', anniversary: '2010-08-12', gifts: 'Scarf\nBook' }); const a = await r.json(); assert.equal(a.anniversary, '2010-08-12'); assert.equal(a.gifts, 'Scarf\nBook');
|
||||
const ev = contacts.birthdayEvents({ fd: '2026-08-01', td: '2026-08-31' }, people.a.id).filter(e => e.contactId === a.id); assert.equal(ev.length, 1); assert.equal(ev[0].title, "Ann Iversary's anniversary (16)"); assert.equal(ev[0].start, '2026-08-12'); }
|
||||
const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known');
|
||||
assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0);
|
||||
// each person has their own book
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// The browser side, without a browser: every script the page loads is run in order in one shared scope (as the browser does), against a stand-in for the page.
|
||||
// This catches what only showed up in the interface before: a syntax error, a name declared twice by two files (which stops the second file and everything after it),
|
||||
// a script that throws as it loads, a missing file, and a script listed in index.html twice or not at all.
|
||||
const fs = require('fs'), vm = require('vm'), path = require('path'), assert = require('assert');
|
||||
const PUB = path.join(__dirname, '..', 'public');
|
||||
const html = fs.readFileSync(path.join(PUB, 'index.html'), 'utf8');
|
||||
const listed = [...html.matchAll(/<script[^>]*\ssrc="(\/[\w.-]+\.js)"/g)].map(m => m[1]);
|
||||
assert.ok(listed.length > 40, 'index.html lists the scripts');
|
||||
assert.equal(new Set(listed).size, listed.length, 'no script is listed twice');
|
||||
for (const f of listed) assert.ok(fs.existsSync(path.join(PUB, f)), f + ' is listed but missing');
|
||||
const unlisted = fs.readdirSync(PUB).filter(f => f.endsWith('.js') && !listed.includes('/' + f) && !/^(sw|login|board|device-ui|webauthn)\.js$/.test(f)); // (the service worker and the other pages have their own)
|
||||
assert.deepEqual(unlisted, [], 'every script in public/ is loaded by the page (or is one of the known others)');
|
||||
|
||||
// a stand-in for the browser that answers anything: any property is another stand-in, calling it gives one, awaiting it gives it back
|
||||
const stub = () => { const f = function () {}; const p = new Proxy(f, { get: (t, k) => k === Symbol.toPrimitive ? () => '' : k === 'then' ? undefined : k === 'length' ? 0 : k === Symbol.iterator ? function* () {} : p, apply: () => p, construct: () => p, set: () => true, has: () => true }); return p; };
|
||||
const store = {}, win = {};
|
||||
const ctx = Object.assign(win, {
|
||||
console: { log() {}, warn() {}, error() {}, info() {}, debug() {} }, document: stub(), navigator: Object.assign(stub(), { onLine: true, userAgent: 'test' }), location: { hash: '', href: 'http://x/', origin: 'http://x', pathname: '/', search: '', reload() {}, replace() {} }, history: stub(),
|
||||
localStorage: { getItem: k => store[k] ?? null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }, sessionStorage: stub(), matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
|
||||
fetch: async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '', headers: { get: () => '' } }), addEventListener() {}, removeEventListener() {}, setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, requestAnimationFrame: () => 0,
|
||||
Audio: function () { return stub(); }, Image: function () { return stub(); }, MutationObserver: function () { return { observe() {}, disconnect() {} }; }, IntersectionObserver: function () { return { observe() {}, disconnect() {}, unobserve() {} }; }, ResizeObserver: function () { return { observe() {}, disconnect() {} }; },
|
||||
URL, URLSearchParams, Blob, TextEncoder, TextDecoder, AbortController, Intl, Date, JSON, Math, Promise, Set, Map, WeakMap, Array, Object, String, Number, RegExp, Error, Symbol, Uint8Array, ArrayBuffer, DataView, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN, structuredClone, atob, btoa,
|
||||
screen: {}, innerWidth: 1000, innerHeight: 800, devicePixelRatio: 1, performance: { now: () => 0 }, crypto: stub(), indexedDB: undefined, caches: stub(), Notification: stub(), alert() {}, confirm: () => false, prompt: () => null, getComputedStyle: () => stub(), CSS: stub(),
|
||||
});
|
||||
ctx.window = ctx; ctx.self = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
|
||||
process.on('unhandledRejection', () => {}); // (boot.js starts the app against the stand-in; what it does after loading is not under test)
|
||||
const failures = [];
|
||||
for (const f of listed) {
|
||||
const file = path.join(PUB, f), src = fs.readFileSync(file, 'utf8');
|
||||
try { new vm.Script(src, { filename: f }); } catch (e) { failures.push(`${f}: syntax error: ${e.message}`); continue; }
|
||||
try { vm.runInContext(src, ctx, { filename: f }); } catch (e) { failures.push(`${f}: ${e.name}: ${e.message}`); }
|
||||
}
|
||||
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
||||
// the pieces the app calls by name across files are there
|
||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||
// the watch-stats counting (a pure function)
|
||||
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
|
||||
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
|
||||
assert.deepEqual([a.week.n, a.week.min, a.month.n, a.year.n], [2, 150, 3, 4], 'week, month and year counts and minutes'); assert.deepEqual(a.genres[0], ['Drama', 3]); assert.equal(a.days[12], 1); assert.equal(a.days[10], 1); assert.equal(a.days.reduce((x, y) => x + y, 0), 2, 'the 14-day strip'); }
|
||||
// changes made offline: only the safe kinds are queued, in order, and tasks show them while they wait
|
||||
{ const run = c => vm.runInContext(c, ctx); run("ME = { id: 'u1' }"); const body = o => JSON.stringify(o);
|
||||
assert.equal(run("offQueued('/api/tasks', { method: 'GET' })"), null, 'a read is not queued'); assert.equal(run(`offQueued('/api/chat/send', { method: 'POST', body: ${JSON.stringify(body({ text: 'hi' }))} })`), null, 'a chat message is not one of the safe kinds');
|
||||
assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 'x', fields: { title: 'edit' } }))} })`), null, 'editing an existing task is not queued (it could clash)');
|
||||
assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', fields: { title: 'Buy milk', due: '' } }))} }).queued`), true); run(`offQueued('/api/tasks/toggle', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 't1', done: true }))} })`);
|
||||
assert.equal(run('offPending()'), 2); const shown = run("offApplyTasks([{ id: 't1', title: 'Old', done: false, list: 'local:inbox' }])"); assert.deepEqual(shown.map(t => [t.title, t.done]), [['Old', true], ['Buy milk', false]], 'a queued tick and a queued new task show right away');
|
||||
assert.equal(run("offqRead().length"), 2); run("localStorage.removeItem('offq:u1')"); assert.equal(run('offPending()'), 0); }
|
||||
console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0);
|
||||
+12
-1
@@ -14,7 +14,18 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
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');
|
||||
await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0);
|
||||
// reading or clearing a notification settles the chat itself, and opening the chat settles the notification
|
||||
const unr = async () => (await j(await call('/api/chat/status', {}, 'b'))).unread;
|
||||
assert.equal(await unr(), 0, 'clearing the notifications read the chat');
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'one more' }, 'a'); assert.equal(await unr(), 1); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 1);
|
||||
await post('/api/chat/read', { id: dm.id }, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 0, 'opening the chat settled its notification');
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'last' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(await unr(), 1);
|
||||
await post('/api/inbox/read', { id: r.items[0].id }, 'b'); assert.equal(await unr(), 0, 'marking one notification read read its chat');
|
||||
await post('/api/chat/send', { conv: dm.id, text: 'and again' }, 'a'); assert.equal(await unr(), 1);
|
||||
await post('/api/inbox/read', { all: true }, 'b'); assert.equal(await unr(), 0, 'mark all read read the chats too');
|
||||
await post('/api/chat/announcement', { text: 'Again' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.ok(r.items[0].ref && r.items[0].ref.announce, 'an announcement knows which one it was');
|
||||
console.log('inbox ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Listening stats: time listened is counted from progress reports (seeks are not), per day and kind; finished items and the day streak are worked out.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-listen-'));
|
||||
const users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), progress = require('../progress');
|
||||
const pod = progress('podcast-progress.json', 'podcast'), ab = progress('audiobook-progress.json', 'audiobook');
|
||||
users.runAs(me, () => {
|
||||
const t0 = Date.now() - 60e3;
|
||||
pod.merge({ e1: { pos: 100, dur: 3000, t: t0 } }); pod.merge({ e1: { pos: 130, dur: 3000, t: t0 + 30e3 } }); // 30 s listened in 30 s
|
||||
pod.merge({ e1: { pos: 1500, dur: 3000, t: t0 + 40e3 } }); // a seek: not counted
|
||||
ab.merge({ b1: { pos: 7000, dur: 7100, t: t0 } }); ab.merge({ b1: { pos: 7090, dur: 7100, t: t0 + 50e3 } }); // 50 s counted at most, and the book is finished
|
||||
let s = progress.stats(); assert.equal(Math.round(s.podcast), 30, 'only real listening counts'); assert.ok(s.audiobook > 0 && s.audiobook <= 55); assert.equal(s.finishedYear, 1); assert.equal(s.finishedAll, 1);
|
||||
pod.merge({ e1: { pos: 1530, dur: 3000, t: t0 + 70e3 } }); s = progress.stats(); assert.equal(s.streak, 1, 'a minute or more makes a streak day');
|
||||
const old = progress('podcast-progress.json', 'podcast'); old.merge({ e1: { pos: 5, dur: 3000, t: t0 - 1e3 } }); assert.equal(progress.stats().podcast, s.podcast, 'an older report changes nothing');
|
||||
// e-books count too, and the family challenge adds everyone up
|
||||
progress.markFinished('ebook:abc', 'ebook'); progress.markFinished('ebook:abc', 'ebook'); s = progress.stats(); assert.equal(s.ebooksYear, 1, 'an e-book counts once'); assert.equal(s.audiobooksYear, 1); assert.equal(s.finishedYear, 2, 'audiobooks and e-books together');
|
||||
assert.equal(progress.familyGoal().finished, 2); assert.equal(progress.familyGoal().goal, 0); assert.equal(progress.setFamilyGoal(40).goal, 40); assert.equal(progress.setFamilyGoal(-5).goal, 0, 'no negative goals');
|
||||
const bo = users.create({ name: 'Bo', role: 'user', handle: 'ZP' }); users.runAs(bo, () => progress.markFinished('ebook:zzz', 'ebook')); progress.setFamilyGoal(10); const fg = progress.familyGoal(); assert.equal(fg.finished, 3, 'everybody\'s books are counted'); assert.deepEqual(fg.people.map(p => p.name + p.n), ['Ann2', 'Bo1']);
|
||||
console.log('listening test ok');
|
||||
});
|
||||
@@ -109,6 +109,19 @@ const SECRET = 'mail-test-secret-'.repeat(3);
|
||||
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && /~M2$/.test(r.json.items[0].id), r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status);
|
||||
// unified inbox, snooze and templates
|
||||
r = await call('unate', '/api/mail/messages?folder=unified'); ok('the unified inbox lists the newest of every account together', r.status === 200 && r.json.items.length === 2 && r.json.items.every(m => m.account && m.accountId) && r.json.more === false, r.json);
|
||||
const sid = r.json.items[0].id; r = await post('unate', '/api/mail/snooze', { id: sid, until: Date.now() - 1000 }); ok('snoozing needs a future time', r.status === 400, r.status);
|
||||
r = await post('unate', '/api/mail/snooze', { id: sid, until: Date.now() + 3600e3, subject: 'Hello', from: 'Ann' }); ok('a message can be snoozed', r.status === 200, r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('a snoozed message leaves the inbox', r.json.items.length === 1 && !r.json.items.some(m => m.id === sid), r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=unified'); ok('and the unified inbox', r.json.items.length === 1, r.json);
|
||||
r = await call('unate', '/api/mail/snoozed'); ok('the snoozed list says what and until when', r.json.length === 1 && r.json[0].id === sid && r.json[0].subject === 'Hello', r.json);
|
||||
r = await post('unate', '/api/mail/unsnooze', { id: sid }); r = await call('unate', '/api/mail/messages?folder=inbox'); ok('unsnoozing brings it back', r.json.items.length === 2, r.json);
|
||||
r = await post('unate', '/api/mail/templates', { name: 'Thanks', subject: 'Thank you', body: 'Thanks so much!\nNate' }); ok('a template can be saved', r.status === 200 && r.json.length === 1 && r.json[0].name === 'Thanks' && r.json[0].id, r.json);
|
||||
const tid = r.json[0].id; r = await post('unate', '/api/mail/templates', { id: tid, name: 'Thanks!', subject: 'Thank you', body: 'Hi' }); ok('and changed', r.json.length === 1 && r.json[0].name === 'Thanks!' && r.json[0].body === 'Hi', r.json);
|
||||
r = await post('unate', '/api/mail/templates', { name: ' ' }); ok('a template needs a name', r.status === 400, r.status);
|
||||
r = await call('uann', '/api/mail/templates'); ok('templates are private to a person', r.status === 200 || r.status === 409 ? !(r.json || []).length : false, r);
|
||||
r = await post('unate', '/api/mail/templates/delete', { id: tid }); ok('and deleted', r.json.length === 0, r.json);
|
||||
r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json;
|
||||
ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
|
||||
ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Movies & TV for the family: favorites are per person; Movie night is one shared list with votes.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mf-'));
|
||||
const users = require('../users'), mf = require('../moviefam');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['video'], handle: 'ZP' });
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => mf.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/moviefam', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = (p, who) => fetch(base + p, { headers: who ? { 'x-user': who } : {} });
|
||||
const up = { id: '101', title: 'Up', year: 2009, type: 'movie', thumb: '/library/metadata/101/thumb/17' };
|
||||
try {
|
||||
// favorites
|
||||
await post('/fav', { ...up, on: true }); assert.deepEqual((await j(await get('/favs'))).items.map(x => x.title), ['Up']); assert.equal((await j(await get('/favs', 'bo'))).items.length, 0, 'favorites are personal');
|
||||
await post('/fav', { ...up, on: false }); assert.equal((await j(await get('/favs'))).items.length, 0);
|
||||
assert.equal((await post('/fav', { id: '../x', title: 'x', on: true })).status, 400, 'a bad id is refused');
|
||||
// a poster path that is not ours is dropped
|
||||
await post('/fav', { ...up, thumb: 'https://evil.example/x.jpg', on: true }); assert.equal((await j(await get('/favs'))).items[0].thumb, '');
|
||||
// movie night
|
||||
let r = await j(await post('/night/add', up)); assert.equal(r.items[0].votes, 1, 'the person who adds it votes for it'); assert.equal((await post('/night/add', up)).status, 400, 'once only');
|
||||
await post('/night/add', { id: '102', title: 'Ratatouille', year: 2007 }, 'bo'); r = await j(await post('/night/vote', { id: '102' }));
|
||||
assert.equal(r.items[0].title, 'Ratatouille', 'the one with more votes comes first'); assert.deepEqual(r.items[0].voters.sort(), ['Ann', 'Bo']);
|
||||
r = await j(await post('/night/vote', { id: '102' })); assert.equal(r.items.find(x => x.id === '102').votes, 1, 'voting again takes the vote back');
|
||||
assert.equal((await post('/night/remove', { id: '101' }, 'bo')).status, 403, 'only the adder or an admin removes'); r = await j(await post('/night/remove', { id: '102' })); assert.equal(r.items.length, 1, 'an admin can remove anything');
|
||||
assert.equal((await post('/night/clear', {}, 'bo')).status, 403); assert.equal((await j(await post('/night/clear', {}))).items.length, 0);
|
||||
console.log('moviefam test ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close();
|
||||
});
|
||||
@@ -47,6 +47,11 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
|
||||
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json(), { grants: [] }); assert.equal((await post(`/ext/${ext.id}/install`, {}, bob)).status, 200);
|
||||
assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['tasks.read', 'tasks.read', 'root.everything'] }, bob)).json()).grants, ['tasks.read'], 'only known permissions are kept');
|
||||
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`)).json(), { grants: [] }, 'they are per person'); await post(`/ext/${ext.id}/uninstall`, {}, bob); assert.deepEqual((await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json()).grants, [], 'removing the extension takes them back');
|
||||
// the newer permissions, and "updated since you last looked"
|
||||
await post(`/ext/${ext.id}/install`, {}, bob); assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['notes.read', 'notes.write', 'recipes.read', 'music.read', 'tasks.read', 'notes.delete'] }, bob)).json()).grants.sort(), ['music.read', 'notes.read', 'notes.write', 'recipes.read', 'tasks.read'], 'notes, recipes and music can be granted; made-up ones cannot');
|
||||
let li = await (await fetch(`${B}/items`, { headers: bob })).json(); assert.equal(li.seen[ext.id], li.items.find(x => x.id === ext.id).updated, 'installing marks this version as seen');
|
||||
await new Promise(r => setTimeout(r, 15)); await post('/items', { id: ext.id, title: ext.title, version: '2.0' }); li = await (await fetch(`${B}/items`, { headers: bob })).json(); assert.ok(li.items.find(x => x.id === ext.id).updated > li.seen[ext.id], 'a new version is newer than the one you saw');
|
||||
li = await (await post(`/ext/${ext.id}/seen`, {}, bob)).json(); assert.equal(li.seen[ext.id], (await (await fetch(`${B}/items`, { headers: bob })).json()).items.find(x => x.id === ext.id).updated, 'and looking at it clears that'); await post(`/ext/${ext.id}/uninstall`, {}, bob);
|
||||
// a site (a link, optionally shown inside the hub)
|
||||
assert.equal((await post('/items', { kind: 'link', title: 'Site' })).status, 400, 'a site needs an address'); let site = await (await post('/items', { kind: 'link', title: 'Docs', url: 'https://example.com/', embed: true, icon: 'D' })).json(); assert.equal(site.embed, true); assert.equal(site.icon, 'D');
|
||||
assert.equal((await put(`/items/${site.id}/files?name=a.html`, 'x')).status, 400, 'a site has no files');
|
||||
|
||||
+18
-2
@@ -37,8 +37,8 @@ const users = require('../users'), push = require('../push');
|
||||
|
||||
// events: a local event starting in 10 minutes
|
||||
const soon = new Date(Date.now() + 10 * 60e3), pad = n => String(n).padStart(2, '0');
|
||||
const st = `${soon.getUTCFullYear()}-${pad(soon.getUTCMonth() + 1)}-${pad(soon.getUTCDate())}T${pad(soon.getUTCHours())}:${pad(soon.getUTCMinutes())}`;
|
||||
fs.writeFileSync(path.join(d, 'events.json'), JSON.stringify([{ uid: 'e1', fields: { title: 'Dentist', location: 'Main St', notes: '', allDay: false, start: st, end: st.replace(/T(\d\d)/, (m, h) => 'T' + pad((+h + 1) % 24)), tz: 'UTC', repeat: 'none' }, created: 1, updated: 1 }]));
|
||||
const utc = d => d.toISOString().slice(0, 16), st = utc(soon), en = utc(new Date(soon.getTime() + 36e5)); // (the end is built from the real time, so an event just before midnight still ends after it starts)
|
||||
fs.writeFileSync(path.join(d, 'events.json'), JSON.stringify([{ uid: 'e1', fields: { title: 'Dentist', location: 'Main St', notes: '', allDay: false, start: st, end: en, tz: 'UTC', repeat: 'none' }, created: 1, updated: 1 }]));
|
||||
writePush({ tasks: false }); sent.length = 0;
|
||||
await push.tick(new Date(Date.now() - 3 * 36e5), true);
|
||||
ok('an event hours away sends nothing', sent.length === 0, sent);
|
||||
@@ -58,6 +58,22 @@ const users = require('../users'), push = require('../push');
|
||||
ok('and only once for that stage', sent.length === n1, sent.length);
|
||||
ok('an ordinary person is not told about it', !sent.some(s => /Mail sign-in/.test(s.title) && !s.sub.endpoint.endsWith('/admin')), sent);
|
||||
|
||||
// birthdays and anniversaries from Contacts come with the morning summary, once a day
|
||||
{ const k = users.create({ name: 'Kim', role: 'user', tabs: ['contacts'] }), kd = users.dirOf(k.id); sent.length = 0;
|
||||
fs.writeFileSync(path.join(kd, 'contacts.json'), JSON.stringify([{ id: 'c1', name: 'Jo', birthday: '--10-22', anniversary: '2010-10-22', emails: [], phones: [] }, { id: 'c2', name: 'Al', birthday: '--03-01', emails: [], phones: [] }]));
|
||||
fs.writeFileSync(path.join(kd, 'push.json'), JSON.stringify({ tz: TZ, tasksAt: '08:00', tasks: true, events: false, subs: [{ sub: { endpoint: 'https://push.example/kim', keys: { p256dh: 'x', auth: 'y' } } }] }));
|
||||
await push.tick(now); const mine = sent.filter(x => x.sub.endpoint.endsWith('/kim'));
|
||||
ok('a birthday and an anniversary today make one notification', mine.length === 1 && /2 birthdays and anniversaries/.test(mine[0].title) && /Jo's birthday/.test(mine[0].body) && /anniversary \(16\)/.test(mine[0].body), mine);
|
||||
await push.tick(now); ok('and not again the same day', sent.filter(x => x.sub.endpoint.endsWith('/kim')).length === 1); }
|
||||
|
||||
// a task with a due time gets a reminder when that time comes, once
|
||||
{ const l = users.create({ name: 'Lee', role: 'user', tabs: ['tasks'] }), ld = users.dirOf(l.id); sent.length = 0;
|
||||
fs.writeFileSync(path.join(ld, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 'a', list: 'inbox', title: 'Call the vet', due: day, dueTime: '08:30', done: false }, { id: 'b', list: 'inbox', title: 'Later today', due: day, dueTime: '17:00', done: false }, { id: 'c', list: 'inbox', title: 'Done already', due: day, dueTime: '08:00', done: true }] }));
|
||||
fs.writeFileSync(path.join(ld, 'push.json'), JSON.stringify({ tz: TZ, tasksAt: '23:00', tasks: true, events: false, subs: [{ sub: { endpoint: 'https://push.example/lee', keys: { p256dh: 'x', auth: 'y' } } }] }));
|
||||
await push.tick(now, true); const lee = () => sent.filter(x => x.sub.endpoint.endsWith('/lee'));
|
||||
ok('a task whose time has come is reminded, with its time', lee().length === 1 && lee()[0].title === 'Call the vet' && /08:30/.test(lee()[0].body), lee());
|
||||
await push.tick(new Date(now.getTime() + 6 * 60e3), true); ok('once only; later and finished tasks are left alone', lee().length === 1, lee()); }
|
||||
|
||||
// a subscription the push service says is gone is dropped
|
||||
dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] }));
|
||||
await push.tick(new Date());
|
||||
|
||||
@@ -55,6 +55,23 @@ const fake = http.createServer((req, res) => {
|
||||
j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too');
|
||||
// an older Mealie that only knows the list form of "add recipe"
|
||||
newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe'));
|
||||
// meal plan: shared, by day; the week's ingredients go on the shopping list
|
||||
assert.equal((await post('/plan/add', { date: 'soon', slug: 'bread' })).status, 400, 'a real day is needed');
|
||||
await post('/plan/add', { date: '2027-03-02', slug: 'bread', name: 'Bread', meal: 'dinner' }); await post('/plan/add', { date: '2027-03-04', slug: 'bread', name: 'Bread', meal: 'lunch' }); await post('/plan/add', { date: '2027-03-20', slug: 'bread', name: 'Bread' });
|
||||
j = await (await get('/plan?from=2027-03-02&to=2027-03-08')).json(); assert.deepEqual(Object.keys(j), ['2027-03-02', '2027-03-04'], 'only the days asked for'); assert.equal(j['2027-03-04'][0].meal, 'lunch');
|
||||
j = await (await post('/plan/shop', { from: '2027-03-02', to: '2027-03-08' })).json(); assert.equal(j.ok, true); assert.equal(j.recipes, 1, 'a recipe planned twice is added once, twice over');
|
||||
assert.equal((await post('/plan/shop', { from: '2027-04-01', to: '2027-04-07' })).status, 400, 'nothing planned, nothing to add');
|
||||
await post('/plan/remove', { id: (await (await get('/plan?from=2027-03-02&to=2027-03-02')).json())['2027-03-02'][0].id }); assert.deepEqual(await (await get('/plan?from=2027-03-02&to=2027-03-02')).json(), {});
|
||||
// the pantry: items with a count and a low level; the plan flags planned ingredients you have ("3 cups flour" is covered by "flour"; butter is not in the pantry)
|
||||
await post('/plan/add', { date: '2027-05-03', slug: 'bread', name: 'Bread' });
|
||||
assert.equal((await post('/pantry/item', { name: ' ' })).status, 400, 'an item needs a name');
|
||||
j = await (await post('/pantry/item', { name: 'Flour', qty: 3, low: 1 })).json(); assert.deepEqual(j.items.map(x => [x.name, x.qty, x.low, x.isLow]), [['Flour', 3, 1, false]]); const fid = j.items[0].id;
|
||||
j = await (await post('/pantry/item', { name: 'salt', qty: '', low: '' })).json(); assert.equal(j.items.find(x => x.name === 'salt').qty, null, 'an item need not be counted'); assert.equal(j.items.find(x => x.name === 'salt').isLow, false);
|
||||
j = await (await post('/pantry/item', { name: 'FLOUR', qty: 3, low: 1 })).json(); assert.equal(j.items.filter(x => x.name.toLowerCase() === 'flour').length, 1, 'the same name updates the item');
|
||||
j = await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json(); assert.deepEqual(j.items.map(i => i.text), ['3 cups flour']); assert.equal(j.items[0].have.toLowerCase(), 'flour');
|
||||
j = await (await post('/pantry/adjust', { id: fid, delta: -2 })).json(); assert.equal(j.items[0].qty, 1); assert.equal(j.items[0].isLow, true, 'at the low level counts as running low'); assert.equal(j.items[0].name, 'Flour', 'low ones come first');
|
||||
j = await (await post('/pantry/adjust', { id: fid, delta: -5 })).json(); assert.equal(j.items[0].qty, 0, 'never below none'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
|
||||
await post('/pantry/delete', { id: fid }); j = await (await get('/pantry')).json(); assert.deepEqual(j.items.map(x => x.name), ['salt']);
|
||||
// a wrong token is explained
|
||||
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
||||
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||
|
||||
@@ -45,6 +45,17 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
r = await call(`/api/tasks/due?from=${plus(0)}&to=${plus(7)}`);
|
||||
ok('the calendar gets open tasks due in the range (not done ones, subtasks or other dates)', r.status === 200 && r.json.length === 1 && r.json[0].title === 'Due soon' && r.json[0].repeat === 'weekly' && r.json[0].listName, r.json);
|
||||
r = await call('/api/tasks/due?from=bad&to=worse'); ok('a bad date range is refused', r.status === 400, r.status);
|
||||
// a due time rides along with the due date (and only with one)
|
||||
await call('/api/tasks', { list: L, fields: { title: 'At noon', notes: '', due: plus(1), dueTime: '12:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Time but no date', notes: '', due: '', dueTime: '09:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Bad time', notes: '', due: plus(1), dueTime: '25:99', done: false } });
|
||||
ts = await list(); ok('a due time is kept with its date', ts.find(t => t.title === 'At noon').dueTime === '12:00', ts.find(t => t.title === 'At noon')); ok('a time with no date is dropped', ts.find(t => t.title === 'Time but no date').dueTime === ''); ok('and a time that is not a time', ts.find(t => t.title === 'Bad time').dueTime === '');
|
||||
// subtasks on iCloud lists: a VTODO that points at its parent (RELATED-TO), kept through edits and ticking
|
||||
{ const ics = require('../ics'), { parseTodos } = require('../tasks');
|
||||
const main = ics.buildTodoCalendar([{ uid: 'p1@x', fields: { title: 'Trip', notes: '', due: '', done: false } }, { uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]);
|
||||
let ts = parseTodos(main); ok('a subtask is written with its parent and read back with it', ts.length === 2 && ts[0].parent === '' && ts[1].parent === 'p1@x', ts);
|
||||
const edited = ics.patchTodo(ics.buildTodoCalendar([{ uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]), { title: 'Book flights now', notes: '', due: '', done: true });
|
||||
ok('ticking or editing it keeps its parent', parseTodos(edited)[0].parent === 'p1@x' && parseTodos(edited)[0].done === true, parseTodos(edited));
|
||||
const moved = ics.patchTodo(edited, { title: 'x', notes: '', due: '', done: false, parent: '' }); ok('and an empty parent makes it a main task again', parseTodos(moved)[0].parent === '', parseTodos(moved));
|
||||
const foreign = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:k@x\r\nSUMMARY:Kid\r\nRELATED-TO;RELTYPE=CHILD:z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; ok('a link that points down (CHILD) is not a parent', parseTodos(foreign)[0].parent === ''); }
|
||||
ok('the server is still running', child.exitCode === null);
|
||||
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
|
||||
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -110,9 +110,14 @@ async function fetchAudio(src, cookie, tmp) {
|
||||
let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); });
|
||||
await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 }));
|
||||
}
|
||||
// TRANSCRIBE_HOURS=22-6 keeps the (slow, CPU-hungry) speech-to-text to the night: a job waits in the queue until that window opens (local time, so set TZ). A job that has started finishes even if the window closes. Empty = any time.
|
||||
const NIGHT = (process.env.TRANSCRIBE_HOURS || '').match(/^(\d{1,2})\s*-\s*(\d{1,2})$/);
|
||||
const inWindow = () => { if (!NIGHT) return true; const a = +NIGHT[1], b = +NIGHT[2], h = new Date().getHours(); return a === b || (a < b ? h >= a && h < b : h >= a || h < b); };
|
||||
async function untilWindow() { while (!inWindow()) await new Promise(r => setTimeout(r, 60e3)); }
|
||||
function startJob(key, src, cookie, getAudio, ep) {
|
||||
jobs.set(key, { state: 'running', since: Date.now() });
|
||||
chain = chain.then(async () => {
|
||||
await untilWindow();
|
||||
const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key);
|
||||
try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(ep ? { ...r, ep } : r)); jobs.delete(key); }
|
||||
catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); }
|
||||
|
||||
@@ -63,6 +63,7 @@ function update(id, patch) {
|
||||
if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; }
|
||||
if (patch.board !== undefined) { if (patch.board === false) u.board = false; else delete u.board; } // whether they are on the family board and in family chore progress
|
||||
if (patch.simple !== undefined) { if (patch.simple) u.simple = true; else delete u.simple; } /* simple mode: a big-button layout with few choices */
|
||||
if (patch.color !== undefined) { if (/^#[0-9a-f]{6}$/i.test(patch.color)) u.color = patch.color.toLowerCase(); else delete u.color; } // the person's own colour on shared calendars
|
||||
if (patch.signOutEverywhere) u.epoch++;
|
||||
save(); return u;
|
||||
}
|
||||
@@ -76,7 +77,10 @@ function remove(id) {
|
||||
|
||||
const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab));
|
||||
const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))];
|
||||
const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple });
|
||||
/** The colour a person's events and name show in on shared calendars: their own pick, or one of a set by the order accounts were made. */
|
||||
const PERSON_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d'];
|
||||
const colorOf = u => !u ? PERSON_COLORS[0] : u.color || PERSON_COLORS[Math.max(0, all().findIndex(x => x.id === u.id)) % PERSON_COLORS.length];
|
||||
const publicView = u => ({ id: u.id, color: colorOf(u), name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple });
|
||||
|
||||
// ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings,
|
||||
// iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.)
|
||||
@@ -93,4 +97,4 @@ function migrateLegacy() {
|
||||
}
|
||||
migrateLegacy();
|
||||
|
||||
module.exports = { TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };
|
||||
module.exports = { PERSON_COLORS, colorOf, TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };
|
||||
Reference in new issue
Block a user