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

+1 -1
View File
@@ -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
+3
View File
@@ -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.
+6 -3
View File
@@ -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 };
+19 -6
View File
@@ -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 = '<?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="VTODO"/></c:comp-filter></c:filter></c:calendar-query>';
/** 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 || '') };
+45 -1
View File
@@ -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 };
+101 -4
View File
@@ -40,6 +40,7 @@ const ICONS = {
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"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
};
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
@@ -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 = `<div class="wrap"><h1>${greet}</h1>
<a class="searchbar" href="#/search">${icon('search')}<span>Search files, music, libraries</span></a>
${t ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
<div id="tcard"></div>
<div id="agenda"></div>
${recents.length ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
<h2>Apps</h2><div class="grid">
@@ -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 = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>';
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 = `<div class="wrap"><h1>Tasks</h1>
<div class="seg">${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div id="tl"><p class="empty">Loading…</p></div>
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
$('#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 = `<div class="wrap"><h1>Tasks</h1><p class="err">${esc(e.message)}</p></div>`; }
}
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 `<div class="row it${t.done ? ' donetask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
const showDone = store.get('task_show_done', false);
box.innerHTML = (open.length ? `<div class="list">${open.map(row).join('')}</div>` : '<p class="empty">Nothing to do. Add a task above.</p>')
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(row).join('')}</div>` : ''}` : '');
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 = `<p class="err">${esc(e.message)}</p>`; }
}
function taskSheet(t, cur, list) {
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
<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">Save</button></div>
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
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 ? `<div class="list" style="background:var(--panel2);border:0">${mine.map(l => `<div class="row"><span class="grow"><b>${esc(l.name)}</b></span><button class="btn danger" data-rm="${esc(l.id)}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
<form id="lf"><label for="ln">New list</label><input type="text" id="ln" placeholder="Groceries" autocomplete="off" required>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Add list</button></div><div class="err" id="le"></div></form>`);
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 = `<h2>Tasks</h2><a class="row card2" href="#/tasks">${icon('check')}<span class="grow"><b>${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention</b><small>${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}</small></span>${icon('chev', 'chev')}</a>`;
} catch {}
}
function viewMail() {
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
<p class="sub">Opens each provider's own webmail in a new tab. Nothing is stored here.</p>
+8
View File
@@ -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); }
+2
View File
@@ -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);
+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 };