Calendar: add events (NE Hub calendar + iCloud) and a Podcast tab
build-container / build (push) Successful in 1m55s
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:
1 parent
7beb06678c
commit
5466506ffb
11 files changed
+550
-22
No files matched your search
@@ -10,3 +10,4 @@ AUDIOBOOKS_URL=https://audiobooks.necloud.us
|
|||||||
# First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists.
|
# First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists.
|
||||||
HUB_SETUP_CODE=
|
HUB_SETUP_CODE=
|
||||||
VERSE_URL=https://versebyversewithnate.us/admin
|
VERSE_URL=https://versebyversewithnate.us/admin
|
||||||
|
PODCAST_URL=http://172.16.16.106:8383/
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.claude/
|
||||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:22-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY server.js auth.js cal.js ./
|
COPY server.js auth.js cal.js ics.js caldav.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
ENV PORT=8080 DATA_DIR=/data
|
ENV PORT=8080 DATA_DIR=/data
|
||||||
RUN mkdir -p /data && chown node:node /data
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
|||||||
- **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile.
|
- **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile.
|
||||||
- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume).
|
- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume).
|
||||||
- **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
|
- **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
|
||||||
- **Calendar**: agenda from private ICS links (Google secret address, Outlook/M365 published link, iCloud public calendar). Links are stored on the server in `/data`.
|
- **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.
|
||||||
|
- **ICS links** (Google secret address, Outlook/M365 published calendar) are read-only.
|
||||||
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
||||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the queue (play next / add to queue) and position are remembered.
|
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the queue (play next / add to queue) and position are remembered.
|
||||||
- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap.
|
- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap.
|
||||||
|
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
|
||||||
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
|
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
|
||||||
- **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose).
|
- **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose).
|
||||||
Providers forbid framing, and a real unified inbox would need Google/Azure app registrations.
|
Providers forbid framing, and a real unified inbox would need Google/Azure app registrations.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const path = require('path');
|
|||||||
const dns = require('dns').promises;
|
const dns = require('dns').promises;
|
||||||
const net = require('net');
|
const net = require('net');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const ics = require('./ics');
|
||||||
|
const caldav = require('./caldav');
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
const FILE = path.join(DATA_DIR, 'calendars.json');
|
const FILE = path.join(DATA_DIR, 'calendars.json');
|
||||||
@@ -105,6 +107,7 @@ function parseEvents(text) {
|
|||||||
case 'DURATION': cur.dur = parseDuration(l.value); break;
|
case 'DURATION': cur.dur = parseDuration(l.value); break;
|
||||||
case 'SUMMARY': cur.title = unesc(l.value); break;
|
case 'SUMMARY': cur.title = unesc(l.value); break;
|
||||||
case 'LOCATION': cur.location = 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 'UID': cur.uid = l.value; break;
|
||||||
case 'STATUS': cur.status = l.value.toUpperCase(); break;
|
case 'STATUS': cur.status = l.value.toUpperCase(); break;
|
||||||
case 'RRULE': cur.rrule = Object.fromEntries(l.value.split(';').map(x => x.split('='))); 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) {
|
if (s.allDay) {
|
||||||
const sd = dateStr(startMs), ed = dateStr(Math.max(endMs, startMs + 864e5));
|
const sd = dateStr(startMs), ed = dateStr(Math.max(endMs, startMs + 864e5));
|
||||||
if (ed <= win.fd || sd > win.td) return;
|
if (ed <= win.fd || sd > win.td) return;
|
||||||
out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed });
|
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 });
|
} 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; }
|
if (!ev.rrule || ev.recId) { add(s.ms); continue; }
|
||||||
let guard = 0;
|
let guard = 0;
|
||||||
@@ -214,6 +217,28 @@ 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.
|
||||||
|
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)
|
// ---- 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)); };
|
||||||
@@ -222,7 +247,7 @@ const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'
|
|||||||
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, 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') {
|
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');
|
||||||
@@ -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 }); }
|
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}`); }
|
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 });
|
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' });
|
json(res, 404, { error: 'Not found' });
|
||||||
} catch (e) { json(res, 400, { error: e.message }); }
|
} catch (e) { json(res, 400, { error: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, parseEvents, expand, zonedToUtc, assertPublic };
|
module.exports = { handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// iCloud calendar access over CalDAV, using an Apple ID + app-specific password (no app registration).
|
||||||
|
// Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords.
|
||||||
|
// Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const auth = require('./auth');
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
|
const FILE = path.join(DATA_DIR, 'icloud.json');
|
||||||
|
const TEST = process.env.CALDAV_TEST === '1'; // lets the tests talk to a local stand-in server; never set in production
|
||||||
|
const BASE = TEST ? process.env.CALDAV_BASE : 'https://caldav.icloud.com';
|
||||||
|
|
||||||
|
// ---- Encrypted credential store
|
||||||
|
const key = () => crypto.scryptSync(auth.secret, 'nehub-icloud', 32);
|
||||||
|
const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||||
|
const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
|
||||||
|
const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12));
|
||||||
|
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); };
|
||||||
|
function load() { try { return unseal(JSON.parse(fs.readFileSync(FILE, 'utf8')).data); } catch { return null; } }
|
||||||
|
function save(o) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify({ data: seal(o) }), { mode: 0o600 }); }
|
||||||
|
function wipe() { try { fs.unlinkSync(FILE); } catch {} }
|
||||||
|
|
||||||
|
// ---- HTTP + XML
|
||||||
|
const allowed = u => TEST ? ['localhost', '127.0.0.1'].includes(u.hostname) : u.protocol === 'https:' && (u.hostname === 'icloud.com' || u.hostname.endsWith('.icloud.com'));
|
||||||
|
async function dav(method, url, { user, pass, depth, body, headers = {} } = {}) {
|
||||||
|
let u = new URL(url);
|
||||||
|
for (let hop = 0; hop < 4; hop++) {
|
||||||
|
if (!allowed(u)) throw new Error('Refusing to contact ' + u.hostname);
|
||||||
|
const r = await fetch(u, { method, redirect: 'manual', signal: AbortSignal.timeout(20000), body,
|
||||||
|
headers: { Authorization: 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'), ...(depth !== undefined ? { Depth: String(depth) } : {}),
|
||||||
|
...(body ? { 'Content-Type': 'application/xml; charset=utf-8' } : {}), ...headers } });
|
||||||
|
if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; }
|
||||||
|
return { status: r.status, text: await r.text(), headers: r.headers, url: u.toString() };
|
||||||
|
}
|
||||||
|
throw new Error('Too many redirects');
|
||||||
|
}
|
||||||
|
const decode = s => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, x) => x.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'))
|
||||||
|
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, '\r').replace(/ /g, '\n').replace(/&/g, '&');
|
||||||
|
const tag = (xml, name) => { const m = new RegExp(`<(?:\\w+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:\\w+:)?${name}>`, 'i').exec(xml); return m ? m[1] : null; };
|
||||||
|
const responses = xml => (xml.match(/<(?:\w+:)?response(?:\s[^>]*)?>[\s\S]*?<\/(?:\w+:)?response>/gi) || []);
|
||||||
|
const hrefOf = block => { const h = tag(block, 'href'); return h ? decode(h).trim() : null; };
|
||||||
|
const abs = (href, base) => new URL(href, base).toString();
|
||||||
|
|
||||||
|
const PROPFIND_PRINCIPAL = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>';
|
||||||
|
const PROPFIND_HOME = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-home-set/></d:prop></d:propfind>';
|
||||||
|
const PROPFIND_CALS = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:prop><d:displayname/><d:resourcetype/><c:supported-calendar-component-set/><a:calendar-color/></d:prop></d:propfind>';
|
||||||
|
const stamp = ms => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||||
|
const REPORT = (from, to) => `<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop>
|
||||||
|
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="${stamp(from)}" end="${stamp(to)}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>`;
|
||||||
|
|
||||||
|
const calId = href => 'icloud:' + crypto.createHash('sha1').update(href).digest('hex').slice(0, 10);
|
||||||
|
|
||||||
|
/** Sign in, find the calendars, and remember everything (encrypted). Throws a readable message on failure. */
|
||||||
|
async function connect(appleId, password) {
|
||||||
|
const o = { user: String(appleId || '').trim(), pass: String(password || '').replace(/\s/g, '') };
|
||||||
|
if (!o.user || !o.pass) throw new Error('Enter your Apple ID and an app-specific password');
|
||||||
|
const a = await dav('PROPFIND', BASE + '/', { ...o, depth: 0, body: PROPFIND_PRINCIPAL });
|
||||||
|
if (a.status === 401 || a.status === 403) throw new Error('Apple rejected that Apple ID or app-specific password. It must be an app-specific password, not your normal one.');
|
||||||
|
if (a.status >= 400) throw new Error('iCloud answered with an error (' + a.status + ')');
|
||||||
|
const principalHref = hrefOf(tag(a.text, 'current-user-principal') || '');
|
||||||
|
if (!principalHref) throw new Error('iCloud did not return an account (is calendar turned on for this Apple ID?)');
|
||||||
|
const b = await dav('PROPFIND', abs(principalHref, a.url), { ...o, depth: 0, body: PROPFIND_HOME });
|
||||||
|
const homeHref = hrefOf(tag(b.text, 'calendar-home-set') || '');
|
||||||
|
if (!homeHref) throw new Error('Could not find the calendar home for this account');
|
||||||
|
const home = abs(homeHref, b.url);
|
||||||
|
const c = await dav('PROPFIND', home, { ...o, depth: 1, body: PROPFIND_CALS });
|
||||||
|
const calendars = [];
|
||||||
|
for (const r of responses(c.text)) {
|
||||||
|
const rt = tag(r, 'resourcetype') || '';
|
||||||
|
if (!/<(?:\w+:)?calendar\s*\/?>/i.test(rt)) continue;
|
||||||
|
const comps = tag(r, 'supported-calendar-component-set');
|
||||||
|
if (comps && !/name="VEVENT"/i.test(comps)) continue; // reminders lists etc.
|
||||||
|
const href = abs(hrefOf(r), c.url);
|
||||||
|
const color = (tag(r, 'calendar-color') || '').trim().slice(0, 7);
|
||||||
|
calendars.push({ id: calId(href), href, name: decode(tag(r, 'displayname') || 'Calendar').trim() || 'Calendar', color: /^#[0-9a-f]{6}$/i.test(color) ? color : '#d9453a', enabled: true });
|
||||||
|
}
|
||||||
|
if (!calendars.length) throw new Error('No calendars found on this account');
|
||||||
|
save({ user: o.user, pass: o.pass, calendars });
|
||||||
|
return status();
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = () => { const s = load(); return s ? { connected: true, appleId: s.user, calendars: s.calendars.map(({ id, name, color, enabled }) => ({ id, name, color, enabled })) } : { connected: false }; };
|
||||||
|
function toggle(id, enabled) { const s = load(); if (!s) throw new Error('Not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); c.enabled = !!enabled; save(s); return status(); }
|
||||||
|
const enabled = () => { const s = load(); return s ? s.calendars.filter(c => c.enabled).map(({ id, name, color }) => ({ id, name, color })) : []; };
|
||||||
|
const inside = (c, href) => { if (!String(href || '').startsWith(c.href.replace(/\/?$/, '/'))) throw new Error('That event is not in this calendar'); };
|
||||||
|
const cred = id => { const s = load(); if (!s) throw new Error('iCloud is not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); return { s, c, o: { user: s.user, pass: s.pass } }; };
|
||||||
|
|
||||||
|
/** Raw calendar entries overlapping the window: [{ calId, name, color, href, etag, ics }] */
|
||||||
|
async function listRaw(from, to) {
|
||||||
|
const s = load(); if (!s) return [];
|
||||||
|
const out = [];
|
||||||
|
await Promise.all(s.calendars.filter(c => c.enabled).map(async c => {
|
||||||
|
const r = await dav('REPORT', c.href, { user: s.user, pass: s.pass, depth: 1, body: REPORT(from - 864e5, to + 864e5) });
|
||||||
|
if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.');
|
||||||
|
if (r.status >= 400) throw new Error(`${c.name}: iCloud answered ${r.status}`);
|
||||||
|
for (const blk of responses(r.text)) {
|
||||||
|
const data = tag(blk, 'calendar-data'); if (!data) continue;
|
||||||
|
out.push({ calId: c.id, name: c.name, color: c.color, href: abs(hrefOf(blk), r.url), etag: decode(tag(blk, 'getetag') || '').trim(), ics: decode(data) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResource(id, href) {
|
||||||
|
const { c, o } = cred(id); inside(c, href);
|
||||||
|
const r = await dav('GET', href, o);
|
||||||
|
if (r.status === 404) throw new Error('That event no longer exists');
|
||||||
|
if (r.status >= 400) throw new Error('iCloud answered ' + r.status);
|
||||||
|
return { ics: r.text, etag: r.headers.get('etag') || '' };
|
||||||
|
}
|
||||||
|
async function putEvent(id, uid, icsText, { href, etag } = {}) {
|
||||||
|
const { c, o } = cred(id);
|
||||||
|
if (href) inside(c, href);
|
||||||
|
const url = href || new URL(encodeURIComponent(String(uid).split('@')[0]) + '.ics', c.href.replace(/\/?$/, '/')).toString();
|
||||||
|
const r = await dav('PUT', url, { ...o, body: icsText, headers: { 'Content-Type': 'text/calendar; charset=utf-8', ...(href ? (etag ? { 'If-Match': etag } : {}) : { 'If-None-Match': '*' }) } });
|
||||||
|
if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.');
|
||||||
|
if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.');
|
||||||
|
if (r.status >= 400) throw new Error('iCloud refused the change (' + r.status + ')');
|
||||||
|
return { href: url, etag: r.headers.get('etag') || '' };
|
||||||
|
}
|
||||||
|
async function deleteEvent(id, href, etag) {
|
||||||
|
const { c, o } = cred(id); inside(c, href);
|
||||||
|
const r = await dav('DELETE', href, { ...o, headers: etag ? { 'If-Match': etag } : {} });
|
||||||
|
if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.');
|
||||||
|
if (r.status >= 400 && r.status !== 404) throw new Error('iCloud refused the delete (' + r.status + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { connect, status, toggle, enabled, listRaw, getResource, putEvent, deleteEvent, wipe, isCal: id => /^icloud:[0-9a-f]{10}$/.test(id || '') };
|
||||||
@@ -16,6 +16,7 @@ services:
|
|||||||
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us}
|
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us}
|
||||||
AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us}
|
AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us}
|
||||||
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
|
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
|
||||||
|
PODCAST_URL: ${PODCAST_URL:-http://172.16.16.106:8383/}
|
||||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
|
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
|
||||||
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
|
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Building and editing iCalendar (ICS) text: used for the NE Hub calendar, its subscribe feed, and iCloud writes.
|
||||||
|
//
|
||||||
|
// "fields" is what the app's event form edits:
|
||||||
|
// { title, location, notes, allDay, start: 'YYYY-MM-DDTHH:mm' | 'YYYY-MM-DD', end: (same; all-day end is inclusive),
|
||||||
|
// tz: IANA zone, repeat: 'none' | 'daily' | 'weekdays' | 'weekly' | 'monthly' | 'yearly' | 'keep' }
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const esc = v => String(v || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||||
|
const fold = line => { // 75 octets per line, continuation lines start with a space
|
||||||
|
if (Buffer.byteLength(line) <= 75) return line;
|
||||||
|
const out = []; let cur = '', bytes = 0;
|
||||||
|
for (const ch of line) {
|
||||||
|
const b = Buffer.byteLength(ch), limit = out.length ? 74 : 75;
|
||||||
|
if (bytes + b > limit) { out.push(cur); cur = ''; bytes = 0; }
|
||||||
|
cur += ch; bytes += b;
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
return out.join('\r\n ');
|
||||||
|
};
|
||||||
|
const ymdhms = (d, t) => d.replace(/-/g, '') + (t ? 'T' + t.replace(/:/g, '').padEnd(6, '0') : '');
|
||||||
|
const nowStamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||||
|
const validTz = tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return !!tz; } catch { return false; } };
|
||||||
|
|
||||||
|
// ---- Time zones
|
||||||
|
const fmtCache = new Map();
|
||||||
|
function partsAt(ms, tz) {
|
||||||
|
let f = fmtCache.get(tz);
|
||||||
|
if (!f) fmtCache.set(tz, f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }));
|
||||||
|
return Object.fromEntries(f.formatToParts(new Date(ms)).map(x => [x.type, +x.value]));
|
||||||
|
}
|
||||||
|
const offsetMin = (ms, tz) => { const p = partsAt(ms, tz); return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000) / 6e4); };
|
||||||
|
const fmtOff = m => (m < 0 ? '-' : '+') + pad(Math.floor(Math.abs(m) / 60)) + pad(Math.abs(m) % 60);
|
||||||
|
/** UTC ms -> 'YYYY-MM-DDTHH:mm' wall clock in tz */
|
||||||
|
function wallClock(ms, tz) { const p = partsAt(ms, tz); return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`; }
|
||||||
|
|
||||||
|
// A VTIMEZONE built from the zone's real offset changes in a year, so other apps do not have to guess.
|
||||||
|
function vtimezone(tz, year = new Date().getUTCFullYear()) {
|
||||||
|
const L = ['BEGIN:VTIMEZONE', 'TZID:' + tz];
|
||||||
|
const changes = [];
|
||||||
|
let prev = offsetMin(Date.UTC(year, 0, 1), tz);
|
||||||
|
for (let d = 1; d <= 366; d++) {
|
||||||
|
const t = Date.UTC(year, 0, 1) + d * 864e5, o = offsetMin(t, tz);
|
||||||
|
if (o !== prev) { // narrow the change down to the minute
|
||||||
|
let lo = t - 864e5, hi = t;
|
||||||
|
while (hi - lo > 6e4) { const mid = Math.floor((lo + hi) / 2); if (offsetMin(mid, tz) === prev) lo = mid; else hi = mid; }
|
||||||
|
changes.push({ at: hi, from: prev, to: o }); prev = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const abbr = ms => (new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'short' }).formatToParts(new Date(ms)).find(x => x.type === 'timeZoneName') || {}).value || '';
|
||||||
|
const comp = (kind, from, to, at) => {
|
||||||
|
const wall = new Date(at + from * 6e4), dow = wall.getUTCDay(), day = wall.getUTCDate();
|
||||||
|
const dim = new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0)).getUTCDate();
|
||||||
|
const nth = day + 7 > dim ? -1 : Math.ceil(day / 7);
|
||||||
|
return ['BEGIN:' + kind, `DTSTART:1970${pad(wall.getUTCMonth() + 1)}01T${pad(wall.getUTCHours())}${pad(wall.getUTCMinutes())}00`.replace(/^(DTSTART:1970\d\d)01/, '$1' + '01'),
|
||||||
|
`RRULE:FREQ=YEARLY;BYMONTH=${wall.getUTCMonth() + 1};BYDAY=${nth}${['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][dow]}`,
|
||||||
|
'TZOFFSETFROM:' + fmtOff(from), 'TZOFFSETTO:' + fmtOff(to), 'TZNAME:' + abbr(at + 6e4), 'END:' + kind];
|
||||||
|
};
|
||||||
|
if (!changes.length) L.push('BEGIN:STANDARD', 'DTSTART:19700101T000000', 'TZOFFSETFROM:' + fmtOff(prev), 'TZOFFSETTO:' + fmtOff(prev), 'TZNAME:' + abbr(Date.UTC(year, 0, 1)), 'END:STANDARD');
|
||||||
|
else for (const c of changes) L.push(...comp(c.to > c.from ? 'DAYLIGHT' : 'STANDARD', c.from, c.to, c.at));
|
||||||
|
L.push('END:VTIMEZONE');
|
||||||
|
return L;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Building
|
||||||
|
const RRULES = { daily: 'FREQ=DAILY', weekdays: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', weekly: 'FREQ=WEEKLY', monthly: 'FREQ=MONTHLY', yearly: 'FREQ=YEARLY' };
|
||||||
|
|
||||||
|
function validate(f) {
|
||||||
|
if (!f || !String(f.title || '').trim()) throw new Error('Give the event a title');
|
||||||
|
const re = f.allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
||||||
|
if (!re.test(f.start || '') || !re.test(f.end || '')) throw new Error('Start and end are required');
|
||||||
|
if (f.end < f.start) throw new Error('The event ends before it starts');
|
||||||
|
if (!f.allDay && !validTz(f.tz)) throw new Error('Unknown time zone');
|
||||||
|
if (f.repeat && f.repeat !== 'none' && f.repeat !== 'keep' && !RRULES[f.repeat]) throw new Error('Unknown repeat option');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The property lines (unfolded) that the form controls. */
|
||||||
|
function propLines(f) {
|
||||||
|
const L = ['SUMMARY:' + esc(String(f.title).trim())];
|
||||||
|
if (f.location) L.push('LOCATION:' + esc(f.location));
|
||||||
|
if (f.notes) L.push('DESCRIPTION:' + esc(f.notes));
|
||||||
|
if (f.allDay) {
|
||||||
|
const end = new Date(Date.parse(f.end + 'T00:00:00Z') + 864e5).toISOString().slice(0, 10); // ICS all-day end is exclusive
|
||||||
|
L.push('DTSTART;VALUE=DATE:' + ymdhms(f.start), 'DTEND;VALUE=DATE:' + ymdhms(end));
|
||||||
|
} else if (f.tz === 'UTC') {
|
||||||
|
L.push('DTSTART:' + ymdhms(...f.start.split('T')) + 'Z', 'DTEND:' + ymdhms(...f.end.split('T')) + 'Z');
|
||||||
|
} else {
|
||||||
|
L.push(`DTSTART;TZID=${f.tz}:` + ymdhms(...f.start.split('T')), `DTEND;TZID=${f.tz}:` + ymdhms(...f.end.split('T')));
|
||||||
|
}
|
||||||
|
if (f.repeat && f.repeat !== 'none' && f.repeat !== 'keep') L.push('RRULE:' + RRULES[f.repeat]);
|
||||||
|
return L;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVevent(f, uid) {
|
||||||
|
validate(f);
|
||||||
|
return ['BEGIN:VEVENT', 'UID:' + uid, 'DTSTAMP:' + nowStamp(), 'SEQUENCE:0', ...propLines(f), 'END:VEVENT'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole VCALENDAR from [{uid, fields}] */
|
||||||
|
function buildCalendar(items, name = 'NE Hub') {
|
||||||
|
const zones = [...new Set(items.filter(i => !i.fields.allDay && i.fields.tz && i.fields.tz !== 'UTC').map(i => i.fields.tz))];
|
||||||
|
const L = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//NE Hub//Calendar//EN', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:' + esc(name)];
|
||||||
|
for (const tz of zones) L.push(...vtimezone(tz));
|
||||||
|
for (const i of items) L.push(...buildVevent(i.fields, i.uid));
|
||||||
|
L.push('END:VCALENDAR');
|
||||||
|
return L.map(fold).join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit an existing resource in place: only the properties the form controls change, everything else (alarms, attendees…) stays. */
|
||||||
|
function patchIcs(text, f) {
|
||||||
|
validate(f);
|
||||||
|
const lines = text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter(l => l !== '');
|
||||||
|
const out = [], drop = new Set(['SUMMARY', 'LOCATION', 'DESCRIPTION', 'DTSTART', 'DTEND', 'DURATION']);
|
||||||
|
if (f.repeat !== 'keep') drop.add('RRULE');
|
||||||
|
let inEv = false, done = false, hasTz = lines.some(l => l === 'BEGIN:VTIMEZONE' && lines.some(x => x === 'TZID:' + f.tz));
|
||||||
|
let master = false, seq = 0, sawStamp = false;
|
||||||
|
// find out whether this VEVENT is the master (no RECURRENCE-ID) before editing it
|
||||||
|
const evBlocks = []; let s = -1;
|
||||||
|
lines.forEach((l, i) => { if (l === 'BEGIN:VEVENT') s = i; if (l === 'END:VEVENT' && s >= 0) { evBlocks.push([s, i]); s = -1; } });
|
||||||
|
const target = evBlocks.find(([a, b]) => !lines.slice(a, b).some(l => /^RECURRENCE-ID/.test(l)));
|
||||||
|
if (!target) throw new Error('Could not find the event in that calendar entry');
|
||||||
|
const needTz = !f.allDay && f.tz && f.tz !== 'UTC' && !hasTz;
|
||||||
|
let depth = 0; // nested components (VALARM) keep their own SUMMARY/DESCRIPTION/TRIGGER lines untouched
|
||||||
|
lines.forEach((l, i) => {
|
||||||
|
if (i === target[0]) { if (needTz) out.push(...vtimezone(f.tz)); out.push(l); inEv = true; return; }
|
||||||
|
if (i === target[1]) { out.push(...propLines(f)); if (!sawStamp) out.push('DTSTAMP:' + nowStamp()); out.push('SEQUENCE:' + (seq + 1)); out.push(l); inEv = false; return; }
|
||||||
|
if (!inEv || i < target[0] || i > target[1]) { out.push(l); return; }
|
||||||
|
if (/^BEGIN:/.test(l)) { depth++; out.push(l); return; }
|
||||||
|
if (/^END:/.test(l)) { depth--; out.push(l); return; }
|
||||||
|
if (depth > 0) { out.push(l); return; }
|
||||||
|
const name = l.split(/[;:]/)[0].toUpperCase();
|
||||||
|
if (drop.has(name)) return;
|
||||||
|
if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; }
|
||||||
|
if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; }
|
||||||
|
out.push(l);
|
||||||
|
});
|
||||||
|
// a series changed from timed to all-day (or back) leaves stale EXDATEs of the wrong type; drop them
|
||||||
|
return out.map(fold).join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Form fields from a parsed master event (see cal.js parseEvents). */
|
||||||
|
function fieldsFromEvent(ev, clientTz) {
|
||||||
|
const s = ev.start, tzOk = tz => tz && tz !== 'UTC' && validTz(tz);
|
||||||
|
const rr = ev.rrule;
|
||||||
|
let repeat = 'none';
|
||||||
|
if (rr) {
|
||||||
|
const by = (rr.BYDAY || '').split(',').sort().join(',');
|
||||||
|
repeat = rr.COUNT || rr.UNTIL || (rr.INTERVAL && rr.INTERVAL !== '1') ? 'keep'
|
||||||
|
: rr.FREQ === 'DAILY' && !rr.BYDAY ? 'daily'
|
||||||
|
: rr.FREQ === 'WEEKLY' && by === 'FR,MO,TH,TU,WE' ? 'weekdays'
|
||||||
|
: rr.FREQ === 'WEEKLY' && !rr.BYMONTH && (!rr.BYDAY || rr.BYDAY.split(',').length === 1) ? 'weekly'
|
||||||
|
: rr.FREQ === 'MONTHLY' && !rr.BYDAY && !rr.BYMONTHDAY ? 'monthly'
|
||||||
|
: rr.FREQ === 'YEARLY' && !rr.BYDAY && !rr.BYMONTH ? 'yearly' : 'keep';
|
||||||
|
}
|
||||||
|
const base = { title: ev.title || '', location: ev.location || '', notes: ev.notes || '', repeat };
|
||||||
|
const endMs = ev.end ? ev.end.ms : ev.dur != null ? s.ms + ev.dur : s.allDay ? s.ms + 864e5 : s.ms;
|
||||||
|
if (s.allDay) {
|
||||||
|
const last = new Date(Math.max(endMs - 864e5, s.ms)).toISOString().slice(0, 10);
|
||||||
|
return { ...base, allDay: true, start: new Date(s.ms).toISOString().slice(0, 10), end: last, tz: clientTz };
|
||||||
|
}
|
||||||
|
const tz = tzOk(s.tz) ? s.tz : clientTz; // zone-less / UTC times are shown in the viewer's own zone
|
||||||
|
return { ...base, allDay: false, start: wallClock(s.ms, tz), end: wallClock(endMs, tz), tz };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUid = () => crypto.randomUUID() + '@nehub';
|
||||||
|
|
||||||
|
module.exports = { buildVevent, buildCalendar, patchIcs, fieldsFromEvent, vtimezone, validate, newUid, wallClock, validTz };
|
||||||
+146
-15
@@ -39,6 +39,7 @@ const ICONS = {
|
|||||||
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
|
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
|
||||||
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
|
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
|
||||||
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
|
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
|
||||||
|
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
|
||||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||||
};
|
};
|
||||||
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
||||||
@@ -62,7 +63,7 @@ function mailUrl(m, kind) {
|
|||||||
return base; // iCloud has no sign-in hint
|
return base; // iCloud has no sign-in hint
|
||||||
}
|
}
|
||||||
|
|
||||||
let cfg = { verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
let cfg = { podcast: 'http://172.16.16.106:8383/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
||||||
|
|
||||||
// ---- Seafile client (via same-origin proxy)
|
// ---- Seafile client (via same-origin proxy)
|
||||||
const sf = {
|
const sf = {
|
||||||
@@ -96,8 +97,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
|||||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
// ---- Navigation
|
// ---- Navigation
|
||||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||||
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
|
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
|
||||||
const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||||
const phone = matchMedia('(max-width: 767px)');
|
const phone = matchMedia('(max-width: 767px)');
|
||||||
let activeTab = 'home';
|
let activeTab = 'home';
|
||||||
@@ -121,7 +122,7 @@ function openMore() {
|
|||||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
||||||
let editor;
|
let editor;
|
||||||
function route() {
|
function route() {
|
||||||
const [, id = 'home', ...rest] = location.hash.split('/');
|
const [, id = 'home', ...rest] = location.hash.split('/');
|
||||||
@@ -158,6 +159,7 @@ function viewHome() {
|
|||||||
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
|
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
|
||||||
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
|
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
|
||||||
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
|
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
|
||||||
|
${tile(cfg.podcast, '#c2417a', 'mic', 'Podcast', 'Home network', true)}
|
||||||
${tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true)}
|
${tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true)}
|
||||||
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
|
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
|
||||||
</div>
|
</div>
|
||||||
@@ -254,17 +256,21 @@ async function loadRange(start, days) {
|
|||||||
}
|
}
|
||||||
const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
|
const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
|
||||||
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
const evRow = e => `<div class="row"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
|
const evStore = []; // events currently on screen, so a tap can open the right one
|
||||||
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}</small></span></div>`;
|
const evRow = e => { const i = evStore.push(e) - 1;
|
||||||
|
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
|
||||||
|
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
|
||||||
|
document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); });
|
||||||
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
|
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
|
||||||
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
|
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
|
||||||
|
|
||||||
async function renderAgenda(el, mode) { // Home card (today only)
|
async function renderAgenda(el, mode) { // Home card (today only)
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
const cals = await jfetch('/api/cal/list'); if (!cals.length) return;
|
const cals = await jfetch('/api/cal/list');
|
||||||
const { byDay } = await loadAgenda(1);
|
const { byDay } = await loadAgenda(1);
|
||||||
const list = byDay.get(ymd(new Date())) || [];
|
const list = byDay.get(ymd(new Date())) || [];
|
||||||
|
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
|
||||||
if (!document.body.contains(el)) return;
|
if (!document.body.contains(el)) return;
|
||||||
el.innerHTML = `<h2>Today</h2><div class="list">${list.length ? list.slice(0, 6).map(evRow).join('') : '<div class="row"><small>Nothing on the calendar today</small></div>'}</div>`;
|
el.innerHTML = `<h2>Today</h2><div class="list">${list.length ? list.slice(0, 6).map(evRow).join('') : '<div class="row"><small>Nothing on the calendar today</small></div>'}</div>`;
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -278,7 +284,9 @@ async function viewCalendar() {
|
|||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = `<div class="wrap"><h1>Calendar</h1>
|
v.innerHTML = `<div class="wrap"><h1>Calendar</h1>
|
||||||
<div class="seg">${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>
|
<div class="seg">${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||||
|
<div class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div>
|
||||||
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
||||||
|
$('#ne').onclick = () => newEvent(mode === 'month' ? calState.sel : ymd(new Date()));
|
||||||
v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
|
v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
|
||||||
$('#mc').onclick = manageCalendars;
|
$('#mc').onclick = manageCalendars;
|
||||||
try {
|
try {
|
||||||
@@ -333,10 +341,98 @@ async function renderMonth() {
|
|||||||
$('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
|
$('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
const pad2 = n => String(n).padStart(2, '0');
|
||||||
|
const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
|
||||||
|
function openEvent(e) {
|
||||||
|
if (!e) return;
|
||||||
|
if (e.editable) return editEvent(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, `<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>
|
||||||
|
<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>`);
|
||||||
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editEvent(e) {
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
|
||||||
|
const r = await jfetch('/api/cal/event?' + q);
|
||||||
|
eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
|
||||||
|
} catch (err) { toast(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newEvent(day) {
|
||||||
|
try {
|
||||||
|
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
|
||||||
|
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' } });
|
||||||
|
} catch (err) { toast(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventForm(o) {
|
||||||
|
const f = { ...o.fields }, editing = o.mode === 'edit';
|
||||||
|
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 ? `<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>`;
|
||||||
|
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>
|
||||||
|
<div id="fdt"></div>
|
||||||
|
<label for="fr">Repeat</label><select id="fr">${opts.map(([v, l]) => `<option value="${v}" ${v === f.repeat ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||||||
|
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
|
||||||
|
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
|
||||||
|
${calPick}
|
||||||
|
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
|
||||||
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
|
||||||
|
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
|
||||||
|
<div class="err" id="fe"></div></form>`);
|
||||||
|
el.querySelector('.sheet').classList.add('wide');
|
||||||
|
const draw = () => { // date-only inputs for all-day, date+time otherwise
|
||||||
|
const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
|
||||||
|
el.querySelector('#fdt').innerHTML = `<label for="fs">Starts</label><input type="${t}" id="fs" value="${cut(f.start)}" required><label for="fe2">Ends</label><input type="${t}" id="fe2" value="${cut(f.end)}" required>
|
||||||
|
${!f.allDay && f.tz !== deviceTz() ? `<p class="sub" style="margin:6px 0 0">Times are in ${esc(f.tz)}.</p>` : ''}`;
|
||||||
|
const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
|
||||||
|
s.onchange = () => { // moving the start keeps the length
|
||||||
|
const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
|
||||||
|
f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
|
||||||
|
};
|
||||||
|
en.onchange = () => { f.end = en.value; };
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
el.querySelector('#fa').onchange = ev => {
|
||||||
|
f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
|
||||||
|
f.allDay = ev.target.checked;
|
||||||
|
if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
|
||||||
|
else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
|
el.querySelector('#ef').onsubmit = async ev => {
|
||||||
|
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
|
||||||
|
const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
|
||||||
|
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
|
||||||
|
const cal = editing ? o.calId : el.querySelector('#fc').value;
|
||||||
|
try {
|
||||||
|
await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
|
||||||
|
if (!editing) store.set('cal_default', cal);
|
||||||
|
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
|
||||||
|
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
|
||||||
|
};
|
||||||
|
const del = el.querySelector('#fd');
|
||||||
|
if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
|
||||||
|
try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
|
||||||
|
catch (err) { toast(err.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addCalendar() {
|
function addCalendar() {
|
||||||
const el = openSheet('Add a calendar', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
|
const el = openSheet('Add a calendar link', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
|
||||||
<label>Private calendar link (ICS)</label><input type="url" id="cu2" placeholder="https://… or webcal://…" autocomplete="off" autocapitalize="off" required>
|
<label>Private calendar link (ICS)</label><input type="url" id="cu2" placeholder="https://… or webcal://…" autocomplete="off" autocapitalize="off" required>
|
||||||
<p class="sub" style="margin:10px 0 0">Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. iCloud: calendar's Share → Public Calendar → copy link. Anyone with the link can read that calendar, so keep it private.</p>
|
<p class="sub" style="margin:10px 0 0">Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.</p>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Add</button></div><div class="err" id="ce"></div></form>`);
|
<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">Add</button></div><div class="err" id="ce"></div></form>`);
|
||||||
el.querySelector('#sx').onclick = closeSheet;
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
el.querySelector('form').onsubmit = async e => {
|
el.querySelector('form').onsubmit = async e => {
|
||||||
@@ -345,13 +441,39 @@ function addCalendar() {
|
|||||||
catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
|
catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function manageCalendars() {
|
async function manageCalendars() {
|
||||||
const cals = await jfetch('/api/cal/list');
|
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
|
||||||
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">${cals.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span>
|
const links = cals.filter(c => !c.local && !c.icloud);
|
||||||
<button class="btn danger" data-id="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('') || '<div class="row"><small>No calendars</small></div>'}</div>
|
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">
|
||||||
<div class="bar"><button class="btn" id="ac" style="flex:1;justify-content:center">Add a calendar</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
<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>
|
||||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#ac').onclick = addCalendar;
|
${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('')}
|
||||||
el.querySelectorAll('button[data-id]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); closeSheet(); viewCalendar(); });
|
</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>
|
||||||
|
${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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Connect iCloud</button></div><div class="err" id="ice"></div></form>`}
|
||||||
|
<h2 style="margin-top:14px">See NE Hub events in other apps</h2>
|
||||||
|
<p class="sub">Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).</p>
|
||||||
|
<input type="text" readonly id="fu" value="${esc(feed.url)}" onfocus="this.select()">
|
||||||
|
<div class="bar"><button class="btn ghost" id="fc2" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" id="fr2" style="flex:1;justify-content:center">Make a new link</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('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
|
||||||
|
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(); };
|
||||||
|
const icf = el.querySelector('#icf');
|
||||||
|
if (icf) icf.onsubmit = async e => {
|
||||||
|
e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
|
||||||
|
try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
|
||||||
|
catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
|
||||||
|
};
|
||||||
|
el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
|
||||||
|
el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewMail() {
|
function viewMail() {
|
||||||
@@ -517,6 +639,15 @@ function viewVerse() {
|
|||||||
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('book')}Open admin</a></div></div></div>`;
|
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('book')}Open admin</a></div></div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
|
||||||
|
// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
|
||||||
|
function viewPodcast() {
|
||||||
|
const url = cfg.podcast, u = new URL(url);
|
||||||
|
$('#view').innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
|
||||||
|
<div class="card" style="max-width:none"><p style="margin-top:0">Opens in its own tab. This address is on your home network, so it works when you are at home or connected to your VPN.</p>
|
||||||
|
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('mic')}Open podcast</a></div></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function viewAudio() {
|
function viewAudio() {
|
||||||
const url = cfg.audiobooks, c = abCreds();
|
const url = cfg.audiobooks, c = abCreds();
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
|
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
|
||||||
|
|||||||
@@ -223,3 +223,9 @@ body.onaudio #view { flex: none; overflow: visible; padding-bottom: 8px; }
|
|||||||
.cday .chips em { font-style: normal; font-size: 12px; line-height: 1.25; padding: 1px 6px; border-radius: 5px; border-left: 3px solid var(--c); background: var(--panel2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
|
.cday .chips em { font-style: normal; font-size: 12px; line-height: 1.25; padding: 1px 6px; border-radius: 5px; border-left: 3px solid var(--c); background: var(--panel2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
|
||||||
.cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; }
|
.cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select, textarea, input[type=date], input[type=datetime-local] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); }
|
||||||
|
textarea { padding: 12px 14px; resize: vertical; font-family: inherit; }
|
||||||
|
.chk { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 16px; margin: 14px 0 0; }
|
||||||
|
.chk input { width: 22px; height: 22px; }
|
||||||
|
.row input[type=checkbox] { width: 22px; height: 22px; }
|
||||||
@@ -12,6 +12,7 @@ const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.neclou
|
|||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const cal = require('./cal');
|
const cal = require('./cal');
|
||||||
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
||||||
|
const PODCAST_URL = process.env.PODCAST_URL || 'http://172.16.16.106:8383/';
|
||||||
const PUBLIC = path.join(__dirname, 'public');
|
const PUBLIC = path.join(__dirname, 'public');
|
||||||
|
|
||||||
const TYPES = {
|
const TYPES = {
|
||||||
@@ -274,6 +275,7 @@ if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app ed
|
|||||||
http.createServer((req, res) => {
|
http.createServer((req, res) => {
|
||||||
const pathname = new URL(req.url, 'http://x').pathname;
|
const pathname = new URL(req.url, 'http://x').pathname;
|
||||||
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
|
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
|
||||||
|
if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session
|
||||||
// Everything else needs a passkey session, except the sign-in page and its assets,
|
// Everything else needs a passkey session, except the sign-in page and its assets,
|
||||||
// and OnlyOffice's save callback (which carries its own signed token).
|
// and OnlyOffice's save callback (which carries its own signed token).
|
||||||
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
|
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
|
||||||
@@ -283,7 +285,7 @@ http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
if (req.url === '/api/config') {
|
if (req.url === '/api/config') {
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, verse: VERSE_URL, editor: true, jwt: !!JWT_SECRET }));
|
return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, verse: VERSE_URL, podcast: PODCAST_URL, editor: true, jwt: !!JWT_SECRET }));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
|
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
|
||||||
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);
|
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);
|
||||||
|
|||||||
Reference in new issue
Block a user