step 3 of docs/nextcloud-replacement.md. collections, events and contacts as plain json, so the browser never has to parse multistatus xml to draw a list. this talks dav to radicale over loopback rather than reading its on-disk format. the storage layout is radicale's private business and changes between versions; propfind is its supported interface and costs one in-process hop. parsing the storage directly would be faster and would break silently on upgrade, which is a bad trade for a calendar. three bugs found and fixed while verifying, all of which fail quietly rather than loudly: calendar-data and address-data are NOT webdav live properties. rfc 4791 and 6352 define them as report-only, and radicale correctly returns an empty prop for them under propfind — so the first version returned a 207 full of nothing, which reads exactly like "your calendar is empty". the principal resource matched the calendar test, because `<C:calendar-home-set/>` satisfies /calendar\b/. the principal showed up in the list as a calendar called "1". the tag has to be required to end. the internal fetcher omitted X-Script-Name, so the ui was handed /1/work/ for the same collection a phone sees as /dav/1/work/, and nothing downstream could have matched them up. ical.ts is deliberately small: it unfolds lines and pulls out the fields a list shows. it does NOT expand rrule or resolve vtimezone — radicale owns correctness there, and rrule is passed through raw so the ui can say "repeats" without either of us pretending to know when. verified against the running stack with a real vevent and a real vcard, then the fixtures were removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
6.8 KiB
TypeScript
203 lines
6.8 KiB
TypeScript
// 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<string, string>; 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<string, string> = {};
|
|
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;
|
|
}
|