// 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 atomicWrite = require('./atomic'); const path = require('path'); const crypto = require('crypto'); const ics = require('./ics'); const cal = require('./cal'); const caldav = require('./caldav'); const users = require('./users'); const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } }; const save = d => atomicWrite(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 })); } // ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it. const REPEATS = ['none', 'daily', 'weekdays', 'weekly', 'monthly', 'yearly']; const toYmd = d => d.toISOString().slice(0, 10); const fromYmd = s => { const [y, m, d] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)); }; function stepDate(s, repeat) { const d = fromYmd(s), day = d.getUTCDate(); if (repeat === 'daily') d.setUTCDate(day + 1); else if (repeat === 'weekdays') { do d.setUTCDate(d.getUTCDate() + 1); while ([0, 6].includes(d.getUTCDay())); } else if (repeat === 'weekly') d.setUTCDate(day + 7); else if (repeat === 'monthly') { d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() + 1); d.setUTCDate(Math.min(day, new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate())); } else if (repeat === 'yearly') { d.setUTCDate(1); d.setUTCFullYear(d.getUTCFullYear() + 1); d.setUTCDate(Math.min(day, new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate())); } return toYmd(d); } /** The next due date after finishing a repeating task: one step after the due date, and always after today. */ function nextDue(due, repeat, today = toYmd(new Date())) { let n = stepDate(due || today, repeat), guard = 0; while (n <= today && guard++ < 4000) n = stepDate(n, repeat); return n; } const cleanRepeat = r => REPEATS.includes(r) ? r : 'none'; /** Mark a local task done; a repeating one rolls forward instead. Returns the new due date when it rolled. */ function finishLocal(t, done) { if (done && !t.done && t.repeat && t.repeat !== 'none') { t.due = nextDue(t.due, t.repeat); t.done = false; t.completedAt = null; t.lastDone = Date.now(); t.updated = Date.now(); return t.due; } t.done = !!done; t.completedAt = t.done ? Date.now() : null; t.updated = Date.now(); return null; } function validParent(d, raw, parent, selfId) { if (!parent) return ''; const p = d.tasks.find(x => x.id === parent && x.list === raw); if (!p || p.parent || parent === selfId) throw new Error('A subtask can only go under a main task in the same list'); if (selfId && d.tasks.some(x => x.parent === selfId)) throw new Error('A task that has subtasks cannot become a subtask'); return parent; } 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, repeat: t.repeat || 'none', parent: t.parent || '' }))); } 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, repeat = f.repeat === undefined ? t.repeat || 'none' : cleanRepeat(f.repeat); Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', repeat, updated: Date.now() }); if (f.parent !== undefined) t.parent = validParent(d, raw, f.parent, t.id) || undefined; const rolled = finishLocal(t, !!f.done); if (wasDone && f.done) { t.done = true; t.completedAt = t.completedAt || Date.now(); } if (b.moveTo && isLocal(b.moveTo) && !t.parent) { const to = b.moveTo.slice(6); d.tasks.filter(x => x.parent === t.id).forEach(x => { x.list = to; }); t.list = to; } save(d); return json(res, 200, { ok: true, rolled }); } 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, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, 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'); const rolled = finishLocal(t, !!b.done); save(d); return json(res, 200, { ok: true, rolled }); } 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 && x.parent !== 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 }); } } /** Every task from every list (NE Hub and iCloud), with its list name. */ async function allTasks() { const d = load(), out = []; for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done }); } for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} } return out; } module.exports = { handle, parseTodos, allTasks, nextDue, stepDate };