import { buildContact, buildEvent, parseContacts, parseEvents, type CalendarEvent, type Contact, type NewContact, type NewEvent, } from './ical'; // The JSON door: what Officer's own web UI reads. // // This talks DAV to Radicale over loopback rather than reading Radicale's 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 HTTP hop. Parsing the storage directly would be faster and would // break silently on an upgrade, which is a bad trade for a personal calendar. // // The browser never sees any of this XML — that is the entire point of having a JSON door. See // docs/nextcloud-replacement.md, "two front doors, not one". export type Collection = { /** DAV path, relative to the /dav mount — the id the UI passes back on every read. */ path: string; /** * The last path segment, and the only part of the path that identifies anything. The web URLs select * with this rather than the whole path: `?collection=personal` instead of `?collection=%2Fdav%2F1%2Fpersonal%2F`, * which is the same information without the user id, the mount point and six escaped slashes. */ name: string; displayName: string; kind: 'calendar' | 'addressbook'; /** Radicale stores a per-collection colour when a client sets one; used for the UI's dot. */ color?: string; }; type Fetcher = (path: string, init: RequestInit) => Promise; // Deliberately not a real XML parser. The only documents read here are Radicale's own multistatus // responses — a narrow, machine-generated shape, not arbitrary user XML — and adding a parser // dependency to pull four fields out of it is not worth the weight. If this ever needs to read XML // from somewhere less predictable, it should grow a parser rather than more regexes. const between = (xml: string, tag: string): string[] => { const out: string[] = []; const re = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tag}[^>]*>([\\s\\S]*?)`, 'g'); let m: RegExpExecArray | null; while ((m = re.exec(xml))) out.push(m[1] ?? ''); return out; }; const first = (xml: string, tag: string): string | undefined => between(xml, tag)[0]; const decodeEntities = (s: string) => s .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); const PROPFIND_COLLECTIONS = ` `; /** Every calendar and address book belonging to this user. */ export async function listCollections(fetcher: Fetcher, userId: string): Promise { const res = await fetcher(`/${userId}/`, { method: 'PROPFIND', headers: { Depth: '1', 'Content-Type': 'application/xml' }, body: PROPFIND_COLLECTIONS, }); if (!res.ok && res.status !== 207) return []; const xml = await res.text(); const collections: Collection[] = []; for (const response of between(xml, 'response')) { const href = first(response, 'href'); if (!href) continue; // The tag must END here — `\b` alone also matches ``, which the principal // carries, and that made the principal itself show up in the list as a calendar named "1". const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\s*\/>/.test(response); const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\s*\/>/.test(response); if (!isCalendar && !isAddressbook) continue; // the principal itself, or a plain collection const path = decodeEntities(href.trim()); const displayName = decodeEntities((first(response, 'displayname') ?? '').trim()); const color = (first(response, 'calendar-color') ?? '').trim() || undefined; const lastSegment = path.replace(/\/$/, '').split('/').pop() || 'Untitled'; collections.push({ path, name: lastSegment, // Radicale's default displayname is the collection's own path ("1/work"), which is an id, not a // name. A real client sets something human when it creates a collection; until one does, the last // segment is the better label — and it is what every DAV client falls back to anyway. displayName: displayName && !displayName.includes('/') ? displayName : lastSegment, kind: isCalendar ? 'calendar' : 'addressbook', color, }); } return collections; } /** * Every object in one collection, in one round trip. * * REPORT, not PROPFIND. `calendar-data` and `address-data` are not WebDAV live properties — RFC 4791 and * RFC 6352 define them as report-only, and Radicale correctly returns an empty prop for them under * PROPFIND. Asking the wrong way produces a 207 full of nothing, which reads exactly like "the calendar * is empty" and is a genuinely confusing way to be wrong. * * One report rather than a listing plus a GET per item: a calendar with a thousand events would * otherwise be a thousand round trips. */ async function readObjects( fetcher: Fetcher, path: string, kind: 'calendar' | 'addressbook', ): Promise<{ href: string; data: string }[]> { const dataTag = kind === 'calendar' ? 'calendar-data' : 'address-data'; const ns = kind === 'calendar' ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav'; const queryTag = kind === 'calendar' ? 'calendar-query' : 'addressbook-query'; // An empty filter means "everything". A calendar-query with no comp-filter is legal and is the // cheapest way to say it. const filter = kind === 'calendar' ? '' : ''; const res = await fetcher(path, { method: 'REPORT', headers: { Depth: '1', 'Content-Type': 'application/xml' }, body: `${filter}`, }); if (!res.ok && res.status !== 207) return []; const xml = await res.text(); /* Paired with the HREF, because a UID is not an address. Reading returned only the object's data, which is enough to SHOW something and not enough to change it: DELETE and PUT both need the item's URL, and a `.ics` filename is only conventionally the UID — Radicale stores what the client that wrote it chose. Deriving `.ics` works for everything Officer wrote and fails for everything a phone did, which is the worst kind of nearly-right. So each `` is split first and the href taken from it, rather than pulling every `calendar-data` out of the whole document and losing which response each came from. */ return between(xml, 'response') .map((block) => { const href = decodeEntities(first(block, 'href') ?? '').trim(); const data = decodeEntities(first(block, dataTag) ?? '').trim(); return { href, data }; }) .filter((o) => o.href && o.data); } export async function listEvents(fetcher: Fetcher, path: string): Promise { const objects = await readObjects(fetcher, path, 'calendar'); // One .ics can hold several VEVENTs (a recurring series with overrides), and they share its href — // which is correct: deleting any of them deletes the file they live in. return objects.flatMap((o) => parseEvents(o.data).map((e) => ({ ...e, href: o.href }))); } export async function listContacts(fetcher: Fetcher, path: string): Promise { const objects = await readObjects(fetcher, path, 'addressbook'); return objects.flatMap((o) => parseContacts(o.data).map((c) => ({ ...c, href: o.href }))); } /** * Create one event, in its own file. * * THE FILENAME IS A FRESH UUID, and it is also the UID. Not because a client may assume they match — * nothing here does, and the listing carries the real href precisely because they often do not — but * because a new name has to come from somewhere, and one that cannot collide is the cheapest kind. * * `If-None-Match: *` makes the PUT conditional on the file NOT existing, so a retried request cannot * quietly overwrite an event created by the first attempt. */ export async function createEvent(fetcher: Fetcher, collection: string, event: NewEvent): Promise<{ href: string }> { const uid = crypto.randomUUID(); const href = `${collection.replace(/\/$/, '')}/${uid}.ics`; const res = await fetcher(href, { method: 'PUT', headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'If-None-Match': '*' }, body: buildEvent(uid, event), }); if (!res.ok) throw new Error(`radicale answered ${res.status}`); return { href }; } /** The same contract as `createEvent`, for a card. */ export async function createContact( fetcher: Fetcher, collection: string, contact: NewContact, ): Promise<{ href: string }> { const uid = crypto.randomUUID(); const href = `${collection.replace(/\/$/, '')}/${uid}.vcf`; const res = await fetcher(href, { method: 'PUT', headers: { 'Content-Type': 'text/vcard; charset=utf-8', 'If-None-Match': '*' }, body: buildContact(uid, contact), }); if (!res.ok) throw new Error(`radicale answered ${res.status}`); return { href }; }