diff --git a/Dockerfile b/Dockerfile
index 044e38e..1a72fa9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@ FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
-COPY server.js auth.js cal.js ics.js caldav.js ./
+COPY server.js auth.js cal.js ics.js caldav.js tasks.js ./
COPY public ./public
ENV PORT=8080 DATA_DIR=/data
RUN mkdir -p /data && chown node:node /data
diff --git a/README.md b/README.md
index 87fb5ad..b4657de 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,9 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **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.
+- **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
+ for iOS 13+, so for most accounts only NE Hub lists are available.
- **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.
- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap.
diff --git a/cal.js b/cal.js
index e776d34..7259b09 100644
--- a/cal.js
+++ b/cal.js
@@ -95,11 +95,14 @@ function parseDuration(v) {
}
function parseEvents(text) {
- const events = []; let cur = null;
+ const events = []; let cur = null, depth = 0;
for (const line of unfold(text)) {
- if (line === 'BEGIN:VEVENT') { cur = { ex: [] }; continue; }
+ if (line === 'BEGIN:VEVENT') { cur = { ex: [] }; depth = 0; continue; }
if (line === 'END:VEVENT') { if (cur && cur.start) events.push(cur); cur = null; continue; }
if (!cur) continue;
+ if (/^BEGIN:/.test(line)) { depth++; continue; } // skip nested VALARM so its DESCRIPTION/SUMMARY never replaces the event's
+ if (/^END:/.test(line)) { depth--; continue; }
+ if (depth > 0) continue;
const l = parseLine(line); if (!l) continue;
switch (l.name) {
case 'DTSTART': cur.start = parseDate(l.value, l.params); break;
@@ -336,4 +339,4 @@ async function handle(req, res) {
} catch (e) { json(res, 400, { error: e.message }); }
}
-module.exports = { handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
+module.exports = { unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
diff --git a/caldav.js b/caldav.js
index 2f74947..a7cfc98 100644
--- a/caldav.js
+++ b/caldav.js
@@ -70,19 +70,22 @@ async function connect(appleId, password) {
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 events = !comps || /name="VEVENT"/i.test(comps), todos = !!comps && /name="VTODO"/i.test(comps);
+ if (!events && !todos) continue;
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 });
+ 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, events, todos });
}
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 }; };
+const status = () => { const s = load(); return s ? { connected: true, appleId: s.user, calendars: s.calendars.filter(c => c.events !== false).map(({ id, name, color, enabled }) => ({ id, name, color, enabled })), reminderLists: s.calendars.filter(c => c.todos).length } : { 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 enabled = () => { const s = load(); return s ? s.calendars.filter(c => c.enabled && c.events !== false).map(({ id, name, color }) => ({ id, name, color })) : []; };
+/** Task lists (VTODO collections) the account exposes. Apple hides upgraded Reminders lists from CalDAV, so this may be empty. */
+const todoLists = () => { const s = load(); return s ? s.calendars.filter(c => c.todos).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 } }; };
@@ -90,7 +93,7 @@ const cred = id => { const s = load(); if (!s) throw new Error('iCloud is not co
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 => {
+ await Promise.all(s.calendars.filter(c => c.enabled && c.events !== false).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}`);
@@ -102,6 +105,16 @@ async function listRaw(from, to) {
return out;
}
+const REPORT_TODO = '
Loading…
Loading…
${esc(e.message)}
Nothing to do. Add a task above.
') + + (done.length ? `${showDone ? 'Hide' : 'Show'} completed (${done.length})
${showDone ? `${esc(e.message)}
`; } +} + +function taskSheet(t, cur, list) { + const el = openSheet('Task', list.name, ``); + el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; }; + el.querySelector('#tf').onsubmit = async e => { + e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; + try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked } }) }); closeSheet(); loadTaskList(cur, list); } + catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; } + }; + el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title, 'Delete', async () => { + try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); } + catch (err) { toast(err.message); } + }); +} + +function newListSheet(lists, list) { + const mine = lists.filter(l => l.local && l.id !== 'local:inbox'); + const el = openSheet('Lists', '', `${mine.length ? `Opens each provider's own webmail in a new tab. Nothing is stored here.
diff --git a/public/style.css b/public/style.css index 5d50c96..7f2183a 100644 --- a/public/style.css +++ b/public/style.css @@ -232,3 +232,11 @@ textarea { padding: 12px 14px; resize: vertical; font-family: inherit; } /* Several kept-alive frames share #audiowrap; only the active one is shown (the others stay loaded, out of sight) */ #audiowrap iframe:not(.act) { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; border: 0; } + +/* Tasks */ +.tick { flex: none; color: var(--muted); } +.tick svg { width: 26px; height: 26px; } +.donetask .rmain b { text-decoration: line-through; color: var(--muted); } +.donetask .tick { color: var(--accent); } +.donetask .tick circle { fill: var(--accent); stroke: var(--accent); } +.donetask .tick path { stroke: var(--accent-t); } diff --git a/server.js b/server.js index e437913..059bb27 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.neclou const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, ''); const auth = require('./auth'); const cal = require('./cal'); +const tasks = require('./tasks'); const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin'; const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/'; const PUBLIC = path.join(__dirname, 'public'); @@ -301,6 +302,7 @@ http.createServer((req, res) => { if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res); if (pathname === '/api/ddg') return ddgAnswer(req, res); if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } + if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res); if (req.url.startsWith('/api/cal/')) return cal.handle(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res); if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res); diff --git a/tasks.js b/tasks.js new file mode 100644 index 0000000..5721a9e --- /dev/null +++ b/tasks.js @@ -0,0 +1,122 @@ +// Tasks: a list stored in NE Hub, plus any iCloud reminder lists the account exposes over CalDAV. +// (Apple hides Reminders lists that were upgraded for iOS 13+ from CalDAV, so iCloud lists may be absent; NE Hub lists always work.) +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const ics = require('./ics'); +const cal = require('./cal'); +const caldav = require('./caldav'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const FILE = path.join(DATA_DIR, 'tasks.json'); +const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } }; +const save = d => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(d, null, 2), { mode: 0o600 }); }; +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 isLocal = id => String(id || '').startsWith('local:'); +const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d']; + +/** Tasks from a VTODO resource (iCloud). */ +function parseTodos(text) { + const out = []; let cur = null, depth = 0; + for (const line of cal.unfold(text)) { + if (line === 'BEGIN:VTODO') { cur = {}; depth = 0; continue; } + if (line === 'END:VTODO') { if (cur && cur.uid) out.push(cur); cur = null; continue; } + if (!cur) continue; + if (/^BEGIN:/.test(line)) { depth++; continue; } // a nested VALARM has its own SUMMARY/DESCRIPTION: not the task's + if (/^END:/.test(line)) { depth--; continue; } + if (depth > 0) continue; + const l = cal.parseLine(line); if (!l) continue; + if (l.name === 'UID') cur.uid = l.value; + else if (l.name === 'SUMMARY') cur.title = cal.unesc(l.value); + else if (l.name === 'DESCRIPTION') cur.notes = cal.unesc(l.value); + else if (l.name === 'STATUS') cur.status = l.value.toUpperCase(); + else if (l.name === 'PERCENT-COMPLETE') cur.percent = parseInt(l.value, 10); + else if (l.name === 'COMPLETED') cur.completed = true; + else if (l.name === 'DUE') { const d = cal.parseDate(l.value, l.params); if (d) cur.due = `${d.y}-${String(d.mo).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`; } + } + return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed })); +} +const fieldsOf = t => ({ title: t.title, notes: t.notes, due: t.due, done: t.done }); + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams; + try { + if (p === '/api/tasks/lists' && req.method === 'GET') { + const d = load(), st = caldav.status(); + return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))], + icloud: { connected: !!st.connected, reminderLists: st.reminderLists || 0 } }); + } + if (p === '/api/tasks/lists' && req.method === 'POST') { + const { name } = JSON.parse(await readBody(req)), d = load(); + if (!String(name || '').trim()) throw new Error('Give the list a name'); + const id = crypto.randomBytes(4).toString('hex'); d.lists.push({ id, name: String(name).trim().slice(0, 40) }); save(d); + return json(res, 200, { ok: true, id: 'local:' + id }); + } + if (p === '/api/tasks/lists/delete' && req.method === 'POST') { + const { id } = JSON.parse(await readBody(req)), d = load(), raw = String(id).replace(/^local:/, ''); + if (raw === 'inbox') throw new Error('The main list cannot be deleted'); + d.lists = d.lists.filter(l => l.id !== raw); d.tasks = d.tasks.filter(t => t.list !== raw); save(d); + return json(res, 200, { ok: true }); + } + if (p === '/api/tasks' && req.method === 'GET') { + const list = q.get('list'); + if (isLocal(list)) { + const raw = list.slice(6); + return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null }))); + } + if (caldav.todoLists().some(l => l.id === list)) { + const rows = await caldav.listTodoRaw(list), tasks = []; + for (const r of rows) for (const t of parseTodos(r.ics)) tasks.push({ ...t, list, href: r.href, etag: r.etag }); + return json(res, 200, tasks); + } + return json(res, 404, { error: 'List not found' }); + } + if (p === '/api/tasks' && req.method === 'POST') { + const b = JSON.parse(await readBody(req)), f = b.fields; + ics.validateTodo(f); + if (isLocal(b.list)) { + const d = load(), raw = b.list.slice(6); + if (!d.lists.some(l => l.id === raw)) throw new Error('List not found'); + if (b.id) { + const t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found'); + const wasDone = !!t.done; Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, updated: Date.now() }); + if (t.done && !wasDone) t.completedAt = Date.now(); if (!t.done) t.completedAt = null; + if (b.moveTo && isLocal(b.moveTo)) t.list = b.moveTo.slice(6); + } else d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, created: Date.now(), updated: Date.now() }); + save(d); return json(res, 200, { ok: true }); + } + if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive + const cur = await caldav.getResource(b.list, b.href); + await caldav.putEvent(b.list, null, ics.patchTodo(cur.ics, f), { href: b.href, etag: cur.etag }); + } else { const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }])); } + return json(res, 200, { ok: true }); + } + if (p === '/api/tasks/toggle' && req.method === 'POST') { + const b = JSON.parse(await readBody(req)); + if (isLocal(b.list)) { + const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found'); + t.done = !!b.done; t.completedAt = t.done ? Date.now() : null; t.updated = Date.now(); save(d); return json(res, 200, { ok: true }); + } + const cur = await caldav.getResource(b.list, b.href); + const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task'); + await caldav.putEvent(b.list, null, ics.patchTodo(cur.ics, { ...fieldsOf(t), done: !!b.done }), { href: b.href, etag: cur.etag }); + return json(res, 200, { ok: true }); + } + if (p === '/api/tasks/delete' && req.method === 'POST') { + const b = JSON.parse(await readBody(req)); + if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id); save(d); return json(res, 200, { ok: true }); } + await caldav.deleteEvent(b.list, b.href, b.etag); return json(res, 200, { ok: true }); + } + if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today + const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 }; + const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; }); + count(load().tasks); + for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch {} } + return json(res, 200, out); + } + json(res, 404, { error: 'Not found' }); + } catch (e) { json(res, 400, { error: e.message }); } +} + +module.exports = { handle, parseTodos };