// 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. // // Everything here is display-only. The authoritative copy of an event is the .ics on disk that DAV // clients read and write; nothing in this file ever writes one back. /** 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; 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; 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; }