Tasks tab: NE Hub lists plus iCloud reminder lists where Apple exposes them
build-container / build (push) Successful in 1m43s

- Lists, quick add, due dates, notes, complete/reopen, delete; Home card for overdue/due today
- iCloud VTODO lists read and written over CalDAV (If-Match, edits patch the latest copy so
  priority/alarms survive); NE Hub lists work regardless
- Fix: text inside a nested VALARM was read as the task's / event's own description and then
  written back on edit; nested components are now skipped (regression tests added)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 15:41:59 -04:00
1 parent 5c7917e57c
commit 916882e55d
9 files changed
+307 -15

No files matched your search

+122
View File
@@ -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 };