Files
nmemmertandClaude Sonnet 5.5 916882e55d
build-container / build (push) Successful in 1m43s
Tasks tab: NE Hub lists plus iCloud reminder lists where Apple exposes them
- 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>
2026-10-20 15:41:59 -04:00

212 lines
12 KiB
JavaScript

// Building and editing iCalendar (ICS) text: used for the NE Hub calendar, its subscribe feed, and iCloud writes.
//
// "fields" is what the app's event form edits:
// { title, location, notes, allDay, start: 'YYYY-MM-DDTHH:mm' | 'YYYY-MM-DD', end: (same; all-day end is inclusive),
// tz: IANA zone, repeat: 'none' | 'daily' | 'weekdays' | 'weekly' | 'monthly' | 'yearly' | 'keep' }
const crypto = require('crypto');
const pad = n => String(n).padStart(2, '0');
const esc = v => String(v || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
const fold = line => { // 75 octets per line, continuation lines start with a space
if (Buffer.byteLength(line) <= 75) return line;
const out = []; let cur = '', bytes = 0;
for (const ch of line) {
const b = Buffer.byteLength(ch), limit = out.length ? 74 : 75;
if (bytes + b > limit) { out.push(cur); cur = ''; bytes = 0; }
cur += ch; bytes += b;
}
out.push(cur);
return out.join('\r\n ');
};
const ymdhms = (d, t) => d.replace(/-/g, '') + (t ? 'T' + t.replace(/:/g, '').padEnd(6, '0') : '');
const nowStamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const validTz = tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return !!tz; } catch { return false; } };
// ---- Time zones
const fmtCache = new Map();
function partsAt(ms, tz) {
let f = fmtCache.get(tz);
if (!f) fmtCache.set(tz, f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }));
return Object.fromEntries(f.formatToParts(new Date(ms)).map(x => [x.type, +x.value]));
}
const offsetMin = (ms, tz) => { const p = partsAt(ms, tz); return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000) / 6e4); };
const fmtOff = m => (m < 0 ? '-' : '+') + pad(Math.floor(Math.abs(m) / 60)) + pad(Math.abs(m) % 60);
/** UTC ms -> 'YYYY-MM-DDTHH:mm' wall clock in tz */
function wallClock(ms, tz) { const p = partsAt(ms, tz); return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`; }
// A VTIMEZONE built from the zone's real offset changes in a year, so other apps do not have to guess.
function vtimezone(tz, year = new Date().getUTCFullYear()) {
const L = ['BEGIN:VTIMEZONE', 'TZID:' + tz];
const changes = [];
let prev = offsetMin(Date.UTC(year, 0, 1), tz);
for (let d = 1; d <= 366; d++) {
const t = Date.UTC(year, 0, 1) + d * 864e5, o = offsetMin(t, tz);
if (o !== prev) { // narrow the change down to the minute
let lo = t - 864e5, hi = t;
while (hi - lo > 6e4) { const mid = Math.floor((lo + hi) / 2); if (offsetMin(mid, tz) === prev) lo = mid; else hi = mid; }
changes.push({ at: hi, from: prev, to: o }); prev = o;
}
}
const abbr = ms => (new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'short' }).formatToParts(new Date(ms)).find(x => x.type === 'timeZoneName') || {}).value || '';
const comp = (kind, from, to, at) => {
const wall = new Date(at + from * 6e4), dow = wall.getUTCDay(), day = wall.getUTCDate();
const dim = new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0)).getUTCDate();
const nth = day + 7 > dim ? -1 : Math.ceil(day / 7);
return ['BEGIN:' + kind, `DTSTART:1970${pad(wall.getUTCMonth() + 1)}01T${pad(wall.getUTCHours())}${pad(wall.getUTCMinutes())}00`.replace(/^(DTSTART:1970\d\d)01/, '$1' + '01'),
`RRULE:FREQ=YEARLY;BYMONTH=${wall.getUTCMonth() + 1};BYDAY=${nth}${['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][dow]}`,
'TZOFFSETFROM:' + fmtOff(from), 'TZOFFSETTO:' + fmtOff(to), 'TZNAME:' + abbr(at + 6e4), 'END:' + kind];
};
if (!changes.length) L.push('BEGIN:STANDARD', 'DTSTART:19700101T000000', 'TZOFFSETFROM:' + fmtOff(prev), 'TZOFFSETTO:' + fmtOff(prev), 'TZNAME:' + abbr(Date.UTC(year, 0, 1)), 'END:STANDARD');
else for (const c of changes) L.push(...comp(c.to > c.from ? 'DAYLIGHT' : 'STANDARD', c.from, c.to, c.at));
L.push('END:VTIMEZONE');
return L;
}
// ---- Building
const RRULES = { daily: 'FREQ=DAILY', weekdays: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', weekly: 'FREQ=WEEKLY', monthly: 'FREQ=MONTHLY', yearly: 'FREQ=YEARLY' };
function validate(f) {
if (!f || !String(f.title || '').trim()) throw new Error('Give the event a title');
const re = f.allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
if (!re.test(f.start || '') || !re.test(f.end || '')) throw new Error('Start and end are required');
if (f.end < f.start) throw new Error('The event ends before it starts');
if (!f.allDay && !validTz(f.tz)) throw new Error('Unknown time zone');
if (f.repeat && f.repeat !== 'none' && f.repeat !== 'keep' && !RRULES[f.repeat]) throw new Error('Unknown repeat option');
}
/** The property lines (unfolded) that the form controls. */
function propLines(f) {
const L = ['SUMMARY:' + esc(String(f.title).trim())];
if (f.location) L.push('LOCATION:' + esc(f.location));
if (f.notes) L.push('DESCRIPTION:' + esc(f.notes));
if (f.allDay) {
const end = new Date(Date.parse(f.end + 'T00:00:00Z') + 864e5).toISOString().slice(0, 10); // ICS all-day end is exclusive
L.push('DTSTART;VALUE=DATE:' + ymdhms(f.start), 'DTEND;VALUE=DATE:' + ymdhms(end));
} else if (f.tz === 'UTC') {
L.push('DTSTART:' + ymdhms(...f.start.split('T')) + 'Z', 'DTEND:' + ymdhms(...f.end.split('T')) + 'Z');
} else {
L.push(`DTSTART;TZID=${f.tz}:` + ymdhms(...f.start.split('T')), `DTEND;TZID=${f.tz}:` + ymdhms(...f.end.split('T')));
}
if (f.repeat && f.repeat !== 'none' && f.repeat !== 'keep') L.push('RRULE:' + RRULES[f.repeat]);
return L;
}
function buildVevent(f, uid) {
validate(f);
return ['BEGIN:VEVENT', 'UID:' + uid, 'DTSTAMP:' + nowStamp(), 'SEQUENCE:0', ...propLines(f), 'END:VEVENT'];
}
/** A whole VCALENDAR from [{uid, fields}] */
function buildCalendar(items, name = 'NE Hub') {
const zones = [...new Set(items.filter(i => !i.fields.allDay && i.fields.tz && i.fields.tz !== 'UTC').map(i => i.fields.tz))];
const L = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//NE Hub//Calendar//EN', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:' + esc(name)];
for (const tz of zones) L.push(...vtimezone(tz));
for (const i of items) L.push(...buildVevent(i.fields, i.uid));
L.push('END:VCALENDAR');
return L.map(fold).join('\r\n') + '\r\n';
}
/** Edit an existing resource in place: only the properties the form controls change, everything else (alarms, attendees…) stays. */
function patchIcs(text, f) {
validate(f);
const lines = text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter(l => l !== '');
const out = [], drop = new Set(['SUMMARY', 'LOCATION', 'DESCRIPTION', 'DTSTART', 'DTEND', 'DURATION']);
if (f.repeat !== 'keep') drop.add('RRULE');
let inEv = false, done = false, hasTz = lines.some(l => l === 'BEGIN:VTIMEZONE' && lines.some(x => x === 'TZID:' + f.tz));
let master = false, seq = 0, sawStamp = false;
// find out whether this VEVENT is the master (no RECURRENCE-ID) before editing it
const evBlocks = []; let s = -1;
lines.forEach((l, i) => { if (l === 'BEGIN:VEVENT') s = i; if (l === 'END:VEVENT' && s >= 0) { evBlocks.push([s, i]); s = -1; } });
const target = evBlocks.find(([a, b]) => !lines.slice(a, b).some(l => /^RECURRENCE-ID/.test(l)));
if (!target) throw new Error('Could not find the event in that calendar entry');
const needTz = !f.allDay && f.tz && f.tz !== 'UTC' && !hasTz;
let depth = 0; // nested components (VALARM) keep their own SUMMARY/DESCRIPTION/TRIGGER lines untouched
lines.forEach((l, i) => {
if (i === target[0]) { if (needTz) out.push(...vtimezone(f.tz)); out.push(l); inEv = true; return; }
if (i === target[1]) { out.push(...propLines(f)); if (!sawStamp) out.push('DTSTAMP:' + nowStamp()); out.push('SEQUENCE:' + (seq + 1)); out.push(l); inEv = false; return; }
if (!inEv || i < target[0] || i > target[1]) { 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 (drop.has(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);
});
// a series changed from timed to all-day (or back) leaves stale EXDATEs of the wrong type; drop them
return out.map(fold).join('\r\n') + '\r\n';
}
/** Form fields from a parsed master event (see cal.js parseEvents). */
function fieldsFromEvent(ev, clientTz) {
const s = ev.start, tzOk = tz => tz && tz !== 'UTC' && validTz(tz);
const rr = ev.rrule;
let repeat = 'none';
if (rr) {
const by = (rr.BYDAY || '').split(',').sort().join(',');
repeat = rr.COUNT || rr.UNTIL || (rr.INTERVAL && rr.INTERVAL !== '1') ? 'keep'
: rr.FREQ === 'DAILY' && !rr.BYDAY ? 'daily'
: rr.FREQ === 'WEEKLY' && by === 'FR,MO,TH,TU,WE' ? 'weekdays'
: rr.FREQ === 'WEEKLY' && !rr.BYMONTH && (!rr.BYDAY || rr.BYDAY.split(',').length === 1) ? 'weekly'
: rr.FREQ === 'MONTHLY' && !rr.BYDAY && !rr.BYMONTHDAY ? 'monthly'
: rr.FREQ === 'YEARLY' && !rr.BYDAY && !rr.BYMONTH ? 'yearly' : 'keep';
}
const base = { title: ev.title || '', location: ev.location || '', notes: ev.notes || '', repeat };
const endMs = ev.end ? ev.end.ms : ev.dur != null ? s.ms + ev.dur : s.allDay ? s.ms + 864e5 : s.ms;
if (s.allDay) {
const last = new Date(Math.max(endMs - 864e5, s.ms)).toISOString().slice(0, 10);
return { ...base, allDay: true, start: new Date(s.ms).toISOString().slice(0, 10), end: last, tz: clientTz };
}
const tz = tzOk(s.tz) ? s.tz : clientTz; // zone-less / UTC times are shown in the viewer's own zone
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 = { validateTodo, buildTodoCalendar, patchTodo, buildVevent, buildCalendar, patchIcs, fieldsFromEvent, vtimezone, validate, newUid, wallClock, validTz };