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:
1 parent
d25eae5920
commit
d813884074
8 files changed
+343
-46
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(); };
|
||||||
|
|||||||
@@ -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
@@ -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…';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user