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

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