one sidecar, one permission, one install — and two screens, because Calendar and Contacts are two places you go rather than two sections of one page. the first plugin to declare `screens`, which the platform gained for this. what moved: the radicale sidecar, the four routers, the panel apps, the app-password table and the settings form. the form became a modal on the calendar screen — a plugin ships panels and cannot contribute a settings section, which is the same wall the email plugin hit and the same answer. the routes are /calendar and /contacts, not /caldav/calendar. they were core routes and keeping them is not cosmetic: a bookmark, a dock tile someone arranged, and a persisted layout all address them. only a first-party plugin may choose its own segment; a third-party one is confined to /p/<publisher>/. four paths outside /api come with it, above the account gate, because phones are not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and have nowhere to put a JWT. that is not "open" — the sync door demands a scoped app password and resolves it to one user before radicale sees anything. two things deliberately unchanged. the table is still dav_app_passwords, not caldav_app_passwords: installs hold real rows and a naming convention is not worth a data migration on a live machine. and the panel names still resolve through the platform's catalogs, so the six locales they had as core screens survive rather than dropping to english to make the refactor tidier. per-user was already true and is worth writing down because the config said otherwise: the sync door resolves an app password to an account, tells radicale that id is the principal, and owner_only does the rest. the comment claiming "the owner is the only principal that exists" had been stale since multi-user landed on 2026-08-07. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
131 lines
6.0 KiB
TypeScript
131 lines
6.0 KiB
TypeScript
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(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/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);
|
|
}
|