import { parseContacts, parseEvents, type CalendarEvent, type Contact } 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. */ path: 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, // 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 { 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(); return between(xml, dataTag) .map((chunk) => decodeEntities(chunk).trim()) .filter(Boolean); } export async function listEvents(fetcher: Fetcher, path: string): Promise { const blobs = await readObjects(fetcher, path, 'calendar'); return blobs.flatMap(parseEvents); } export async function listContacts(fetcher: Fetcher, path: string): Promise { const blobs = await readObjects(fetcher, path, 'addressbook'); return blobs.flatMap(parseContacts); }