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>
212 lines
12 KiB
JavaScript
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 };
|