Files
pastilhasandClaude Opus 5 43cb531dc2 one CRUD, three callers — the browser and the agent stop writing iCalendar
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
2026-08-27 10:51:22 +00:00

346 lines
13 KiB
TypeScript

// 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.
//
// Reading is display-only: the authoritative copy of an event is the .ics on disk that DAV clients read
// and write, and an edit patches THAT text rather than rebuilding it from what was parsed here.
//
// `buildEvent` is the one exception, and only because a NEW event has no original to preserve. It is
// deliberately the minimum a VEVENT can legally be — no RRULE, no alarms, no attendees. Officer's screens
// cannot express those, and writing a field the UI cannot show is how an editor starts lying.
/** 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;
/** Where this event LIVES — the DAV href. Absent when parsed outside a listing. Needed to edit or delete it. */
href?: 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;
/** Where this contact LIVES — the DAV href. Absent when parsed outside a listing. Needed to edit or delete it. */
href?: 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;
}
// ─── Writing ────────────────────────────────────────────────────────────────────────────────────────
export const escapeText = (value: string) =>
value
.replace(/\\/g, '\\\\')
.replace(/\r?\n/g, '\\n')
.replace(/([,;])/g, '\\$1');
/**
* RFC 5545 §3.1: no content line over 75 OCTETS, continued by a line starting with one space.
*
* Octets, not characters — so this measures encoded length and breaks between code points. A pasted
* title with an accent in it is exactly where a naive `slice(0, 75)` splits a multi-byte character in
* half and produces a file some clients refuse and others render as mojibake.
*/
export function fold(line: string): string {
const encoder = new TextEncoder();
const out: string[] = [];
let current = '';
let bytes = 0;
for (const ch of line) {
const size = encoder.encode(ch).length;
if (bytes + size > 75) {
out.push(current);
current = ' ';
bytes = 1;
}
current += ch;
bytes += size;
}
out.push(current);
return out.join('\r\n');
}
export const pad = (n: number) => String(n).padStart(2, '0');
/** `20260827T140000Z`. Always UTC, so the file carries no timezone this codebase would have to resolve. */
export const utcStamp = (date: Date) =>
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T` +
`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
/** `2026-08-27` → `20260827`. */
export const dateStamp = (iso: string) => iso.slice(0, 10).replace(/-/g, '');
export const addDay = (iso: string) => {
const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
return d.toISOString().slice(0, 10);
};
export type NewEvent = {
summary: string;
location?: string;
/** An ISO instant for a timed event; `YYYY-MM-DD` when `allDay`. */
start: string;
/** Same shape as `start`. For an all-day event this is the LAST day, inclusive, as a person means it. */
end?: string;
allDay: boolean;
};
/**
* A minimal VEVENT, wrapped in its own VCALENDAR — one event per file, which is what every DAV client
* writes and what makes an href addressable.
*
* TIMES ARE WRITTEN IN UTC. The alternative is `DTSTART;TZID=Europe/Lisbon` plus a VTIMEZONE block
* defining that zone's rules, and getting a VTIMEZONE subtly wrong moves an event by an hour twice a
* year. UTC needs no definition and every client renders it in local time.
*
* ALL-DAY IS DIFFERENT, and its trap is DTEND. RFC 5545 makes the end date EXCLUSIVE, so a one-day
* event on the 27th ends on the 28th. Writing the 27th produces a zero-length event that some clients
* hide entirely; callers pass the last day they mean and this adds the day.
*/
export function buildEvent(uid: string, event: NewEvent): string {
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Officer//caldav//EN', 'BEGIN:VEVENT', `UID:${uid}`];
lines.push(`DTSTAMP:${utcStamp(new Date())}`);
if (event.allDay) {
const last = event.end?.slice(0, 10) || event.start.slice(0, 10);
lines.push(`DTSTART;VALUE=DATE:${dateStamp(event.start)}`);
lines.push(`DTEND;VALUE=DATE:${dateStamp(addDay(last))}`);
} else {
const start = new Date(event.start);
// An hour is the default a calendar has taught everyone to expect from an event with no end.
const end = event.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000);
lines.push(`DTSTART:${utcStamp(start)}`);
lines.push(`DTEND:${utcStamp(end)}`);
}
lines.push(`SUMMARY:${escapeText(event.summary)}`);
if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`);
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.map(fold).join('\r\n') + '\r\n';
}
/** The inverse of `addDay`. Turns a file's EXCLUSIVE all-day DTEND back into the last day a person means. */
export const subDay = (iso: string) => {
const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
};
export type NewContact = {
fullName: string;
emails?: string[];
phones?: string[];
org?: string;
title?: string;
};
/**
* A minimal vCard 3.0 — the version CardDAV clients, iOS included, actually write.
*
* `N` is REQUIRED in 3.0 and is `Family;Given;Middle;Prefix;Suffix`. Nobody supplies it, so it is split
* out of the full name: last word is the family name, everything before it is given. That is wrong for
* some names in the world and right for most, and `FN` — which is what a phone displays — is whatever
* was actually typed either way.
*/
export function buildContact(uid: string, contact: NewContact): string {
const [family, ...given] = contact.fullName.trim().split(/\s+/).reverse();
const lines = [
'BEGIN:VCARD',
'VERSION:3.0',
`UID:${uid}`,
`FN:${escapeText(contact.fullName)}`,
`N:${escapeText(family ?? '')};${escapeText(given.reverse().join(' '))};;;`,
];
for (const email of contact.emails ?? []) lines.push(`EMAIL;TYPE=INTERNET:${escapeText(email)}`);
for (const phone of contact.phones ?? []) lines.push(`TEL;TYPE=CELL:${escapeText(phone)}`);
if (contact.org) lines.push(`ORG:${escapeText(contact.org)}`);
if (contact.title) lines.push(`TITLE:${escapeText(contact.title)}`);
lines.push('END:VCARD');
return lines.map(fold).join('\r\n') + '\r\n';
}