Calendar: add events (NE Hub calendar + iCloud) and a Podcast tab
build-container / build (push) Successful in 1m55s

- Add, edit and delete events; month/agenda views open them on tap
- NE Hub calendar stored on the server and published as a private ICS subscribe link
- iCloud CalDAV read/write with an app-specific password (encrypted at rest,
  only *.icloud.com contacted, event addresses checked against the calendar)
- Edits patch the existing entry so alarms/attendees survive; writes use If-Match / If-None-Match
- Time zone definitions (VTIMEZONE) generated so other apps read times correctly
- Podcast tab (PODCAST_URL) alongside Podcast Admin

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 15:08:41 -04:00
1 parent 7beb06678c
commit 5466506ffb
11 files changed
+550 -22

No files matched your search

+88 -4
View File
@@ -5,6 +5,8 @@ const path = require('path');
const dns = require('dns').promises;
const net = require('net');
const crypto = require('crypto');
const ics = require('./ics');
const caldav = require('./caldav');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'calendars.json');
@@ -105,6 +107,7 @@ function parseEvents(text) {
case 'DURATION': cur.dur = parseDuration(l.value); break;
case 'SUMMARY': cur.title = unesc(l.value); break;
case 'LOCATION': cur.location = unesc(l.value); break;
case 'DESCRIPTION': cur.notes = unesc(l.value); break;
case 'UID': cur.uid = l.value; break;
case 'STATUS': cur.status = l.value.toUpperCase(); break;
case 'RRULE': cur.rrule = Object.fromEntries(l.value.split(';').map(x => x.split('='))); break;
@@ -199,8 +202,8 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD
if (s.allDay) {
const sd = dateStr(startMs), ed = dateStr(Math.max(endMs, startMs + 864e5));
if (ed <= win.fd || sd > win.td) return;
out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed });
} else if (endMs >= win.from && startMs <= win.to) out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: false, start: startMs, end: endMs });
out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed, uid: ev.uid, series: !!ev.rrule });
} else if (endMs >= win.from && startMs <= win.to) out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: false, start: startMs, end: endMs, uid: ev.uid, series: !!ev.rrule });
};
if (!ev.rrule || ev.recId) { add(s.ms); continue; }
let guard = 0;
@@ -214,6 +217,28 @@ 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 = path.join(DATA_DIR, 'events.json'), FEED_FILE = path.join(DATA_DIR, 'feed.json');
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const writeJ = (f, v) => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2), { mode: 0o600 }); };
const loadLocal = () => readJ(EV_FILE, []);
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
function feedToken(reset) {
let t = readJ(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 })));
/** GET /cal/feed/<token>.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret. */
function feed(req, res) {
const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname);
const want = Buffer.from(feedToken()), got = Buffer.from(m ? m[1] : '');
if (!m || want.length !== got.length || !crypto.timingSafeEqual(want, got)) { 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(localIcs());
}
// ---- 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)); };
@@ -222,7 +247,7 @@ const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'
async function handle(req, res) {
const u = new URL(req.url, 'http://x');
try {
if (u.pathname === '/api/cal/list') return json(res, 200, load().map(({ id, name, color }) => ({ id, name, color })));
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/add' && req.method === 'POST') {
const { name, url } = JSON.parse(await readBody(req));
if (!url) throw new Error('Paste the calendar link');
@@ -246,10 +271,69 @@ async function handle(req, res) {
try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, 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 });
} 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); }
return json(res, 200, { events: all, errors });
}
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'));
if (!e) throw new Error('Event not found');
return json(res, 200, { cal: 'local', uid: e.uid, fields: e.fields });
}
if (caldav.isCal(q.get('cal'))) {
const r = await caldav.getResource(q.get('cal'), q.get('href'));
const master = parseEvents(r.ics).find(e => !e.recId);
if (!master) throw new Error('Could not read that event');
return json(res, 200, { cal: q.get('cal'), href: q.get('href'), etag: r.etag, fields: ics.fieldsFromEvent(master, tz) });
}
return json(res, 400, { error: 'Unknown calendar' });
}
if (u.pathname === '/api/cal/event' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
if (b.cal === 'local') {
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() };
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() });
writeJ(EV_FILE, all);
return json(res, 200, { ok: true, uid });
}
if (caldav.isCal(b.cal)) {
ics.validate(b.fields);
if (b.href) { // edit: patch the latest copy so alarms, attendees etc. survive
const cur = await caldav.getResource(b.cal, b.href);
await caldav.putEvent(b.cal, null, ics.patchIcs(cur.ics, b.fields), { href: b.href, etag: cur.etag });
return json(res, 200, { ok: true });
}
const uid = ics.newUid();
await caldav.putEvent(b.cal, uid, ics.buildCalendar([{ uid, fields: b.fields }], 'NE Hub'));
return json(res, 200, { ok: true, uid });
}
throw new Error('Unknown calendar');
}
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 }); }
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');
}
if (u.pathname === '/api/cal/icloud' && req.method === 'GET') return json(res, 200, caldav.status());
if (u.pathname === '/api/cal/icloud/connect' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); return json(res, 200, await caldav.connect(b.appleId, b.password)); }
if (u.pathname === '/api/cal/icloud/toggle' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); return json(res, 200, caldav.toggle(b.id, b.enabled)); }
if (u.pathname === '/api/cal/icloud/disconnect' && req.method === 'POST') { caldav.wipe(); return json(res, 200, { ok: true }); }
if (u.pathname === '/api/cal/feed') {
const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`;
if (req.method === 'POST') feedToken(true); // reset: the old link stops working
return json(res, 200, { url: `${origin}/cal/feed/${feedToken()}.ics` });
}
json(res, 404, { error: 'Not found' });
} catch (e) { json(res, 400, { error: e.message }); }
}
module.exports = { handle, parseEvents, expand, zonedToUtc, assertPublic };
module.exports = { handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };