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 = ''; +/** Raw task entries in one list: [{ href, etag, ics }] */ +async function listTodoRaw(id) { + const { c, o } = cred(id); + const r = await dav('REPORT', c.href, { ...o, depth: 1, body: REPORT_TODO }); + 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}`); + return responses(r.text).map(blk => ({ href: abs(hrefOf(blk), r.url), etag: decode(tag(blk, 'getetag') || '').trim(), ics: decode(tag(blk, 'calendar-data') || '') })).filter(x => x.ics); +} + async function getResource(id, href) { const { c, o } = cred(id); inside(c, href); const r = await dav('GET', href, o); @@ -126,4 +139,4 @@ async function deleteEvent(id, href, etag) { 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 || '') }; +module.exports = { connect, status, toggle, enabled, todoLists, listTodoRaw, listRaw, getResource, putEvent, deleteEvent, wipe, isCal: id => /^icloud:[0-9a-f]{10}$/.test(id || '') }; diff --git a/ics.js b/ics.js index fb1302b..80cad87 100644 --- a/ics.js +++ b/ics.js @@ -162,6 +162,50 @@ function fieldsFromEvent(ev, clientTz) { return { ...base, allDay: false, start: wallClock(s.ms, tz), end: wallClock(endMs, tz), tz }; } + +// ---- Tasks (VTODO). fields: { title, notes, due: '' | 'YYYY-MM-DD', done: boolean } +function validateTodo(f) { + if (!f || !String(f.title || '').trim()) throw new Error('Give the task a title'); + if (f.due && !/^\d{4}-\d{2}-\d{2}$/.test(f.due)) throw new Error('The due date is not valid'); +} +function todoLines(f, { completed = true } = {}) { + const L = ['SUMMARY:' + esc(String(f.title).trim())]; + if (f.notes) L.push('DESCRIPTION:' + esc(f.notes)); + if (f.due) L.push('DUE;VALUE=DATE:' + f.due.replace(/-/g, '')); + if (f.done) { L.push('STATUS:COMPLETED', 'PERCENT-COMPLETE:100'); if (completed) L.push('COMPLETED:' + nowStamp()); } + else L.push('STATUS:NEEDS-ACTION'); + return L; +} +function buildTodoCalendar(items) { + const L = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//NE Hub//Tasks//EN']; + for (const i of items) { validateTodo(i.fields); L.push('BEGIN:VTODO', 'UID:' + i.uid, 'DTSTAMP:' + nowStamp(), 'CREATED:' + nowStamp(), 'SEQUENCE:0', ...todoLines(i.fields), 'END:VTODO'); } + L.push('END:VCALENDAR'); + return L.map(fold).join('\r\n') + '\r\n'; +} +/** Edit an existing VTODO resource in place; alarms and other properties are kept. */ +function patchTodo(text, f) { + validateTodo(f); + const lines = text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter(l => l !== ''); + const a = lines.indexOf('BEGIN:VTODO'), b = lines.indexOf('END:VTODO'); + if (a < 0 || b < a) throw new Error('Could not find the task in that entry'); + const out = []; let depth = 0, seq = 0, hadCompleted = false, sawStamp = false; + lines.forEach((l, i) => { + if (i < a || i > b) { out.push(l); return; } + if (i === a) { out.push(l); return; } + if (i === b) { out.push(...todoLines(f, { completed: !hadCompleted }), 'SEQUENCE:' + (seq + 1)); if (!sawStamp) out.push('DTSTAMP:' + nowStamp()); 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 (name === 'COMPLETED') { if (f.done) { hadCompleted = true; out.push(l); } return; } // keep the original completion time + if (['SUMMARY', 'DESCRIPTION', 'DUE', 'STATUS', 'PERCENT-COMPLETE'].includes(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); + }); + return out.map(fold).join('\r\n') + '\r\n'; +} + const newUid = () => crypto.randomUUID() + '@nehub'; -module.exports = { buildVevent, buildCalendar, patchIcs, fieldsFromEvent, vtimezone, validate, newUid, wallClock, validTz }; +module.exports = { validateTodo, buildTodoCalendar, patchTodo, buildVevent, buildCalendar, patchIcs, fieldsFromEvent, vtimezone, validate, newUid, wallClock, validTz }; diff --git a/public/app.js b/public/app.js index 386d367..4b85dbd 100644 --- a/public/app.js +++ b/public/app.js @@ -40,6 +40,7 @@ const ICONS = { search: '', book: '', mic: '', + check: '', trash: '', }; const icon = (n, cls = '') => `${ICONS[n]}`; @@ -97,9 +98,9 @@ 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' }); // ---- Navigation -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', 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 TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['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', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' }; +const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More" const phone = matchMedia('(max-width: 767px)'); let activeTab = 'home'; @@ -122,7 +123,7 @@ function openMore() { el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); } -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 }; +const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit }; let editor; function route() { const [, id = 'home', ...rest] = location.hash.split('/'); @@ -151,6 +152,7 @@ function viewHome() { $('#view').innerHTML = `

${greet}

${icon('search')}Search files, music, libraries ${t ? `

Now playing

` : ''} +
${recents.length ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''}

Apps

@@ -170,6 +172,7 @@ function viewHome() { $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); }); + renderTasksCard($('#tcard')); if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today'); } @@ -487,6 +490,100 @@ async function manageCalendars() { 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(); }); } +// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes +const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' }); + return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; }; +const taskStore = []; // tasks on screen, so a tap can open the right one + +async function viewTasks() { + const v = $('#view'); + v.innerHTML = '

Tasks

Loading…

'; + try { + const { lists, icloud } = await jfetch('/api/tasks/lists'); + if (!location.hash.startsWith('#/tasks')) return; + let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id; + const list = lists.find(l => l.id === cur); + v.innerHTML = `

Tasks

+ +
+

Loading…

+

`; + $('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.' + : icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : ''; + v.querySelectorAll('.seg a[data-l]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('task_list', a.dataset.l); viewTasks(); }); + $('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); }; + $('#qa').onsubmit = async e => { + e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return; + try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); } + catch (err) { toast(err.message); } + }; + loadTaskList(cur, list); + } catch (e) { v.innerHTML = `

Tasks

${esc(e.message)}

`; } +} + +async function loadTaskList(cur, list) { + const box = $('#tl'); if (!box) return; + try { + const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur)); + if (!$('#tl')) return; + taskStore.length = 0; + const open = tasks.filter(t => !t.done).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)); + const done = tasks.filter(t => t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title)); + const row = t => { const i = taskStore.push(t) - 1, du = dueInfo(t.due); + return `
+
`; }; + const showDone = store.get('task_show_done', false); + box.innerHTML = (open.length ? `
${open.map(row).join('')}
` : '

Nothing to do. Add a task above.

') + + (done.length ? `

${showDone ? 'Hide' : 'Show'} completed (${done.length})

${showDone ? `
${done.map(row).join('')}
` : ''}` : ''); + const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); }; + box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => { + const t = taskStore[+b.dataset.t]; b.disabled = true; + try { await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); loadTaskList(cur, list); } + catch (err) { b.disabled = false; toast(err.message); } + }); + box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list)); + } catch (e) { box.innerHTML = `

${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 ? `
${mine.map(l => `
${esc(l.name)}
`).join('')}
` : ''} +
+
`); + el.querySelector('#sx').onclick = closeSheet; + el.querySelector('#lf').onsubmit = async e => { e.preventDefault(); try { const r = await jfetch('/api/tasks/lists', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#ln').value }) }); store.set('task_list', r.id); closeSheet(); viewTasks(); } catch (err) { el.querySelector('#le').textContent = err.message; } }; + el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); })); +} + +// Home: a card only when something is overdue or due today +async function renderTasksCard(el) { + if (!el) return; + try { + const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today; + if (!n || !document.body.contains(el)) return; + el.innerHTML = `

Tasks

${icon('check')}${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}${icon('chev', 'chev')}`; + } catch {} +} + function viewMail() { $('#view').innerHTML = `

Mail

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 };