caldav: json door for the web ui

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>
This commit is contained in:
2026-08-04 03:30:28 +00:00
co-authored by Claude Opus 5
parent bc52ce6361
commit 8cf210eae5
3 changed files with 370 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
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<Response>;
// 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]*?)</(?:[a-zA-Z0-9]+:)?${tag}>`, '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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
const PROPFIND_COLLECTIONS = `<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:IC="http://apple.com/ns/ical/">
<D:prop><D:resourcetype/><D:displayname/><IC:calendar-color/></D:prop>
</D:propfind>`;
/** Every calendar and address book belonging to this user. */
export async function listCollections(fetcher: Fetcher, userId: string): Promise<Collection[]> {
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 `<C:calendar-home-set/>`, 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<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' ? '<X:filter><X:comp-filter name="VCALENDAR"/></X:filter>' : '<X:filter/>';
const res = await fetcher(path, {
method: 'REPORT',
headers: { Depth: '1', 'Content-Type': 'application/xml' },
body: `<?xml version="1.0"?><X:${queryTag} xmlns:D="DAV:" xmlns:X="${ns}"><D:prop><X:${dataTag}/></D:prop>${filter}</X:${queryTag}>`,
});
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<CalendarEvent[]> {
const blobs = await readObjects(fetcher, path, 'calendar');
return blobs.flatMap(parseEvents);
}
export async function listContacts(fetcher: Fetcher, path: string): Promise<Contact[]> {
const blobs = await readObjects(fetcher, path, 'addressbook');
return blobs.flatMap(parseContacts);
}
+202
View File
@@ -0,0 +1,202 @@
// 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;
}
+38
View File
@@ -1,6 +1,7 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { startRadicale, davPaths } from './radicale';
import { listCollections, listEvents, listContacts } from './collections';
// The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the
// collection storage under DATA_PATH/dav, and exposes two very different doors.
@@ -153,6 +154,43 @@ const server = Bun.serve({
return forwardToRadicale(req, subpath, String(userId));
}
// The JSON door. Same data, no XML: a month view built out of multistatus responses would make the
// web UI as fragile as the protocol.
if (url.pathname.startsWith('/_officer/')) {
// Radicale generates hrefs prefixed with /dav (X-Script-Name), and the UI hands those back
// verbatim as collection ids — so strip the prefix again on the way in.
const fetcher = (path: string, init: RequestInit) =>
fetch(`${radicale.url}${path.replace(/^\/dav/, '')}`, {
...init,
headers: {
...(init.headers as Record<string, string>),
'X-Remote-User': String(userId),
// Same prefix the phone-facing door sends, so the collection ids the UI receives here are
// the same strings a DAV client would see. Without it the UI gets /1/work/ and the phone
// gets /dav/1/work/ for the same collection, and nothing downstream can match them up.
'X-Script-Name': DAV_MOUNT,
},
});
if (url.pathname === '/_officer/collections') {
return Response.json({ collections: await listCollections(fetcher, String(userId)) });
}
const path = url.searchParams.get('collection');
// Confine reads to this user's own tree. The header already scopes Radicale, but a path from a
// query string is attacker-shaped input and should not be handed on unchecked.
if (!path || !path.startsWith(`/dav/${userId}/`)) {
return Response.json({ error: 'collection is required' }, { status: 400 });
}
if (url.pathname === '/_officer/events') {
return Response.json({ events: await listEvents(fetcher, path) });
}
if (url.pathname === '/_officer/contacts') {
return Response.json({ contacts: await listContacts(fetcher, path) });
}
}
return new Response('not found', { status: 404 });
},
});