Calendar: several NE Hub calendars with colors, per-person view/edit sharing and per-calendar subscribe links; Movies & TV: converted video keeps the right time and seeks inside what the stream has

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 12:38:15 -04:00
1 parent d25eae5920
commit d813884074
8 files changed
+343 -46

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ jobs:
- run: node tools/tasks-test.js # repeating tasks and subtasks - run: node tools/tasks-test.js # repeating tasks and subtasks
- run: node tools/push-test.js # reminders: when they are sent, never twice - run: node tools/push-test.js # reminders: when they are sent, never twice
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration - run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
- run: node tools/cal-test.js # calendars: several per person, colors, sharing view/edit, subscribe links
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself - run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery - run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
- run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered - run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered
+2 -4
View File
@@ -36,10 +36,8 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
breadcrumb** to move them (hold Alt/Option to copy). **Drag a single file out to your desktop** to download it (Chrome and Edge only; browsers cannot drag out folders breadcrumb** to move them (hold Alt/Option to copy). **Drag a single file out to your desktop** to download it (Chrome and Edge only; browsers cannot drag out folders
or several files). Also share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation. or several files). Also share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
- **Calendar**: month grid and agenda. Add, edit and delete events: - **Calendar**: month grid and agenda. Add, edit and delete events:
- **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link - **NE Hub calendars**: make as many as you like (Manage calendars -> New calendar), each with its own name and color (a picker; iCloud, linked and shared calendars can be recolored too). Events carry their calendar's color everywhere and you pick the calendar when adding one. Older events stay in the first calendar.
(Manage calendars -> copy link) so Outlook, Google or Apple Calendar can show them (read-only there). - **Sharing**: a calendar is private until you share it. In its Edit sheet, choose per person whether they can **view** or **edit**; the server enforces it. Each calendar also has its own private subscribe link (copy or make a new one), and there is still one link for all of yours, so Outlook, Google or Apple Calendar can show them (read-only there).
- **iCloud (two-way)**: connect with an Apple ID and an *app-specific password* (appleid.apple.com). Credentials are
encrypted in `/data/icloud.json`; only `*.icloud.com` is contacted. Edits keep alarms and attendees intact.
- **ICS links** (Google secret address, Outlook/M365 published calendar) are read-only. - **ICS links** (Google secret address, Outlook/M365 published calendar) are read-only.
- **Tasks**: to-do lists stored in NE Hub (`/data/tasks.json`), with due dates, notes and completion. If the connected iCloud - **Tasks**: to-do lists stored in NE Hub (`/data/tasks.json`), with due dates, notes and completion. If the connected iCloud
account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded
+148 -27
View File
@@ -222,60 +222,173 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD
return out; return out;
} }
// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed. // ---- NE Hub calendars: any number per person, stored here, shared by choice, and published as subscribe feeds.
const EV_FILE = 'events.json', FEED_FILE = 'feed.json'; const EV_FILE = 'events.json', FEED_FILE = 'feed.json', NCAL_FILE = 'ncals.json', COLOR_FILE = 'calcolors.json';
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 }); const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
const loadLocal = () => readJ(here(EV_FILE), []); const HEX = /^#[0-9a-f]{6}$/i;
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' }; const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
const dirFor = id => users.dirOf(id);
const myId = () => users.current().id;
/** 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) {
const l = readJ(path.join(dir, NCAL_FILE), null);
return Array.isArray(l) && l.length ? l : [{ id: 'local', name: 'NE Hub', color: DEFAULT_COLOR, shares: [] }];
}
const saveCals = v => writeJ(NCAL_FILE, v);
const eventsIn = dir => readJ(path.join(dir, EV_FILE), []);
const saveEvents = (dir, v) => atomicWrite(path.join(dir, EV_FILE), JSON.stringify(v, null, 2), { mode: 0o600 });
const evCal = e => e.cal || 'local';
const colorOverrides = () => readJ(here(COLOR_FILE), {});
/** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */
function sharedWithMe() {
const me = myId(), out = [];
for (const u of users.all()) {
if (u.disabled || u.id === me) continue;
for (const c of calsIn(dirFor(u.id))) {
const sh = (c.shares || []).find(x => x.user === me);
if (sh) out.push({ id: u.id + '~' + c.id, owner: u.id, ownerName: u.name, cal: c, mode: sh.mode === 'edit' ? 'edit' : 'view' });
}
}
return out;
}
/** Find the NE Hub calendar an id points at, with the current person's rights on it (null if none). */
function resolveCal(id) {
const me = myId();
if (typeof id === 'string' && id.includes('~')) {
const [owner, cid] = id.split('~');
const u = users.get(owner);
if (!u || u.disabled) return null;
const c = calsIn(dirFor(owner)).find(x => x.id === cid);
const sh = c && (c.shares || []).find(x => x.user === me);
return sh ? { owner, dir: dirFor(owner), cal: c, cid, mode: sh.mode === 'edit' ? 'edit' : 'view' } : null;
}
const c = calsIn(dirFor(me)).find(x => x.id === id);
return c ? { owner: me, dir: dirFor(me), cal: c, cid: c.id, mode: 'owner' } : null;
}
const canWrite = r => r && (r.mode === 'owner' || r.mode === 'edit');
const icsOf = (dir, ids) => ics.buildCalendar(eventsIn(dir).filter(e => !ids || ids.includes(evCal(e))).map(e => ({ uid: e.uid, fields: e.fields })));
const localIcs = () => icsOf(dirFor(myId()));
function feedToken(reset) { function feedToken(reset) {
let t = readJ(here(FEED_FILE), {}).token; let t = readJ(here(FEED_FILE), {}).token;
if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); } if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); }
return t; return t;
} }
const localIcs = () => ics.buildCalendar(loadLocal().map(e => ({ uid: e.uid, fields: e.fields }))); function calToken(id, reset) {
const cals = calsIn(users.dir()), c = cals.find(x => x.id === id);
if (!c) throw new Error('Calendar not found');
if (!c.token || reset) { c.token = crypto.randomBytes(24).toString('hex'); saveCals(cals); }
return c.token;
}
/** GET /cal/feed/<token>.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret. /** GET /cal/feed/<token>.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret.
* Every person has their own token, and it only ever shows that person's own NE Hub events. */ * A person's own token shows all their NE Hub calendars; a calendar's token shows just that one. */
function feed(req, res) { function feed(req, res) {
const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname); const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname);
const got = Buffer.from(m ? m[1] : ''); const got = Buffer.from(m ? m[1] : '');
let owner = null; const same = t => { const want = Buffer.from(String(t || '')); return want.length && want.length === got.length && crypto.timingSafeEqual(want, got); };
let body = null;
if (m) for (const u of users.all()) { if (m) for (const u of users.all()) {
if (u.disabled) continue; if (u.disabled) continue;
const want = Buffer.from(String(readJ(path.join(users.dirOf(u.id), FEED_FILE), {}).token || '')); const dir = dirFor(u.id);
if (want.length && want.length === got.length && crypto.timingSafeEqual(want, got)) { owner = u; break; } if (same(readJ(path.join(dir, FEED_FILE), {}).token)) { body = icsOf(dir); break; }
const c = calsIn(dir).find(x => same(x.token));
if (c) { body = icsOf(dir, [c.id]); break; }
} }
if (!owner) { res.writeHead(404); return res.end('Not found'); } if (body == null) { res.writeHead(404); return res.end('Not found'); }
res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'private, max-age=300' }); res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'private, max-age=300' });
res.end(users.runAs(owner, () => localIcs())); res.end(body);
} }
// ---- HTTP API (called by server.js, behind the passkey gate) // ---- HTTP API (called by server.js, behind the passkey gate)
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
/** Every event in the window from all calendars (ICS links, iCloud, NE Hub): { events, errors } */ /** Every event in the window from all calendars (ICS links, iCloud, NE Hub, shared): { events, errors } */
async function collectEvents(win) { async function collectEvents(win) {
const all = [], errors = []; const all = [], errors = [], over = colorOverrides();
await Promise.all(load().map(async c => { await Promise.all(load().map(async c => {
try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, color: c.color }); } try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, calId: c.id, color: c.color }); }
catch (e) { errors.push(`${c.name}: ${e.message}`); } catch (e) { errors.push(`${c.name}: ${e.message}`); }
})); }));
try { try {
for (const r of await caldav.listRaw(win.from, win.to)) for (const r of await caldav.listRaw(win.from, win.to))
for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: r.color, editable: true, href: r.href, etag: r.etag }); for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: over[r.calId] || r.color, editable: true, href: r.href, etag: r.etag });
} catch (e) { errors.push('iCloud: ' + e.message); } } catch (e) { errors.push('iCloud: ' + e.message); }
try { for (const e of expand(parseEvents(localIcs()), win)) all.push({ ...e, cal: LOCAL.name, calId: 'local', color: LOCAL.color, editable: true }); } try {
catch (e) { errors.push('NE Hub: ' + e.message); } 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, ...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 });
} catch (e) { errors.push('NE Hub: ' + e.message); }
return { events: all, errors }; return { events: all, errors };
} }
async function handle(req, res) { async function handle(req, res) {
const u = new URL(req.url, 'http://x'); const u = new URL(req.url, 'http://x');
try { try {
if (u.pathname === '/api/cal/list') return json(res, 200, [{ ...LOCAL, local: true }, ...caldav.enabled().map(c => ({ ...c, icloud: true })), ...load().map(({ id, name, color }) => ({ id, name, color }))]); if (u.pathname === '/api/cal/list') {
const over = colorOverrides();
return json(res, 200, [
...calsIn(users.dir()).map(c => ({ id: c.id, name: c.name, color: c.color || DEFAULT_COLOR, local: true, shares: (c.shares || []).filter(x => users.get(x.user)) })),
...sharedWithMe().map(s => ({ id: s.id, name: s.cal.name, color: over[s.id] || s.cal.color || DEFAULT_COLOR, local: true, shared: true, owner: s.ownerName, mode: s.mode })),
...caldav.enabled().map(c => ({ ...c, color: over[c.id] || c.color, icloud: true })),
...load().map(({ id, name, color }) => ({ id, name, color }))]);
}
if (u.pathname === '/api/cal/ncal' && req.method === 'POST') { // create
const { name, color } = JSON.parse(await readBody(req));
const cals = calsIn(users.dir()), nm = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 40);
if (!nm) throw new Error('Give the calendar a name');
if (cals.length >= 30) throw new Error('That is plenty of calendars');
const c = { id: crypto.randomBytes(5).toString('hex'), name: nm, color: HEX.test(color) ? color : COLORS[cals.length % COLORS.length], shares: [] };
saveCals([...cals, c]);
return json(res, 200, { ok: true, id: c.id });
}
if (u.pathname === '/api/cal/ncal/update' && req.method === 'POST') { // rename, recolor, share
const b = JSON.parse(await readBody(req));
const cals = calsIn(users.dir()), c = cals.find(x => x.id === b.id);
if (!c) throw new Error('Calendar not found');
if (b.name != null) { const nm = String(b.name).replace(/\s+/g, ' ').trim().slice(0, 40); if (!nm) throw new Error('Give the calendar a name'); c.name = nm; }
if (b.color != null) { if (!HEX.test(b.color)) throw new Error('Pick a color'); c.color = b.color; }
if (b.shares != null) {
if (!Array.isArray(b.shares)) throw new Error('Bad share list');
const seen = new Set();
c.shares = b.shares.filter(x => x && x.user !== myId() && users.get(x.user) && !seen.has(x.user) && seen.add(x.user)).map(x => ({ user: x.user, mode: x.mode === 'edit' ? 'edit' : 'view' }));
}
saveCals(cals);
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/ncal/delete' && req.method === 'POST') {
const { id } = JSON.parse(await readBody(req));
const cals = calsIn(users.dir());
if (!cals.some(x => x.id === id)) throw new Error('Calendar not found');
if (cals.length < 2) throw new Error('Keep at least one NE Hub calendar');
saveCals(cals.filter(x => x.id !== id));
saveEvents(users.dir(), eventsIn(users.dir()).filter(e => evCal(e) !== id));
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/ncal/feed' && req.method === 'POST') { // one calendar's subscribe link (reset: true makes a new one)
const b = JSON.parse(await readBody(req));
const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`;
return json(res, 200, { url: `${origin}/cal/feed/${calToken(b.id, !!b.reset)}.ics` });
}
if (u.pathname === '/api/cal/people') // who a calendar can be shared with
return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId()).map(x => ({ id: x.id, name: x.name })));
if (u.pathname === '/api/cal/color' && req.method === 'POST') { // your own color for iCloud, shared or link calendars
const { id, color } = JSON.parse(await readBody(req));
if (!HEX.test(color)) throw new Error('Pick a color');
const link = load(), l = link.find(x => x.id === id);
if (l) { l.color = color; save(link); } else writeJ(COLOR_FILE, { ...colorOverrides(), [id]: color });
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/add' && req.method === 'POST') { if (u.pathname === '/api/cal/add' && req.method === 'POST') {
const { name, url } = JSON.parse(await readBody(req)); const { name, url } = JSON.parse(await readBody(req));
if (!url) throw new Error('Paste the calendar link'); if (!url) throw new Error('Paste the calendar link');
@@ -298,10 +411,11 @@ async function handle(req, res) {
} }
if (u.pathname === '/api/cal/event' && req.method === 'GET') { if (u.pathname === '/api/cal/event' && req.method === 'GET') {
const q = u.searchParams, tz = q.get('tz') || 'UTC'; const q = u.searchParams, tz = q.get('tz') || 'UTC';
if (q.get('cal') === 'local') { const r = !caldav.isCal(q.get('cal')) && resolveCal(q.get('cal'));
const e = loadLocal().find(x => x.uid === q.get('uid')); if (r) {
const e = eventsIn(r.dir).find(x => x.uid === q.get('uid') && evCal(x) === r.cid);
if (!e) throw new Error('Event not found'); if (!e) throw new Error('Event not found');
return json(res, 200, { cal: 'local', uid: e.uid, fields: e.fields }); return json(res, 200, { cal: q.get('cal'), uid: e.uid, fields: e.fields });
} }
if (caldav.isCal(q.get('cal'))) { if (caldav.isCal(q.get('cal'))) {
const r = await caldav.getResource(q.get('cal'), q.get('href')); const r = await caldav.getResource(q.get('cal'), q.get('href'));
@@ -313,12 +427,14 @@ async function handle(req, res) {
} }
if (u.pathname === '/api/cal/event' && req.method === 'POST') { if (u.pathname === '/api/cal/event' && req.method === 'POST') {
const b = JSON.parse(await readBody(req)); const b = JSON.parse(await readBody(req));
if (b.cal === 'local') { const r = !caldav.isCal(b.cal) && resolveCal(b.cal);
if (r) {
if (!canWrite(r)) throw new Error('This calendar is shared with you to view only');
ics.validate(b.fields); ics.validate(b.fields);
const all = loadLocal(), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid); const all = eventsIn(r.dir), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid && evCal(x) === r.cid);
const rec = { uid, fields: b.fields, updated: Date.now() }; 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() }); if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() });
writeJ(EV_FILE, all); saveEvents(r.dir, all);
return json(res, 200, { ok: true, uid }); return json(res, 200, { ok: true, uid });
} }
if (caldav.isCal(b.cal)) { if (caldav.isCal(b.cal)) {
@@ -336,7 +452,12 @@ async function handle(req, res) {
} }
if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') { if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') {
const b = JSON.parse(await readBody(req)); const b = JSON.parse(await readBody(req));
if (b.cal === 'local') { writeJ(EV_FILE, loadLocal().filter(x => x.uid !== b.uid)); return json(res, 200, { ok: true }); } const r = !caldav.isCal(b.cal) && resolveCal(b.cal);
if (r) {
if (!canWrite(r)) throw new Error('This calendar is shared with you to view only');
saveEvents(r.dir, eventsIn(r.dir).filter(x => !(x.uid === b.uid && evCal(x) === r.cid)));
return json(res, 200, { ok: true });
}
if (caldav.isCal(b.cal)) { await caldav.deleteEvent(b.cal, b.href, b.etag); return json(res, 200, { ok: true }); } if (caldav.isCal(b.cal)) { await caldav.deleteEvent(b.cal, b.href, b.etag); return json(res, 200, { ok: true }); }
throw new Error('Unknown calendar'); throw new Error('Unknown calendar');
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"test": "for t in smoke tasks push users plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done", "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high" "audit": "npm audit --omit=dev --audit-level=high"
}, },
"keywords": [], "keywords": [],
+67 -9
View File
@@ -128,7 +128,7 @@ function openEvent(e) {
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end); const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div> const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div> ${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`); <p class="sub">${e.shared ? esc(e.owner) + ' shared this calendar with you to view only.' : 'This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.'}</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#sx').onclick = closeSheet;
} }
@@ -142,11 +142,12 @@ async function editEvent(e) {
async function newEvent(day) { async function newEvent(day) {
try { try {
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud); const cals = (await jfetch('/api/cal/list')).filter(c => (c.local && c.mode !== 'view') || c.icloud);
if (!cals.length) return toast('No calendar here can take new events');
const d = new Date(day + 'T00:00:00'), now = new Date(); const d = new Date(day + 'T00:00:00'), now = new Date();
if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0); if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
const end = new Date(d.getTime() + 36e5); const end = new Date(d.getTime() + 36e5);
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } }); eventForm({ mode: 'new', cals, calId: (cals.find(c => c.id === store.get('cal_default', 'local')) || cals[0]).id, fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
} catch (err) { toast(err.message); } } catch (err) { toast(err.message); }
} }
@@ -155,7 +156,7 @@ function eventForm(o) {
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']]; const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']); if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>` const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`; : `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : c.shared ? ' (' + esc(c.owner) + ')' : ''}</option>`).join('')}</select>`;
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px"> const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label> <label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
<div id="fdt"></div> <div id="fdt"></div>
@@ -219,16 +220,70 @@ function addCalendar() {
}; };
} }
const CAL_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#e8559a', '#14a3b8', '#7a8a3a', '#6b7280'];
const swatches = cur => `<div class="swatches">${CAL_COLORS.map(c => `<button type="button" class="sw ${c.toLowerCase() === String(cur).toLowerCase() ? 'on' : ''}" data-c="${c}" aria-label="${c}" style="background:${c}"></button>`).join('')}<label class="sw custom" title="Any color"><input type="color" value="${/^#[0-9a-f]{6}$/i.test(cur) ? cur : '#4f7cff'}"></label></div>`;
// Wires swatches() inside root; calls onPick(hex) whenever the color changes.
function wireSwatches(root, onPick) {
root.querySelectorAll('.sw[data-c]').forEach(b => b.onclick = () => { root.querySelectorAll('.sw').forEach(x => x.classList.toggle('on', x === b)); onPick(b.dataset.c); });
const cu = root.querySelector('.sw.custom input'); if (cu) cu.oninput = () => { root.querySelectorAll('.sw').forEach(x => x.classList.remove('on')); onPick(cu.value); };
}
function colorSheet(c) {
const el = openSheet(c.name, 'Calendar color', `${swatches(c.color)}<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = () => { closeSheet(); viewCalendar(); };
wireSwatches(el, async hex => { try { await jfetch('/api/cal/color', { method: 'POST', body: JSON.stringify({ id: c.id, color: hex }) }); } catch (e) { toast(e.message); } });
}
// Create (c = null) or edit one of your NE Hub calendars: name, color, who it is shared with (view or edit), subscribe link, delete.
async function calendarSheet(c) {
const people = c ? await jfetch('/api/cal/people').catch(() => []) : [];
const st = { name: c ? c.name : '', color: c ? c.color : CAL_COLORS[Math.floor(Math.random() * CAL_COLORS.length)], shares: Object.fromEntries(((c && c.shares) || []).map(x => [x.user, x.mode])) };
const el = openSheet(c ? 'Edit calendar' : 'New calendar', '', `<form id="cf"><label for="cn">Name</label><input type="text" id="cn" value="${esc(st.name)}" placeholder="Family" autocomplete="off" required>
<label>Color</label>${swatches(st.color)}
${c ? `<h2 style="margin-top:14px">Sharing</h2><p class="sub" style="margin-top:0">Private unless you share it. For each person you choose whether they can only look, or add and change events too.</p>
${people.length ? `<div class="list" style="background:var(--panel2);border:0">${people.map(p => `<div class="row"><span class="grow"><b>${esc(p.name)}</b></span><select data-sh="${esc(p.id)}" style="width:auto;min-height:36px">${[['', 'Not shared'], ['view', 'Can view'], ['edit', 'Can edit']].map(([v, l]) => `<option value="${v}" ${(st.shares[p.id] || '') === v ? 'selected' : ''}>${l}</option>`).join('')}</select></div>`).join('')}</div>` : '<p class="sub">Nobody else has an account on this hub yet.</p>'}
<h2 style="margin-top:14px">Subscribe link</h2><p class="sub" style="margin-top:0">Show only this calendar in Outlook, Google or Apple Calendar (read-only there). Anyone with the link can see its events.</p>
<div class="bar"><button class="btn ghost" type="button" id="cl" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" type="button" id="cr" style="flex:1;justify-content:center">Make a new link</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">${c ? 'Save' : 'Create'}</button></div>
${c ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="cd" style="flex:1;justify-content:center">Delete calendar</button></div>' : ''}
<div class="err" id="ce"></div></form>`);
el.querySelector('.sheet').classList.add('wide');
wireSwatches(el, hex => { st.color = hex; });
el.querySelectorAll('[data-sh]').forEach(sel => sel.onchange = () => { if (sel.value) st.shares[sel.dataset.sh] = sel.value; else delete st.shares[sel.dataset.sh]; });
el.querySelector('#sx').onclick = () => { closeSheet(); manageCalendars(); };
el.querySelector('#cf').onsubmit = async ev => {
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
const name = el.querySelector('#cn').value.trim();
try {
if (c) await jfetch('/api/cal/ncal/update', { method: 'POST', body: JSON.stringify({ id: c.id, name, color: st.color, shares: Object.entries(st.shares).map(([user, mode]) => ({ user, mode })) }) });
else await jfetch('/api/cal/ncal', { method: 'POST', body: JSON.stringify({ name, color: st.color }) });
closeSheet(); toast(c ? 'Calendar saved' : 'Calendar created'); viewCalendar();
} catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
};
if (!c) return;
const link = async reset => (await jfetch('/api/cal/ncal/feed', { method: 'POST', body: JSON.stringify({ id: c.id, reset }) })).url;
el.querySelector('#cl').onclick = async () => { try { const u = await link(false); await navigator.clipboard.writeText(u); toast('Link copied'); } catch (e) { toast(e.message || 'Could not copy'); } };
el.querySelector('#cr').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { try { await navigator.clipboard.writeText(await link(true)); toast('New link copied'); } catch (e) { toast(e.message); } });
el.querySelector('#cd').onclick = () => confirmSheet('Delete ' + c.name + '?', 'Every event in it is deleted too' + (Object.keys(st.shares).length ? ', and the people it is shared with lose it' : '') + '. This cannot be undone.', 'Delete', async () => {
try { await jfetch('/api/cal/ncal/delete', { method: 'POST', body: JSON.stringify({ id: c.id }) }); toast('Calendar deleted'); viewCalendar(); } catch (e) { toast(e.message); }
});
}
async function manageCalendars() { async function manageCalendars() {
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]); const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
const links = cals.filter(c => !c.local && !c.icloud); const links = cals.filter(c => !c.local && !c.icloud), mine = cals.filter(c => c.local), icol = Object.fromEntries(cals.filter(c => c.icloud).map(c => [c.id, c.color]));
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0"> const dot = c => `<button type="button" class="cdot" data-col="${esc(c.id)}" aria-label="Color of ${esc(c.name)}" style="background:${esc(c.color)}"></button>`;
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div> const el = openSheet('Calendars', '', `<h2>NE Hub calendars</h2><div class="list" style="background:var(--panel2);border:0">
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')} ${mine.map(c => `<div class="row">${dot(c)}<span class="grow"><b>${esc(c.name)}</b><small>${c.shared ? 'Shared by ' + esc(c.owner) + ' · ' + (c.mode === 'edit' ? 'you can edit' : 'view only') : c.shares && c.shares.length ? 'Shared with ' + c.shares.length + (c.shares.length === 1 ? ' person' : ' people') : 'Private'}</small></span>${c.shared ? '' : `<button class="btn ghost" data-ed="${esc(c.id)}" style="min-height:36px">Edit</button>`}</div>`).join('')}
</div>
<div class="bar"><button class="btn ghost" id="nc" style="flex:1;justify-content:center">New calendar</button></div>
<h2 style="margin-top:14px">Calendar links</h2>
<div class="list" style="background:var(--panel2);border:0">
${links.length ? links.map(c => `<div class="row">${dot(c)}<span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('') : '<div class="row"><small>None yet</small></div>'}
</div> </div>
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div> <div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div>
<h2 style="margin-top:14px">iCloud calendars</h2> <h2 style="margin-top:14px">iCloud calendars</h2>
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div> ${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<div class="row">${dot({ id: c.id, name: c.name, color: icol[c.id] || c.color })}<span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></div>`).join('')}</div>
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>` <p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
: `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p> : `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p>
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required> <form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
@@ -240,6 +295,9 @@ async function manageCalendars() {
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`); <div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('.sheet').classList.add('wide');
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar; el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
el.querySelector('#nc').onclick = () => calendarSheet(null);
el.querySelectorAll('[data-ed]').forEach(b => b.onclick = () => calendarSheet(mine.find(c => c.id === b.dataset.ed)));
el.querySelectorAll('[data-col]').forEach(b => b.onclick = () => colorSheet(cals.find(c => c.id === b.dataset.col) || { id: b.dataset.col, name: 'Calendar', color: b.style.background }));
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); }); el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } }); el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); }; const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
+8
View File
@@ -693,3 +693,11 @@ body.nav-mini .ngrp span { display: none; }
.vtop .vtc { flex: 0 1 auto; } .vtop .vtc { flex: 0 1 auto; }
.mini .vskip { position: relative; display: grid; place-items: center; } .mini .vskip em { position: absolute; font-style: normal; font-size: 8px; font-weight: 700; top: 7px; } .mini .vskip { position: relative; display: grid; place-items: center; } .mini .vskip em { position: absolute; font-style: normal; font-size: 8px; font-weight: 700; top: 7px; }
@media (max-width: 700px) { .vtop .vright input[type=range], .vtop #vmute, .vtop #vspd { display: none; } .vtop .vtitle { display: none; } } @media (max-width: 700px) { .vtop .vright input[type=range], .vtop #vmute, .vtop #vspd { display: none; } .vtop .vtitle { display: none; } }
/* calendar colors */
.cdot{width:22px;height:22px;min-height:0;padding:0;border-radius:50%;border:2px solid var(--panel2);box-shadow:0 0 0 1px var(--line);flex:none;cursor:pointer}
.swatches{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0 4px}
.sw{width:30px;height:30px;min-height:0;padding:0;border-radius:50%;border:2px solid transparent;cursor:pointer;position:relative;box-shadow:0 0 0 1px var(--line)}
.sw.on{border-color:var(--text)}
.sw.custom{background:conic-gradient(#d9453a,#e5a00d,#2f9e6e,#4f7cff,#8a5cf0,#d9453a);overflow:hidden}
.sw.custom input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}
+10 -5
View File
@@ -204,7 +204,10 @@ function videoMount() {
/** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */ /** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */
function videoBindMedia(el) { function videoBindMedia(el) {
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; }; const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); }); el.addEventListener('timeupdate', () => { if (vp && vp.el === el && vp.mode === 'hls' && vp.learn && el.currentTime > 0 && !el.seeking) { // Plex's converted stream may count from 0 (what we asked for starts there) or carry on from the real position: tell which, once
const first = el.buffered.length ? el.buffered.start(0) : el.currentTime;
vp.base = vp.learn > 5 ? (first > vp.learn - 10 ? 0 : Math.floor(vp.learn)) : 0; vp.learn = 0; }
if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); });
el.addEventListener('play', () => { vdUi(); if (vp && vp.wake) vp.wake(); }); el.addEventListener('play', () => { vdUi(); if (vp && vp.wake) vp.wake(); });
el.addEventListener('pause', () => { vdUi(); if (vp && vp.wake) vp.wake(); if (!el.ended) vdReport('paused'); }); el.addEventListener('pause', () => { vdUi(); if (vp && vp.wake) vp.wake(); if (!el.ended) vdReport('paused'); });
el.addEventListener('volumechange', () => { const mu = $('#vmute'); if (mu) mu.innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); }); el.addEventListener('volumechange', () => { const mu = $('#vmute'); if (mu) mu.innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
@@ -281,8 +284,10 @@ function videoSeek(abs) {
if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000; if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000;
abs = Math.max(0, t ? Math.min(abs, t - 1) : abs); abs = Math.max(0, t ? Math.min(abs, t - 1) : abs);
if (vp.mode === 'direct') { el.currentTime = abs; return; } if (vp.mode === 'direct') { el.currentTime = abs; return; }
const rel = abs - vp.base; const rel = abs - vp.base, sk = el.seekable;
if (rel >= 0 && isFinite(el.duration) && rel < el.duration - 2) el.currentTime = rel; else videoStart(abs); // Only inside what the converted stream really has (its range may begin late when it started part-way); anything else restarts the conversion there.
const inside = sk && sk.length ? rel >= sk.start(0) - 0.5 && rel <= sk.end(sk.length - 1) - 2 : rel >= 0 && isFinite(el.duration) && rel < el.duration - 2;
if (inside) el.currentTime = rel; else videoStart(abs);
} }
function videoSkip(d) { function videoSkip(d) {
if (!vp) return; const to = Math.max(0, vp.base + vp.el.currentTime + d), t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0; if (!vp) return; const to = Math.max(0, vp.base + vp.el.currentTime + d), t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
@@ -320,10 +325,10 @@ async function videoStart(sec) {
vp.session = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()); vp.session = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
const q = QUALITIES.find(x => x[0] === vp.q) || QUALITIES[0], direct = !vp.forceHls && (vp.q === 'original' || (vp.q === 'auto' && canDirect(media) && !vp.audio && !vp.sub)); const q = QUALITIES.find(x => x[0] === vp.q) || QUALITIES[0], direct = !vp.forceHls && (vp.q === 'original' || (vp.q === 'auto' && canDirect(media) && !vp.audio && !vp.sub));
if (direct) { if (direct) {
vp.mode = 'direct'; vp.base = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true }); vp.mode = 'direct'; vp.base = 0; vp.learn = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true });
const t = $('#vmsg'); if (t) t.textContent = ''; const t = $('#vmsg'); if (t) t.textContent = '';
} else { } else {
vp.mode = 'hls'; vp.base = sec; vp.mode = 'hls'; vp.base = sec; vp.learn = sec || 0.001;
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' }); 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'); } 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…'; const url = '/plex/video/:/transcode/universal/start.m3u8?' + p; const t = $('#vmsg'); if (t) t.textContent = 'Preparing the stream…';
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
// NE Hub calendars: several per person, colors, private by default, shared view-only or edit by the owner's choice, subscribe links.
// Calls the calendar module directly as different people (no server needed).
// Run: node tools/cal-test.js
const fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-cal-'));
const users = require('../users'), cal = require('../cal');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const ann = users.create({ name: 'Ann', role: 'admin' }), bob = users.create({ name: 'Bob', tabs: ['calendar'] }), cy = users.create({ name: 'Cy', tabs: ['calendar'] });
async function call(u, p, body, method) {
return users.runAs(u, () => new Promise(done => {
const { EventEmitter } = require('events');
const req = new EventEmitter(); req.url = p; req.method = method || (body ? 'POST' : 'GET'); req.headers = { host: 'hub.test' };
const res = { code: 0, writeHead(c) { this.code = c; }, end(t) { let j = {}; try { j = JSON.parse(t); } catch {} done({ status: this.code, json: j, text: t }); } };
cal.handle(req, res);
process.nextTick(() => { if (body) req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end'); });
}));
}
const F = (title, d = '2026-11-03') => ({ title, location: '', notes: '', allDay: true, start: d, end: d, tz: 'UTC', repeat: 'none' });
const win = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
const titles = async u => (await call(u, win)).json.events.map(e => e.title).sort().join('|');
(async () => {
let r = await call(ann, '/api/cal/list');
ok('everyone starts with the one NE Hub calendar', r.json.length === 1 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[0].color === '#4f7cff', r.json);
// legacy events (no calendar field) belong to the first calendar
fs.writeFileSync(path.join(users.dirOf(ann.id), 'events.json'), JSON.stringify([{ uid: 'old1', fields: F('Old event'), created: 1, updated: 1 }]));
ok('older events stay in the first calendar', (await call(ann, win)).json.events.find(e => e.title === 'Old event').calId === 'local');
// create, recolor, rename
r = await call(ann, '/api/cal/ncal', { name: ' Family ', color: '#d9453a' }); const fam = r.json.id;
ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.length === 2);
r = await call(ann, '/api/cal/ncal', { name: ' ' }); ok('a calendar needs a name', r.status === 400, r);
r = await call(ann, '/api/cal/ncal/update', { id: fam, color: 'red' }); ok('colors must be real hex colors', r.status === 400, r);
await call(ann, '/api/cal/ncal/update', { id: fam, name: 'Family stuff', color: '#8a5cf0' });
const l = (await call(ann, '/api/cal/list')).json.find(c => c.id === fam); ok('rename and recolor stick', l.name === 'Family stuff' && l.color === '#8a5cf0', l);
// events go into the chosen calendar and carry its color
await call(ann, '/api/cal/event', { cal: fam, fields: F('Dinner') });
await call(ann, '/api/cal/event', { cal: 'local', fields: F('Work thing') });
const ev = (await call(ann, win)).json.events;
const dn = ev.find(e => e.title === 'Dinner'), wk = ev.find(e => e.title === 'Work thing');
ok('events use the color of their calendar', dn.color === '#8a5cf0' && dn.calId === fam && wk.color === '#4f7cff' && wk.calId === 'local', [dn, wk]);
ok('an event can be read back from its calendar', (await call(ann, `/api/cal/event?cal=${fam}&uid=${dn.uid}&tz=UTC`)).json.fields.title === 'Dinner');
ok('the same uid is not found in a different calendar', (await call(ann, `/api/cal/event?cal=local&uid=${dn.uid}&tz=UTC`)).status === 400);
// private by default
ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Sneaky') }); ok('and cannot write to it', r.status === 400, r);
r = await call(bob, `/api/cal/event?cal=${ann.id}~${fam}&uid=${dn.uid}&tz=UTC`); ok('or read it', r.status === 400, r);
// sharing, with the owner's choice of view or edit per person
r = await call(ann, '/api/cal/people'); ok('the owner can see who it can be shared with (not themself)', r.json.map(p => p.name).sort().join() === 'Bob,Cy', r.json);
r = await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: bob.id, mode: 'view' }, { user: cy.id, mode: 'edit' }, { user: ann.id, mode: 'edit' }, { user: 'nobody', mode: 'edit' }] });
ok('sharing saves, ignoring yourself and unknown people', r.status === 200 && (await call(ann, '/api/cal/list')).json.find(c => c.id === fam).shares.length === 2);
ok('a view-only person sees the events', (await titles(bob)) === 'Dinner');
const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared);
ok('and the calendar in their list, marked shared with the owner and their access', bl && bl.mode === 'view' && bl.owner === 'Ann' && bl.name === 'Family stuff', bl);
const bev = (await call(bob, win)).json.events[0]; ok('shared events say they cannot be edited', bev.editable === false && bev.shared === true, bev);
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Bob adds') }); ok('view-only people cannot add', r.status === 400 && /view only/.test(r.json.error), r);
r = await call(bob, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('or delete', r.status === 400, r);
ok('and still see exactly what they saw', (await titles(bob)) === 'Dinner');
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Cy adds') }); ok('an edit person can add', r.status === 200, r);
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, uid: dn.uid, fields: F('Dinner at 7') }); ok('and change events', r.status === 200);
ok('the owner sees those changes', (await titles(ann)).includes('Cy adds') && (await titles(ann)).includes('Dinner at 7'));
ok('Cy saw it in the owner\'s calendar, not in their own', (await call(cy, '/api/cal/list')).json.filter(c => !c.shared).length === 1);
r = await call(cy, '/api/cal/ncal/update', { id: fam, shares: [] }); ok("a person can't change the sharing of someone else's calendar", r.status === 400, r);
r = await call(cy, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('an edit person can delete events', r.status === 200 && !(await titles(ann)).includes('Dinner at 7'));
// everybody's own color for things shared with them / not theirs
await call(bob, '/api/cal/color', { id: `${ann.id}~${fam}`, color: '#2f9e6e' });
ok('a shared calendar can be given your own color', (await call(bob, win)).json.events[0].color === '#2f9e6e' && (await call(ann, win)).json.events.find(e => e.title === 'Cy adds').color === '#8a5cf0');
// stopping sharing
await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: cy.id, mode: 'edit' }] });
ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
// subscribe links
const feedOf = async (u, id, reset) => { const x = await call(u, '/api/cal/ncal/feed', { id, reset }); return x.json.url; };
const get = async url => { const res = { text: '', code: 0, writeHead(c) { this.code = c; }, end(t) { this.text = t; } }; cal.feed({ url: new URL(url).pathname }, res); return res; };
const u1 = await feedOf(ann, fam), u2 = await feedOf(ann, fam), u3 = await feedOf(ann, 'local');
ok('each calendar has its own stable subscribe link', u1 === u2 && u1 !== u3 && /^http:\/\/hub\.test\/cal\/feed\/[0-9a-f]{48}\.ics$/.test(u1), [u1, u3]);
const f1 = await get(u1), f3 = await get(u3);
ok('it shows only that calendar', f1.code === 200 && f1.text.includes('Cy adds') && !f1.text.includes('Work thing') && f3.text.includes('Work thing') && !f3.text.includes('Cy adds'));
const all = await get((await call(ann, '/api/cal/feed')).json.url);
ok('the person-wide link still shows all of their calendars', all.text.includes('Cy adds') && all.text.includes('Work thing') && all.text.includes('Old event'));
const u4 = await feedOf(ann, fam, true); const old = await get(u1);
ok('making a new link kills the old one', u4 !== u1 && old.code === 404 && (await get(u4)).code === 200);
// delete
r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.length === 1);
ok('and removes its events and its share', !(await titles(ann)).includes('Cy adds') && (await titles(cy)) === '');
r = await call(ann, '/api/cal/ncal/delete', { id: 'local' }); ok('the last calendar cannot be deleted', r.status === 400, r);
ok('older events survived all that', (await titles(ann)).includes('Old event'));
// link-calendar colors
fs.writeFileSync(path.join(users.dirOf(bob.id), 'calendars.json'), JSON.stringify([{ id: 'lnk', name: 'Holidays', url: 'https://example.com/x.ics', color: '#e5a00d' }]));
await call(bob, '/api/cal/color', { id: 'lnk', color: '#112233' });
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);
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
})();