// Just enough iCalendar/vCard to drive a list view. // // SCOPE, DELIBERATELY SMALL. This unfolds lines, splits properties and pulls out the handful of fields a // UI shows. It does NOT expand RRULE, and it does not resolve VTIMEZONE — see // docs/nextcloud-replacement.md for why those are the expensive half and why Radicale, not this file, // owns correctness. `rrule` is passed through raw so the UI can say "repeats" without either of us // pretending to know when. // // Reading is display-only: the authoritative copy of an event is the .ics on disk that DAV clients read // and write, and an edit patches THAT text rather than rebuilding it from what was parsed here. // // `buildEvent` is the one exception, and only because a NEW event has no original to preserve. It is // deliberately the minimum a VEVENT can legally be — no RRULE, no alarms, no attendees. Officer's screens // cannot express those, and writing a field the UI cannot show is how an editor starts lying. /** Unfold RFC 5545 continuation lines (a leading space or tab continues the previous line). */ function unfold(text: string): string[] { const out: string[] = []; for (const raw of text.split(/\r?\n/)) { if ((raw.startsWith(' ') || raw.startsWith('\t')) && out.length > 0) { out[out.length - 1] += raw.slice(1); } else if (raw.length > 0) { out.push(raw); } } return out; } export type Prop = { name: string; params: Record; value: string }; /** `DTSTART;TZID=Europe/Lisbon:20260805T090000` → name, params, value. */ function parseLine(line: string): Prop | null { const colon = line.indexOf(':'); if (colon < 0) return null; const head = line.slice(0, colon); const value = line.slice(colon + 1); const [name, ...paramParts] = head.split(';'); const params: Record = {}; for (const part of paramParts) { const eq = part.indexOf('='); if (eq > 0) params[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1).replace(/^"|"$/g, ''); } return { name: (name ?? '').toUpperCase(), params, value }; } // Escaped per RFC 5545 §3.3.11. Order matters: backslash last, or it would un-escape the escapes. const unescapeText = (value: string) => value.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\'); /** * iCalendar dates come in three shapes and conflating them is how an all-day event ends up on the wrong * day: `20260805` (date only), `20260805T090000Z` (UTC) and `20260805T090000` with a TZID parameter * (floating/local). Only the first two can be resolved here without a timezone database, so the third is * returned as-is with `allDay:false` and the UI treats it as local — which is what it means to whoever * typed it in. */ export function parseDate(prop: Prop): { iso: string; allDay: boolean } | null { const v = prop.value.trim(); const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v); if (dateOnly) return { iso: `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}`, allDay: true }; const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v); if (!dateTime) return null; const [, y, mo, d, h, mi, s, z] = dateTime; return { iso: `${y}-${mo}-${d}T${h}:${mi}:${s}${z ? 'Z' : ''}`, allDay: false }; } export type CalendarEvent = { uid: string; /** Where this event LIVES — the DAV href. Absent when parsed outside a listing. Needed to edit or delete it. */ href?: string; summary: string; description?: string; location?: string; start?: string; end?: string; allDay: boolean; /** Raw RRULE, unexpanded. Present means "this repeats"; the UI must not infer occurrences from it. */ rrule?: string; status?: string; }; /** Every VEVENT in one .ics. A file usually holds one, plus overrides for a recurring series. */ export function parseEvents(ics: string): CalendarEvent[] { const events: CalendarEvent[] = []; let current: CalendarEvent | null = null; // Only VEVENT is collected; a VALARM or VTIMEZONE nested inside must not have its properties // attributed to the event that contains it. let depth: string[] = []; for (const line of unfold(ics)) { const prop = parseLine(line); if (!prop) continue; if (prop.name === 'BEGIN') { depth.push(prop.value.toUpperCase()); if (prop.value.toUpperCase() === 'VEVENT') current = { uid: '', summary: '', allDay: false }; continue; } if (prop.name === 'END') { if (prop.value.toUpperCase() === 'VEVENT' && current) { events.push(current); current = null; } depth.pop(); continue; } if (!current || depth[depth.length - 1] !== 'VEVENT') continue; switch (prop.name) { case 'UID': current.uid = prop.value; break; case 'SUMMARY': current.summary = unescapeText(prop.value); break; case 'DESCRIPTION': current.description = unescapeText(prop.value); break; case 'LOCATION': current.location = unescapeText(prop.value); break; case 'STATUS': current.status = prop.value; break; case 'RRULE': current.rrule = prop.value; break; case 'DTSTART': { const parsed = parseDate(prop); if (parsed) { current.start = parsed.iso; current.allDay = parsed.allDay; } break; } case 'DTEND': { const parsed = parseDate(prop); if (parsed) current.end = parsed.iso; break; } } } return events; } export type Contact = { uid: string; /** Where this contact LIVES — the DAV href. Absent when parsed outside a listing. Needed to edit or delete it. */ href?: string; fullName: string; emails: string[]; phones: string[]; org?: string; title?: string; /** Present when the card carries an embedded photo; the bytes are not inlined into list responses. */ hasPhoto: boolean; }; /** Every VCARD in one .vcf. */ export function parseContacts(vcf: string): Contact[] { const contacts: Contact[] = []; let current: Contact | null = null; for (const line of unfold(vcf)) { const prop = parseLine(line); if (!prop) continue; if (prop.name === 'BEGIN' && prop.value.toUpperCase() === 'VCARD') { current = { uid: '', fullName: '', emails: [], phones: [], hasPhoto: false }; continue; } if (prop.name === 'END' && prop.value.toUpperCase() === 'VCARD') { if (current) contacts.push(current); current = null; continue; } if (!current) continue; switch (prop.name) { case 'UID': current.uid = prop.value; break; case 'FN': current.fullName = unescapeText(prop.value); break; case 'EMAIL': current.emails.push(prop.value.trim()); break; case 'TEL': current.phones.push(prop.value.trim()); break; case 'ORG': // ORG is structured: `Company;Department`. The first component is the one a list shows. current.org = unescapeText(prop.value.split(';')[0] ?? ''); break; case 'TITLE': current.title = unescapeText(prop.value); break; case 'PHOTO': current.hasPhoto = true; break; } } // A card with no FN is legal but unshowable; fall back to the first email so it is at least findable. for (const contact of contacts) { if (!contact.fullName) contact.fullName = contact.emails[0] ?? contact.uid ?? 'Unnamed'; } return contacts; } // ─── Writing ──────────────────────────────────────────────────────────────────────────────────────── export const escapeText = (value: string) => value .replace(/\\/g, '\\\\') .replace(/\r?\n/g, '\\n') .replace(/([,;])/g, '\\$1'); /** * RFC 5545 §3.1: no content line over 75 OCTETS, continued by a line starting with one space. * * Octets, not characters — so this measures encoded length and breaks between code points. A pasted * title with an accent in it is exactly where a naive `slice(0, 75)` splits a multi-byte character in * half and produces a file some clients refuse and others render as mojibake. */ export function fold(line: string): string { const encoder = new TextEncoder(); const out: string[] = []; let current = ''; let bytes = 0; for (const ch of line) { const size = encoder.encode(ch).length; if (bytes + size > 75) { out.push(current); current = ' '; bytes = 1; } current += ch; bytes += size; } out.push(current); return out.join('\r\n'); } export const pad = (n: number) => String(n).padStart(2, '0'); /** `20260827T140000Z`. Always UTC, so the file carries no timezone this codebase would have to resolve. */ export const utcStamp = (date: Date) => `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T` + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`; /** `2026-08-27` → `20260827`. */ export const dateStamp = (iso: string) => iso.slice(0, 10).replace(/-/g, ''); export const addDay = (iso: string) => { const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); return d.toISOString().slice(0, 10); }; export type NewEvent = { summary: string; location?: string; /** An ISO instant for a timed event; `YYYY-MM-DD` when `allDay`. */ start: string; /** Same shape as `start`. For an all-day event this is the LAST day, inclusive, as a person means it. */ end?: string; allDay: boolean; }; /** * A minimal VEVENT, wrapped in its own VCALENDAR — one event per file, which is what every DAV client * writes and what makes an href addressable. * * TIMES ARE WRITTEN IN UTC. The alternative is `DTSTART;TZID=Europe/Lisbon` plus a VTIMEZONE block * defining that zone's rules, and getting a VTIMEZONE subtly wrong moves an event by an hour twice a * year. UTC needs no definition and every client renders it in local time. * * ALL-DAY IS DIFFERENT, and its trap is DTEND. RFC 5545 makes the end date EXCLUSIVE, so a one-day * event on the 27th ends on the 28th. Writing the 27th produces a zero-length event that some clients * hide entirely; callers pass the last day they mean and this adds the day. */ export function buildEvent(uid: string, event: NewEvent): string { const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Officer//caldav//EN', 'BEGIN:VEVENT', `UID:${uid}`]; lines.push(`DTSTAMP:${utcStamp(new Date())}`); if (event.allDay) { const last = event.end?.slice(0, 10) || event.start.slice(0, 10); lines.push(`DTSTART;VALUE=DATE:${dateStamp(event.start)}`); lines.push(`DTEND;VALUE=DATE:${dateStamp(addDay(last))}`); } else { const start = new Date(event.start); // An hour is the default a calendar has taught everyone to expect from an event with no end. const end = event.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000); lines.push(`DTSTART:${utcStamp(start)}`); lines.push(`DTEND:${utcStamp(end)}`); } lines.push(`SUMMARY:${escapeText(event.summary)}`); if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`); lines.push('END:VEVENT', 'END:VCALENDAR'); return lines.map(fold).join('\r\n') + '\r\n'; } /** The inverse of `addDay`. Turns a file's EXCLUSIVE all-day DTEND back into the last day a person means. */ export const subDay = (iso: string) => { const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`); d.setUTCDate(d.getUTCDate() - 1); return d.toISOString().slice(0, 10); }; export type NewContact = { fullName: string; emails?: string[]; phones?: string[]; org?: string; title?: string; }; /** * A minimal vCard 3.0 — the version CardDAV clients, iOS included, actually write. * * `N` is REQUIRED in 3.0 and is `Family;Given;Middle;Prefix;Suffix`. Nobody supplies it, so it is split * out of the full name: last word is the family name, everything before it is given. That is wrong for * some names in the world and right for most, and `FN` — which is what a phone displays — is whatever * was actually typed either way. */ export function buildContact(uid: string, contact: NewContact): string { const [family, ...given] = contact.fullName.trim().split(/\s+/).reverse(); const lines = [ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${uid}`, `FN:${escapeText(contact.fullName)}`, `N:${escapeText(family ?? '')};${escapeText(given.reverse().join(' '))};;;`, ]; for (const email of contact.emails ?? []) lines.push(`EMAIL;TYPE=INTERNET:${escapeText(email)}`); for (const phone of contact.phones ?? []) lines.push(`TEL;TYPE=CELL:${escapeText(phone)}`); if (contact.org) lines.push(`ORG:${escapeText(contact.org)}`); if (contact.title) lines.push(`TITLE:${escapeText(contact.title)}`); lines.push('END:VCARD'); return lines.map(fold).join('\r\n') + '\r\n'; }