the skill told an agent how to hand-write a .ics: escape the commas, fold at 75 octets, CRLF, DTSTAMP is required, and DTEND is the day after. all true, all silent when wrong, and the browser had learned the same lesson separately in its own copy of the code. this makes the sidecar the only thing that knows the format, and gives everyone else fields. WHAT WAS MISSING BEFORE THE SKILL COULD CHANGE. reading was already JSON, and create-event landed this morning. this adds PATCH /_officer/item — change some fields of an event or a card — and POST /_officer/contact. delete already worked. with those the skill has no reason left to mention iCalendar, so it does not. PATCH IS READ-PATCH-PUT, SERVER SIDE. it reads the stored text, replaces only the lines it owns, and puts the whole thing back, so alarms, attendees, RRULEs and every X- property a phone invented survive an edit made from a form that cannot show them. that logic came out of the browser, where it had needed a duplicate of the escaping and folding rules to live; the agent would have been a third copy in bash. the web bundle now contains no iCalendar literals at all. AND LIVE TESTING FOUND THE BUG THAT MATTERS. an event with a TZID is stored with a VTIMEZONE block beside it, and that block has DTSTART lines of its own — the rule for when summer time starts. taking the first DTSTART in the file rewrote the TIMEZONE DEFINITION, left the event where it was, and answered ok. the calendar showed the old time and the file was quietly corrupt. every write is now confined to the first VEVENT or VCARD, and the file that caught it is a test. three more traps handled rather than documented: a patch that reuses an all-day event's stored DTEND must subtract the exclusive day first or the holiday drifts every time the title is edited; moving a timed event must DROP a stale TZID rather than preserve it, because a TZID and a Z together are a contradiction clients resolve by moving the event; and a move bumps SEQUENCE, which is how a phone tells an update from a duplicate. contacts got the create form the calendar had, so both screens now do all four. editing a card sends the other emails and phones back untouched — the form shows the first of each, and sending only that would delete the rest. 47 tests across the writer and the patcher. verified live against a real event carrying an alarm, an attendee, an RRULE and an X- property: all four intact after a rename, a relocation and a move to a different day. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
210 lines
9.1 KiB
TypeScript
210 lines
9.1 KiB
TypeScript
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<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,
|
|
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' ? '<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();
|
|
|
|
/*
|
|
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 `<uid>.ics` works for everything Officer
|
|
wrote and fails for everything a phone did, which is the worst kind of nearly-right.
|
|
|
|
So each `<response>` 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<CalendarEvent[]> {
|
|
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<Contact[]> {
|
|
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 };
|
|
}
|