diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 2cafb4c..9aefa50 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -22,6 +22,7 @@ jobs: - 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/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/player-test.js # music player: queue, shuffle/repeat, stall recovery - run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered diff --git a/README.md b/README.md index 64e72b6..2a55e82 100644 --- a/README.md +++ b/README.md @@ -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 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: - - **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link - (Manage calendars -> copy link) 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. + - **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. + - **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). - **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 account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded diff --git a/cal.js b/cal.js index 1b53ece..b7b25e2 100644 --- a/cal.js +++ b/cal.js @@ -222,60 +222,173 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD return out; } -// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed. -const EV_FILE = 'events.json', FEED_FILE = 'feed.json'; +// ---- 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', NCAL_FILE = 'ncals.json', COLOR_FILE = 'calcolors.json'; 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 loadLocal = () => readJ(here(EV_FILE), []); -const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' }; +const HEX = /^#[0-9a-f]{6}$/i; +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) { let t = readJ(here(FEED_FILE), {}).token; if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: 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/.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) { const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname); 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 (u.disabled) continue; - const want = Buffer.from(String(readJ(path.join(users.dirOf(u.id), FEED_FILE), {}).token || '')); - if (want.length && want.length === got.length && crypto.timingSafeEqual(want, got)) { owner = u; break; } + const dir = dirFor(u.id); + 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.end(users.runAs(owner, () => localIcs())); + res.end(body); } // ---- 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 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) { - const all = [], errors = []; + const all = [], errors = [], over = colorOverrides(); 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}`); } })); try { 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); } - try { for (const e of expand(parseEvents(localIcs()), win)) all.push({ ...e, cal: LOCAL.name, calId: 'local', color: LOCAL.color, editable: true }); } - catch (e) { errors.push('NE Hub: ' + e.message); } + try { + 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 }; } async function handle(req, res) { const u = new URL(req.url, 'http://x'); 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') { const { name, url } = JSON.parse(await readBody(req)); 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') { const q = u.searchParams, tz = q.get('tz') || 'UTC'; - if (q.get('cal') === 'local') { - const e = loadLocal().find(x => x.uid === q.get('uid')); + const r = !caldav.isCal(q.get('cal')) && resolveCal(q.get('cal')); + 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'); - 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'))) { 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') { 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); - const all = loadLocal(), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid); - const rec = { uid, fields: b.fields, updated: Date.now() }; + const all = eventsIn(r.dir), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid && evCal(x) === r.cid); + 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() }); - writeJ(EV_FILE, all); + saveEvents(r.dir, all); return json(res, 200, { ok: true, uid }); } if (caldav.isCal(b.cal)) { @@ -336,7 +452,12 @@ async function handle(req, res) { } if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') { 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 }); } throw new Error('Unknown calendar'); } diff --git a/package.json b/package.json index 52bb6d8..722caf6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "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" }, "keywords": [], diff --git a/public/calendar.js b/public/calendar.js index 4434d0b..644102e 100644 --- a/public/calendar.js +++ b/public/calendar.js @@ -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 el = openSheet(e.title, e.cal, `
When${esc(when)}
${e.location ? `
Where${esc(e.location)}
` : ''}
-

This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.

`); +

${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.'}

`); el.querySelector('#sx').onclick = closeSheet; } @@ -142,11 +142,12 @@ async function editEvent(e) { async function newEvent(day) { 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(); 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); - 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); } } @@ -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']]; if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']); const calPick = editing ? `
Calendar${esc(o.calName)}
` - : ``; + : ``; const el = openSheet(editing ? 'Edit event' : 'New event', '', `
@@ -219,16 +220,70 @@ function addCalendar() { }; } +const CAL_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#e8559a', '#14a3b8', '#7a8a3a', '#6b7280']; +const swatches = cur => `
${CAL_COLORS.map(c => ``).join('')}
`; +// 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)}
`); + 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', '', ` + ${swatches(st.color)} + ${c ? `

Sharing

Private unless you share it. For each person you choose whether they can only look, or add and change events too.

+ ${people.length ? `
${people.map(p => `
${esc(p.name)}
`).join('')}
` : '

Nobody else has an account on this hub yet.

'} +

Subscribe link

Show only this calendar in Outlook, Google or Apple Calendar (read-only there). Anyone with the link can see its events.

+
` : ''} +
+ ${c ? '
' : ''} +
`); + 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() { 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 el = openSheet('Calendars', '', `
-
NE HubEvents you add here
- ${links.map(c => `
${esc(c.name)}Link (read-only)
`).join('')} + 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 dot = c => ``; + const el = openSheet('Calendars', '', `

NE Hub calendars

+ ${mine.map(c => `
${dot(c)}${esc(c.name)}${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'}${c.shared ? '' : ``}
`).join('')} +
+
+

Calendar links

+
+ ${links.length ? links.map(c => `
${dot(c)}${esc(c.name)}Link (read-only)
`).join('') : '
None yet
'}

iCloud calendars

- ${ic.connected ? `
${ic.calendars.map(c => ``).join('')}
+ ${ic.connected ? `
${ic.calendars.map(c => `
${dot({ id: c.id, name: c.name, color: icol[c.id] || c.color })}${esc(c.name)}
`).join('')}

Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.

` : `

Read and add events in your iCloud calendars. Use an app-specific password (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.

@@ -240,6 +295,9 @@ async function manageCalendars() {
`); el.querySelector('.sheet').classList.add('wide'); 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-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(); }; diff --git a/public/style.css b/public/style.css index 5631e5f..383846c 100644 --- a/public/style.css +++ b/public/style.css @@ -693,3 +693,11 @@ body.nav-mini .ngrp span { display: none; } .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; } @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} diff --git a/public/video.js b/public/video.js index d7c6ca9..ba85f95 100644 --- a/public/video.js +++ b/public/video.js @@ -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). */ function videoBindMedia(el) { 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('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'); }); @@ -281,8 +284,10 @@ function videoSeek(abs) { if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000; abs = Math.max(0, t ? Math.min(abs, t - 1) : abs); if (vp.mode === 'direct') { el.currentTime = abs; return; } - const rel = abs - vp.base; - if (rel >= 0 && isFinite(el.duration) && rel < el.duration - 2) el.currentTime = rel; else videoStart(abs); + const rel = abs - vp.base, sk = el.seekable; + // 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) { 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()); 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) { - 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 = ''; } 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' }); 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…'; diff --git a/tools/cal-test.js b/tools/cal-test.js new file mode 100644 index 0000000..f9865a3 --- /dev/null +++ b/tools/cal-test.js @@ -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); +})();