Author SHA1 Message Date
pastilhasandClaude Opus 5 76b4454cae 2026.08.27.02 — create, edit and delete, and a skill that speaks JSON
everything since .01: edit and delete on both screens, create an event and a
contact, PATCH one item's fields, ?collection=<name> instead of an escaped dav
path, and the agent skill rewritten off hand-written iCalendar onto the routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:28:43 +00:00
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
pastilhasandClaude Opus 5 25c8502c4b create an event, newest day first, and a URL you can read
three small things, one of which turned out to be a bug in what shipped an hour
ago.

CREATE. a POST to /_officer/event, and the SIDECAR builds the file — the browser
sends title, when and where. a UID, a DTSTAMP and a UTC timestamp each have
exactly one correct form, and that belongs next to the parser that reads them
back, not in a react component. it writes the minimum a VEVENT can legally be:
no recurrence, no alarms, no attendees. this app cannot show those, and a field
the list cannot display is a field that silently disappears on the next edit.

times go in as UTC. the alternative is DTSTART;TZID plus a VTIMEZONE block
defining that zone's rules, and a subtly wrong VTIMEZONE moves an event by an
hour twice a year. UTC needs no definition and every client renders it local.
all-day is the other trap: RFC 5545 makes DTEND EXCLUSIVE, so a one-day event on
the 29th ends on the 30th — write the 29th and some clients draw nothing at all.
the form takes the last day a person means and the builder adds the day.

NEWEST DAY ON TOP. a calendar accumulates years, so ascending opened on whatever
was imported first and this week was a scroll away. within a day the order stays
chronological, because that is what a day is.

?collection=ra-guide, NOT ?collection=%2Fdav%2F1%2Fra-guide%2F. the path is
/dav/<userId>/<name>/, so the escaped form spent six characters on slashes to
say `ra-guide` and put the owner's user id in a URL people paste at each other.
the name is unique within a user, which is all a selector needs, and the full
path is recovered from the collections list the screen already loaded. a full
path in the param still resolves, because somebody's bookmark has one.

AND THE BUG. setProperty replaced a property's first line and left its
continuation lines behind. a value over 75 octets is FOLDED onto lines starting
with a space — so editing a long summary left the old tail folded onto the new
value, and each edit inherited the last one's ending. it only bites on long
values, which is why the first round of testing missed it. the fix consumes the
whole folded run and folds what it writes; both are now under test, including
the accent-near-the-boundary case that a naive slice(0,75) cuts in half.

verified live: both event shapes written and read back, escaping round-tripped,
and a long summary edited twice with no accumulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 10:40:10 +00:00
pastilhasandClaude Opus 5 785e5f9eed edit and delete, on both screens
the screens could show a calendar and an address book and change neither. this
adds a pencil and a bin to every event and every card, with a confirm on the bin.

three things worth defending.

THE HREF IS THE ADDRESS, and the listing now carries it. it used to return only
the object's data, which is enough to show something and not enough to change
it. deriving `<uid>.ics` was the obvious shortcut and is wrong: a .ics filename
only conventionally matches the uid — radicale stores what the writing client
chose — so it works for everything officer wrote and misses everything a phone
did. proved immediately: this machine's own calendar holds
62970EC7-47B1-4214-858E-85C32C2476A5.ics, written by an iphone.

READ, PATCH, PUT — NEVER REBUILD. an event is a file with far more in it than
these screens show: alarms, attendees, RRULEs, every X- property. rebuilding it
from the fields on screen would silently drop all of that, and an edit that
quietly deletes half an event is worse than no editing. so a save reads the
original text, replaces only the lines it owns, and puts the whole file back.
verified on a real event: 22 lines before, 22 after, one changed.

THE EDITOR IS DELIBERATELY SMALL. title and place for an event, name/email/phone
for a card. times and recurrence are where an editor does damage, and a
half-editor that rewrites an RRULE it does not understand is the thing this
avoids. `setProperty` also escapes commas, semicolons and newlines, because a
raw comma silently splits an iCalendar TEXT value into parts.

the sidecar grew GET/PUT/DELETE on /_officer/item, above the `collection is
required` guard — they take an href rather than a collection, and sitting below
it they answered 400 forever. both check the href is inside this user's own tree
even though X-Remote-User already scopes radicale: an href from a request body
is attacker-shaped input either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 10:25:38 +00:00
pastilhasandClaude Opus 5 0faf9183ad sign with -binary, or iOS refuses the profile it just verified
every signed profile was rejected with "Invalid Profile". `openssl smime -verify`
reported success the whole time, which is exactly why it took so long to see:
the signature was valid over the WRONG BYTES.

without `-binary`, openssl treats the input as TEXT — it canonicalises line
endings and adds MIME headers before signing — so what iOS unwraps is no longer
the plist that was written.

this was already found and written down in officer-oas/enroll.ts on 2026-08-20,
four wrong endings into a hunt that was never about the ending. this file was
written without reading that one, and repeated it exactly.

also reads OFFICERDEV_DAV_PROFILE_SIGN_* as well as the bare names, which is the
convention every other consumer follows. on a converted install the signing
certificate could be configured correctly and silently ignored, and the only
symptom is a profile that says "Not Signed" for no visible reason.

verified on a real iPhone (iOS 26.6): the profile installs, and the signed inner
payload starts with <?xml with no MIME headers and no CRLF mangling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 09:49:55 +00:00
pastilhasandClaude Opus 5 bfb12c2dde drop the jsonBody workaround — the platform parses these bodies now
this plugin read ctx.req.json() directly because registerProxiedPrefix claimed
the whole of /api/caldav, so bodyParser skipped the plugin's OWN routes and every
field read as missing.

the platform's proxy takes a proxiedSubPath now, so this claims only
/api/caldav/_officer — what the sidecar actually answers — and /passwords and
/provision/ios get their bodies the ordinary way.

verified end to end: POST /api/caldav/passwords with a label came back with the
label, and _officer/collections still reaches the sidecar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 08:48:53 +00:00
17 changed files with 1725 additions and 180 deletions
+2 -32
View File
@@ -1,4 +1,3 @@
import type { Context } from 'hono';
import { createRouter } from '@@/create-router';
import {
listDavAppPasswords,
@@ -19,35 +18,6 @@ import { stashIosProfile } from './ios-profile';
export const appPasswordsRouter = createRouter();
/**
* Read this request's JSON body.
*
* `ctx.get('body')` is the platform's parsed body, and it is EMPTY here — deliberately, and for a reason
* that only became true when caldav became a plugin.
*
* `createSidecarProxy` registers `/api/caldav` with `registerProxiedPrefix`, which tells `bodyParser` to
* leave those request streams untouched. That is right for forwarded traffic and load-bearing for
* multipart uploads: a body parsed here is re-serialised with a new boundary while the old content-type
* header is still forwarded, and every upload fails. But the prefix covers this plugin's OWN routes too,
* so they get `{}` and every field reads as missing.
*
* Before the extraction these routes lived under `/api/dav`, a second top-level prefix that was not
* proxied, so their bodies parsed. One prefix per plugin is the rule that ended that arrangement.
*
* Read here rather than fixed in the platform because the platform's rule is correct — the narrower fix
* is for the proxy to register only the sub-path it actually serves (`_officer/*`), which is a change to
* shared machinery every sidecar depends on and not one to make while a phone is waiting.
*/
async function jsonBody<T>(ctx: Context): Promise<T> {
try {
return (await ctx.req.json()) as T;
} catch {
// No body, or not JSON. Every caller below treats a missing field as a bad request anyway.
return {} as T;
}
}
appPasswordsRouter.get('/passwords', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
@@ -55,7 +25,7 @@ appPasswordsRouter.get('/passwords', async (ctx) => {
appPasswordsRouter.post('/passwords', async (ctx) => {
const user = ctx.get('user');
const body = await jsonBody<{ label?: string }>(ctx);
const body = (ctx.get('body') ?? {}) as { label?: string };
const label = body?.label?.trim();
if (!label) throw errors.BAD_REQUEST('label is required');
@@ -72,7 +42,7 @@ appPasswordsRouter.post('/passwords', async (ctx) => {
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
appPasswordsRouter.post('/provision/ios', async (ctx) => {
const user = ctx.get('user');
const body = await jsonBody<{ deviceLabel?: string; only?: 'caldav' | 'carddav' }>(ctx);
const body = (ctx.get('body') ?? {}) as { deviceLabel?: string; only?: 'caldav' | 'carddav' };
const deviceLabel = body?.deviceLabel?.trim();
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
+81 -6
View File
@@ -136,11 +136,21 @@ ${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Con
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
// old one is removed. Nothing detects that for you.
/**
* `OFFICERDEV_X ?? X`, which is the convention every other consumer follows.
*
* This read the bare names only. `.env` on a converted install prefixes everything — see
* `servers/env-prefix.ts` — so on those machines the signing certificate could be configured correctly
* and silently ignored, and the only symptom is a profile that says "Not Signed" for no visible reason.
* The unprefixed name stays as the fallback, never the source.
*/
const env = (name: string) => process.env[`OFFICERDEV_${name}`] ?? process.env[name];
const signingConfig = () => {
const cert = process.env.DAV_PROFILE_SIGN_CERT;
const key = process.env.DAV_PROFILE_SIGN_KEY;
const cert = env('DAV_PROFILE_SIGN_CERT');
const key = env('DAV_PROFILE_SIGN_KEY');
if (!cert || !key) return null;
return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
return { cert, key, chain: env('DAV_PROFILE_SIGN_CHAIN') };
};
let warnedUnsigned = false;
@@ -161,7 +171,30 @@ async function sign(plist: string): Promise<Uint8Array | null> {
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
// the file is a signature with no profile embedded in it.
const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
/*
`-binary` IS NOT OPTIONAL, and its absence is why every signed profile was refused.
Without it `openssl smime -sign` treats the input as TEXT: it canonicalises line endings and adds MIME
headers before signing. The signature then verifies perfectly — `openssl smime -verify` reports
"Verification successful" — while the bytes iOS unwraps are no longer the plist that was written. The
device shows the profile, then refuses it with "Invalid Profile".
Apple's own sample signs with `OpenSSL::PKCS7::BINARY` for the same reason. This was found and written
down in `officer-oas/enroll.ts` on 2026-08-20, four wrong endings into a hunt that was never about the
ending — and then repeated here on 2026-08-27 because this file was written without reading that one.
*/
const args = [
'smime',
'-sign',
'-binary',
'-signer',
config.cert,
'-inkey',
config.key,
'-outform',
'der',
'-nodetach',
];
if (config.chain) args.push('-certfile', config.chain);
args.push('-md', 'sha256');
@@ -185,7 +218,42 @@ async function sign(plist: string): Promise<Uint8Array | null> {
return new Uint8Array(out);
}
export type StoredProfile = { url: string; expiresAt: string; signed: boolean };
export type StoredProfile = {
url: string;
/** The same profile at a URL a person can TYPE. See `shortCode`. */
shortUrl: string;
code: string;
expiresAt: string;
signed: boolean;
};
/*
A code short enough to type into Safari's address bar.
── Why a long URL is not good enough ──
The 43-character token cannot be typed, so it has to be tapped or pasted — and on a phone neither
works. Officer runs from the Home Screen in a WEBVIEW, and a webview cannot hand a `.mobileconfig` to
Settings: the download has nowhere to go and the screen does nothing. Every link the chat renders also
gets `target="_blank"`, and a popup tab breaks the profile hand-off in Safari too.
So the only path that reliably reaches iOS is a person typing a URL into real Safari, and that means it
has to be short.
── Why this is not a weaker credential ──
Crockford's alphabet minus the ambiguous letters, 8 characters: ~10^12 combinations. It is SINGLE USE
and lives ten minutes, so an attacker gets one guess per attempt against a target that disappears when
it is claimed or when the clock runs out. The long token still exists and still works; this is a second
door to the same one-shot entry, not a replacement for it.
*/
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789';
const CODE_TTL_MS = 10 * 60_000;
const shortCode = (): string => {
const bytes = randomBytes(8);
return Array.from(bytes, (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
};
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
@@ -200,8 +268,15 @@ export async function stashIosProfile(params: ProfileParams, publicUrl: string):
const expiresAt = Date.now() + TTL_MS;
pending.set(token, { body, expiresAt });
// The same profile behind a typeable code. Shorter-lived on purpose — see `shortCode`.
const code = shortCode();
pending.set(code, { body, expiresAt: Date.now() + CODE_TTL_MS });
const base = publicUrl.replace(/\/$/, '');
return {
url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
url: `${base}/dav/provision/${token}.mobileconfig`,
shortUrl: `${base}/dav/p/${code}`,
code,
expiresAt: new Date(expiresAt).toISOString(),
signed: signed !== null,
};
+6 -1
View File
@@ -11,7 +11,12 @@ import { createSidecarProxy } from '@@/sidecar/create-proxy';
//
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' });
// `proxiedSubPath`: this sidecar answers `_officer/*` and nothing else, so the proxy claims only that.
//
// Claiming the whole prefix is what stopped this plugin's OWN routes from seeing a request body —
// `registerProxiedPrefix` tells bodyParser to leave the prefix alone, which is right for forwarded
// traffic and wrong for `/passwords` and `/provision/ios`, which are served here.
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav', proxiedSubPath: '/_officer' });
export const caldavRouter = proxy.router;
export const getCaldavUrl = proxy.getHttpUrl;
+1 -1
View File
@@ -9,7 +9,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '2026.08.27.01',
version: '2026.08.27.02',
platform: '>=1.0.0',
/**
+89 -10
View File
@@ -1,4 +1,13 @@
import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './ical';
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.
//
@@ -11,8 +20,14 @@ import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './
// 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. */
/** 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. */
@@ -77,6 +92,7 @@ export async function listCollections(fetcher: Fetcher, userId: string): Promise
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.
@@ -99,7 +115,11 @@ export async function listCollections(fetcher: Fetcher, userId: string): Promise
* 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[]> {
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';
@@ -114,17 +134,76 @@ async function readObjects(fetcher: Fetcher, path: string, kind: 'calendar' | 'a
});
if (!res.ok && res.status !== 207) return [];
const xml = await res.text();
return between(xml, dataTag)
.map((chunk) => decodeEntities(chunk).trim())
.filter(Boolean);
/*
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 blobs = await readObjects(fetcher, path, 'calendar');
return blobs.flatMap(parseEvents);
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 blobs = await readObjects(fetcher, path, 'addressbook');
return blobs.flatMap(parseContacts);
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 };
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test';
import { buildEvent, fold, parseEvents } from './ical';
// The two rules here are the ones that are invisible when broken: DTEND is exclusive for an all-day
// event, and a content line is folded at 75 OCTETS. Both produce a file that looks fine and renders
// wrong somewhere else.
describe('buildEvent', () => {
test('a timed event is written in UTC, with both ends', () => {
const ics = buildEvent('u1', {
summary: 'Lunch',
start: '2026-08-28T13:00:00.000Z',
end: '2026-08-28T14:00:00.000Z',
allDay: false,
});
expect(ics).toContain('DTSTART:20260828T130000Z');
expect(ics).toContain('DTEND:20260828T140000Z');
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
});
test('no end means an hour', () => {
const ics = buildEvent('u1', { summary: 'Lunch', start: '2026-08-28T13:00:00.000Z', allDay: false });
expect(ics).toContain('DTEND:20260828T140000Z');
});
test('an all-day event ends the NEXT day, because DTEND is exclusive', () => {
const ics = buildEvent('u1', { summary: 'Holiday', start: '2026-08-29', allDay: true });
expect(ics).toContain('DTSTART;VALUE=DATE:20260829');
expect(ics).toContain('DTEND;VALUE=DATE:20260830');
});
test('a multi-day all-day event takes the last day inclusive', () => {
const ics = buildEvent('u1', { summary: 'Trip', start: '2026-08-29', end: '2026-08-31', allDay: true });
expect(ics).toContain('DTEND;VALUE=DATE:20260901');
});
test('a month boundary rolls the month, not just the day', () => {
const ics = buildEvent('u1', { summary: 'Last of the month', start: '2026-08-31', allDay: true });
expect(ics).toContain('DTEND;VALUE=DATE:20260901');
});
test('TEXT separators are escaped, and survive the parser', () => {
const summary = 'Comma, semicolon; and a backslash \\ walk in';
const ics = buildEvent('u1', { summary, start: '2026-08-28T13:00:00.000Z', allDay: false });
expect(ics).toContain(String.raw`SUMMARY:Comma\, semicolon\; and a backslash \\ walk in`);
expect(parseEvents(ics)[0]!.summary).toBe(summary);
});
test('a long summary folds, and the parser reads it back whole', () => {
const summary = 'x'.repeat(200);
const ics = buildEvent('u1', { summary, start: '2026-08-28T13:00:00.000Z', allDay: false });
expect(ics.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
expect(parseEvents(ics)[0]!.summary).toBe(summary);
});
});
describe('fold', () => {
test('a short line is left alone', () => {
expect(fold('SUMMARY:short')).toBe('SUMMARY:short');
});
test('every produced line fits in 75 octets, continuations included', () => {
const folded = fold(`SUMMARY:${'a'.repeat(400)}`);
expect(folded.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
});
test('continuation lines start with exactly one space', () => {
const [, ...rest] = fold(`SUMMARY:${'a'.repeat(200)}`).split('\r\n');
expect(rest.every((l) => l.startsWith(' ') && !l.startsWith(' a'))).toBe(true);
});
test('a multi-byte character is never cut in half', () => {
// 'é' is two octets, so a boundary lands mid-character unless the fold counts encoded length.
const folded = fold(`SUMMARY:${'é'.repeat(100)}`);
expect(folded).not.toContain('�');
expect(folded.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
expect(folded.split('\r\n').map((l, i) => (i ? l.slice(1) : l.slice(8))).join('')).toBe('é'.repeat(100));
});
});
+145 -2
View File
@@ -6,8 +6,12 @@
// 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.
// 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[] {
@@ -63,6 +67,8 @@ export function parseDate(prop: Prop): { iso: string; allDay: boolean } | null {
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;
@@ -140,6 +146,8 @@ export function parseEvents(ics: string): CalendarEvent[] {
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[];
@@ -200,3 +208,138 @@ export function parseContacts(vcf: string): Contact[] {
}
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';
}
+177 -2
View File
@@ -1,7 +1,8 @@
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { startRadicale, davPaths } from './radicale';
import { listCollections, listEvents, listContacts } from './collections';
import { listCollections, listEvents, listContacts, createEvent, createContact } from './collections';
import { patchFor, summarise } from './patch';
import { DATA_PATH } from '@@/data-path';
import { API_URL } from '@@/officer-url.mjs';
@@ -16,7 +17,10 @@ import { API_URL } from '@@/officer-url.mjs';
// Reached from the platform's TOP-LEVEL /dav mount, which authenticates a DAV
// app password over HTTP Basic. This is the door phones use.
// GET /_health is Radicale up, and does it answer.
// GET /_officer/* the JSON door for Officer's own web UI (see collections.ts).
// * /_officer/* the JSON door. Reads, and field-level writes: POST /_officer/event and
// /_officer/contact create, PATCH /_officer/item changes fields, DELETE removes.
// Built for the web UI; the agent skill speaks it too, which is why the writes
// take FIELDS rather than iCalendar text (see patch.ts).
//
// WHY TWO DOORS. A browser should not speak DAV — rendering a month view out of multistatus XML is a
// lot of machinery, and it would make the web UI as fragile as the protocol. The collections are on
@@ -185,6 +189,161 @@ const server = Bun.serve({
return Response.json({ collections: await listCollections(fetcher, String(userId)) });
}
/*
One item's ORIGINAL text, for an editor that must not lose what it does not understand.
Rebuilding an .ics from the parsed fields would silently drop alarms, attendees, recurrence rules
and every X- property the file came with — an edit that quietly deletes half an event is worse
than no editing at all. So the editor reads the real thing, changes the lines it owns, and puts
the whole file back.
*/
if (url.pathname === '/_officer/item' && req.method === 'GET') {
const href = url.searchParams.get('href') ?? '';
if (!href.startsWith(`/dav/${userId}/`)) {
return Response.json({ error: 'href is required and must be your own' }, { status: 400 });
}
const res = await fetcher(href, { method: 'GET' });
if (!res.ok) return Response.json({ error: `radicale answered ${res.status}` }, { status: 502 });
return Response.json({ href, data: await res.text() });
}
/*
Change some FIELDS of an item, rather than replacing its text.
This is the route anything sane should use. PUT is still here because the raw text is sometimes
genuinely what a caller has, but a caller that only wants to move an event or fix a phone number
should not have to learn iCalendar escaping to do it — and every caller that did learn it learned
it slightly differently. The read, the patch and the write happen here, in one round trip, over
the ORIGINAL text, so nothing the patcher does not recognise is disturbed.
*/
if (url.pathname === '/_officer/item' && req.method === 'PATCH') {
const body = (await req.json().catch(() => ({}))) as { href?: string; [field: string]: unknown };
const href = String(body.href ?? '');
if (!href.startsWith(`/dav/${userId}/`)) {
return Response.json({ error: 'href is required and must be your own' }, { status: 400 });
}
const read = await fetcher(href, { method: 'GET' });
if (!read.ok) return Response.json({ error: `radicale answered ${read.status}` }, { status: 502 });
let patched: string;
try {
const { href: _ignored, ...fields } = body;
patched = patchFor(href, fields, await read.text());
} catch (err) {
return Response.json({ error: String(err) }, { status: 400 });
}
const isCard = href.endsWith('.vcf');
const written = await fetcher(href, {
method: 'PUT',
headers: { 'Content-Type': isCard ? 'text/vcard; charset=utf-8' : 'text/calendar; charset=utf-8' },
body: patched,
});
if (!written.ok) return Response.json({ error: `radicale answered ${written.status}` }, { status: 502 });
// The item as it now reads, so a caller can confirm what it did without a second call.
return Response.json({ ok: true, item: summarise(href, patched) });
}
if (url.pathname === '/_officer/item' && (req.method === 'PUT' || req.method === 'DELETE')) {
const body = (await req.json().catch(() => ({}))) as { href?: string; data?: string };
const href = String(body.href ?? '');
if (!href.startsWith(`/dav/${userId}/`)) {
return Response.json({ error: 'href is required and must be your own' }, { status: 400 });
}
if (req.method === 'DELETE') {
const res = await fetcher(href, { method: 'DELETE' });
// 404 is success from the caller's point of view: the thing they asked to be gone is gone.
if (!res.ok && res.status !== 404) {
return Response.json({ error: `radicale answered ${res.status}` }, { status: 502 });
}
return Response.json({ ok: true });
}
if (!body.data) return Response.json({ error: 'data is required' }, { status: 400 });
const isCard = href.endsWith('.vcf');
const res = await fetcher(href, {
method: 'PUT',
headers: {
'Content-Type': isCard ? 'text/vcard; charset=utf-8' : 'text/calendar; charset=utf-8',
},
body: body.data,
});
if (!res.ok) return Response.json({ error: `radicale answered ${res.status}` }, { status: 502 });
return Response.json({ ok: true, status: res.status });
}
/*
Create an event. POST rather than PUT, because the caller does not choose the address — the
sidecar mints the filename, so this is "add one to that collection", not "put this here".
The BODY carries the collection, which is why this sits above the guard below: that one reads a
collection out of the query string, and a create has none there.
*/
if (url.pathname === '/_officer/event' && req.method === 'POST') {
const body = (await req.json().catch(() => ({}))) as {
collection?: string;
summary?: string;
location?: string;
start?: string;
end?: string;
allDay?: boolean;
};
const collection = String(body.collection ?? '');
if (!collection.startsWith(`/dav/${userId}/`)) {
return Response.json({ error: 'collection is required and must be your own' }, { status: 400 });
}
const summary = (body.summary ?? '').trim();
if (!summary) return Response.json({ error: 'summary is required' }, { status: 400 });
if (!body.start) return Response.json({ error: 'start is required' }, { status: 400 });
try {
const created = await createEvent(fetcher, collection, {
summary,
location: (body.location ?? '').trim() || undefined,
start: body.start,
end: body.end,
allDay: Boolean(body.allDay),
});
return Response.json(created, { status: 201 });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
}
}
/* Same contract as the event route above, for an address book. */
if (url.pathname === '/_officer/contact' && req.method === 'POST') {
const body = (await req.json().catch(() => ({}))) as {
collection?: string;
fullName?: string;
emails?: string[];
phones?: string[];
org?: string;
title?: string;
};
const collection = String(body.collection ?? '');
if (!collection.startsWith(`/dav/${userId}/`)) {
return Response.json({ error: 'collection is required and must be your own' }, { status: 400 });
}
const fullName = (body.fullName ?? '').trim();
// FN is what a phone displays and vCard requires it. A card without one shows as a blank row.
if (!fullName) return Response.json({ error: 'fullName is required' }, { status: 400 });
try {
const created = await createContact(fetcher, collection, {
fullName,
emails: (body.emails ?? []).filter(Boolean),
phones: (body.phones ?? []).filter(Boolean),
org: (body.org ?? '').trim() || undefined,
title: (body.title ?? '').trim() || undefined,
});
return Response.json(created, { status: 201 });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
}
}
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.
@@ -198,6 +357,22 @@ const server = Bun.serve({
if (url.pathname === '/_officer/contacts') {
return Response.json({ contacts: await listContacts(fetcher, path) });
}
/*
On the writing routes above.
── Why the href is required and never derived ──
A `.ics` filename only conventionally matches the UID. Radicale stores whatever the client that
wrote it chose, so `<uid>.ics` finds everything Officer wrote and misses everything a phone did.
The listing hands back the real href; these take it back unchanged.
── Why it is checked against this user's tree ──
`X-Remote-User` already scopes Radicale, and an href arriving in a request body is
attacker-shaped input either way. Two gates rather than one, for the same reason the reads above
check `collection`.
*/
}
return new Response('not found', { status: 404 });
+240
View File
@@ -0,0 +1,240 @@
import { describe, expect, test } from 'bun:test';
import { patchContact, patchEvent, setProperty, setPropertyList } from './patch';
import { parseContacts, parseEvents } from './ical';
// What these protect: an edit must change what was asked and NOTHING else. Every failure mode here is
// silent — the write succeeds, the list still looks right, and something the UI cannot show is gone.
const EVENT = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
'UID:abc',
'DTSTAMP:20260827T100000Z',
'DTSTART;TZID=Europe/Lisbon:20260828T130000',
'DTEND;TZID=Europe/Lisbon:20260828T140000',
'SUMMARY:Before',
'RRULE:FREQ=WEEKLY',
'ATTENDEE;CN=Someone:mailto:someone@example.com',
'X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC',
'BEGIN:VALARM',
'ACTION:DISPLAY',
'TRIGGER:-PT15M',
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const ALL_DAY = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
'UID:def',
'DTSTAMP:20260827T100000Z',
'DTSTART;VALUE=DATE:20260829',
'DTEND;VALUE=DATE:20260830',
'SUMMARY:Holiday',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const CARD = [
'BEGIN:VCARD',
'VERSION:3.0',
'UID:ghi',
'FN:Peter Griffin',
'N:Griffin;Peter;;;',
'EMAIL;TYPE=INTERNET:peter@quahog.example',
'EMAIL;TYPE=WORK:peter@brewery.example',
'TEL;TYPE=CELL:+351912345678',
'X-SOMETHING-ELSE:kept',
'END:VCARD',
].join('\r\n');
describe('setProperty', () => {
test('changes only the line it owns, and keeps what it does not understand', () => {
const out = setProperty(EVENT, 'SUMMARY', 'After');
expect(out).toContain('SUMMARY:After');
for (const kept of ['RRULE:FREQ=WEEKLY', 'ATTENDEE;CN=Someone', 'X-APPLE-TRAVEL', 'BEGIN:VALARM'])
expect(out).toContain(kept);
});
test('preserves parameters by default, and replaces them when told', () => {
expect(setProperty(EVENT, 'DTSTART', '20260901T090000')).toContain('DTSTART;TZID=Europe/Lisbon:20260901T090000');
expect(setProperty(EVENT, 'DTSTART', '20260901T090000Z', '')).toContain('DTSTART:20260901T090000Z');
});
test('appends after UID when absent, so it lands in the right component', () => {
const lines = setProperty(EVENT, 'LOCATION', 'Ramiro').split('\r\n');
expect(lines[lines.indexOf('UID:abc') + 1]).toBe('LOCATION:Ramiro');
});
test('null removes it', () => {
expect(setProperty(EVENT, 'RRULE', null)).not.toContain('RRULE');
});
// The regression that shipped once: replacing only the first line of a FOLDED value left the rest
// behind, and each edit inherited the tail of the last.
test('replaces the whole of a folded value', () => {
const folded = EVENT.replace('SUMMARY:Before', 'SUMMARY:Before and a great deal more text\r\n folded onto line two');
const out = setProperty(folded, 'SUMMARY', 'After');
expect(parseEvents(out)[0]!.summary).toBe('After');
expect(out).not.toContain('folded onto line two');
});
test('a long value is folded to 75 octets and reads back whole', () => {
const long = 'y'.repeat(300);
const out = setProperty(EVENT, 'SUMMARY', long);
expect(out.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
expect(parseEvents(out)[0]!.summary).toBe(long);
});
test('repeated edits do not accumulate', () => {
let out = EVENT;
for (const v of ['one'.repeat(40), 'two'.repeat(40), 'three']) out = setProperty(out, 'SUMMARY', v);
expect(parseEvents(out)[0]!.summary).toBe('three');
});
});
describe('setPropertyList', () => {
test('replaces the whole list and carries parameters over by position', () => {
const out = setPropertyList(CARD, 'EMAIL', ['new@example.com'], ';TYPE=INTERNET');
expect(out).toContain('EMAIL;TYPE=INTERNET:new@example.com');
expect(out).not.toContain('brewery');
expect(parseContacts(out)[0]!.emails).toEqual(['new@example.com']);
});
test('extra values get the default parameters', () => {
const out = setPropertyList(CARD, 'TEL', ['+351911111111', '+351922222222'], ';TYPE=CELL');
expect(out).toContain('TEL;TYPE=CELL:+351911111111');
expect(out).toContain('TEL;TYPE=CELL:+351922222222');
});
test('an empty list removes them all, and leaves the rest of the card', () => {
const out = setPropertyList(CARD, 'EMAIL', [], ';TYPE=INTERNET');
expect(out).not.toContain('EMAIL');
expect(out).toContain('X-SOMETHING-ELSE:kept');
expect(out).toContain('TEL;TYPE=CELL:+351912345678');
});
});
describe('patchEvent', () => {
test('a text-only patch does not touch the times', () => {
const out = patchEvent(EVENT, { summary: 'After' });
expect(out).toContain('DTSTART;TZID=Europe/Lisbon:20260828T130000');
expect(out).not.toContain('SEQUENCE');
});
test('moving a timed event writes UTC and DROPS the stale TZID', () => {
const out = patchEvent(EVENT, { start: '2026-09-01T08:00:00Z', end: '2026-09-01T09:00:00Z' });
expect(out).toContain('DTSTART:20260901T080000Z');
expect(out).toContain('DTEND:20260901T090000Z');
expect(out).not.toContain('TZID');
});
test('moving bumps SEQUENCE, so a phone reads an update rather than a duplicate', () => {
const once = patchEvent(EVENT, { start: '2026-09-01T08:00:00Z' });
expect(once).toContain('SEQUENCE:1');
expect(patchEvent(once, { start: '2026-09-02T08:00:00Z' })).toContain('SEQUENCE:2');
});
test('changing only the end leaves the start exactly as it was', () => {
const out = patchEvent(ALL_DAY, { end: '2026-08-31' });
expect(out).toContain('DTSTART;VALUE=DATE:20260829');
expect(out).toContain('DTEND;VALUE=DATE:20260901'); // inclusive in, exclusive out
});
// The one that would silently move a holiday: the file's DTEND is exclusive, so editing anything else
// about the event must not re-add a day to it.
test('editing an all-day event does not drift its end', () => {
const out = patchEvent(patchEvent(ALL_DAY, { summary: 'A' }), { start: '2026-08-29' });
expect(out).toContain('DTEND;VALUE=DATE:20260830');
});
test('timed becomes all-day, both ends rewritten', () => {
const out = patchEvent(EVENT, { allDay: true, start: '2026-08-28' });
expect(out).toContain('DTSTART;VALUE=DATE:20260828');
expect(out).toContain('DTEND;VALUE=DATE:20260829');
expect(parseEvents(out)[0]!.allDay).toBe(true);
});
test('null removes a field, an empty string does not', () => {
const withPlace = patchEvent(EVENT, { location: 'Ramiro' });
expect(patchEvent(withPlace, { location: null })).not.toContain('LOCATION');
expect(patchEvent(withPlace, { location: '' })).toContain('LOCATION:');
});
test('separators are escaped and survive the round trip', () => {
const out = patchEvent(EVENT, { summary: 'a, b; c' });
expect(parseEvents(out)[0]!.summary).toBe('a, b; c');
});
});
describe('patchContact', () => {
test('renaming updates N as well as FN', () => {
const out = patchContact(CARD, { fullName: 'Lois Griffin' });
expect(out).toContain('FN:Lois Griffin');
expect(out).toContain('N:Griffin;Lois;;;');
});
test('a patch that names no list leaves both lists alone', () => {
const out = patchContact(CARD, { org: 'Pawtucket' });
expect(parseContacts(out)[0]!.emails).toEqual(['peter@quahog.example', 'peter@brewery.example']);
expect(out).toContain('ORG:Pawtucket');
});
test('everything unknown survives', () => {
expect(patchContact(CARD, { fullName: 'Peter L. Griffin' })).toContain('X-SOMETHING-ELSE:kept');
});
test('REV moves, because some clients break a tie with it', () => {
expect(patchContact(CARD, { title: 'Safety Inspector' })).toContain('REV:');
});
});
// The bug that live testing caught, and the reason `within` exists. Radicale stores a VTIMEZONE beside
// any event carrying a TZID, and that block has DTSTART lines of its own — the summer-time rule. Taking
// the first DTSTART in the file rewrote the timezone and left the event where it was.
const WITH_VTIMEZONE = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VTIMEZONE',
'TZID:Europe/Lisbon',
'BEGIN:DAYLIGHT',
'DTSTART:20000326T010000',
'TZOFFSETFROM:+0000',
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3',
'END:DAYLIGHT',
'END:VTIMEZONE',
'BEGIN:VEVENT',
'UID:jkl',
'DTSTAMP:20260827T100000Z',
'DTSTART;TZID=Europe/Lisbon:20260828T130000',
'DTEND;TZID=Europe/Lisbon:20260828T140000',
'SUMMARY:Probe',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
describe('a file with a VTIMEZONE', () => {
test('moving the event does not touch the timezone rule', () => {
const out = patchEvent(WITH_VTIMEZONE, { start: '2026-09-01T08:00:00Z', end: '2026-09-01T09:00:00Z' });
expect(out).toContain('DTSTART:20000326T010000'); // the summer-time rule, untouched
expect(out).toContain('DTSTART:20260901T080000Z'); // the event, moved
expect(out).not.toContain('DTSTART;TZID=Europe/Lisbon:20260828T130000');
expect(parseEvents(out)[0]!.start).toBe('2026-09-01T08:00:00Z');
});
test('the VTIMEZONE block survives intact', () => {
const out = patchEvent(WITH_VTIMEZONE, { summary: 'Renamed' });
for (const kept of ['BEGIN:VTIMEZONE', 'TZID:Europe/Lisbon', 'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0000'])
expect(out).toContain(kept);
expect(parseEvents(out)[0]!.summary).toBe('Renamed');
});
test('a summary is not appended into the wrong component', () => {
const out = patchEvent(WITH_VTIMEZONE, { location: 'Ramiro' });
const lines = out.split('\r\n');
expect(lines.indexOf('LOCATION:Ramiro')).toBeGreaterThan(lines.indexOf('BEGIN:VEVENT'));
});
});
+231
View File
@@ -0,0 +1,231 @@
import {
addDay,
dateStamp,
escapeText,
fold,
parseContacts,
parseEvents,
subDay,
utcStamp,
type CalendarEvent,
} from './ical';
/**
* Changing one field of an event or a card, without losing what this codebase does not understand.
*
* ── Read, patch, put — never rebuild ──
*
* A stored object holds far more than any of our screens show: alarms, attendees, recurrence rules,
* categories, every `X-` property the client that wrote it invented. Rebuilding it from the fields we
* parsed would silently drop all of that, and an edit that quietly deletes half an event is worse than
* no editing at all. So every function here takes the ORIGINAL text, replaces only the lines it owns,
* and hands the whole thing back.
*
* ── Why this is on the server ──
*
* It lived in the browser first, next to the form, and the folding rules had to be duplicated to get it
* there. Two copies of "escape a TEXT value and fold at 75 octets" is two places to be subtly wrong, and
* the agent skill needed the same logic a THIRD time in bash. One implementation, one round trip, three
* callers.
*/
/**
* Replace a property's line, or append it when the file has none. `null` removes it.
*
* A property is not always one line. RFC 5545 folds anything over 75 octets onto continuation lines
* starting with a space, so a long description is one property spread over several. Replacing only the
* first line leaves the rest behind, and they then fold onto whatever was written in its place — the new
* value silently grows the tail of the old one.
*
* `params` REPLACES the property's parameters when given, and preserves them when omitted. Both matter:
* `DTSTART;TZID=Europe/Lisbon` must not become a bare `DTSTART` when its value changes, and it must not
* KEEP that TZID when the new value is a UTC instant ending in Z — a timezone and a Z together are a
* contradiction some clients resolve by moving the event.
*/
export function setProperty(raw: string, prop: string, value: string | null, params?: string): string {
const lines = raw.split(/\r?\n/);
const at = lines.findIndex((l) => l === prop || l.startsWith(`${prop}:`) || l.startsWith(`${prop};`));
// Everything folded onto the property belongs to it.
let after = at + 1;
while (at !== -1 && after < lines.length && /^[ \t]/.test(lines[after]!)) after++;
if (value === null) return at === -1 ? raw : [...lines.slice(0, at), ...lines.slice(after)].join('\r\n');
if (at !== -1) {
const existing = /^[A-Z-]+(;[^:]*)?:/.exec(lines[at]!)?.[1] ?? '';
return [...lines.slice(0, at), fold(`${prop}${params ?? existing}:${value}`), ...lines.slice(after)].join('\r\n');
}
// Appended immediately after UID, so it lands in the right block of a file holding more than one.
const uidAt = lines.findIndex((l) => l.startsWith('UID:'));
const insertAt = uidAt === -1 ? Math.max(0, lines.length - 2) : uidAt + 1;
return [...lines.slice(0, insertAt), fold(`${prop}${params ?? ''}:${value}`), ...lines.slice(insertAt)].join('\r\n');
}
/**
* Replace every occurrence of a repeatable property — a card's EMAILs and TELs.
*
* Parameters are carried over BY POSITION: the first new phone keeps whatever the first old one had, so
* replacing a number does not quietly turn a mobile into an unlabelled one. Extra values get the default.
*/
export function setPropertyList(raw: string, prop: string, values: string[], fallbackParams: string): string {
const lines = raw.split(/\r?\n/);
const kept: string[] = [];
const oldParams: string[] = [];
let insertAt = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (line.startsWith(`${prop}:`) || line.startsWith(`${prop};`)) {
if (insertAt === -1) insertAt = kept.length;
oldParams.push(/^[A-Z-]+(;[^:]*)?:/.exec(line)?.[1] ?? '');
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1]!)) i++; // its continuation lines go too
continue;
}
kept.push(line);
}
if (insertAt === -1) {
const uidAt = kept.findIndex((l) => l.startsWith('UID:'));
insertAt = uidAt === -1 ? Math.max(0, kept.length - 1) : uidAt + 1;
}
const written = values.map((v, i) => fold(`${prop}${oldParams[i] ?? fallbackParams}:${escapeText(v)}`));
return [...kept.slice(0, insertAt), ...written, ...kept.slice(insertAt)].join('\r\n');
}
/**
* Confine a patch to one component's lines.
*
* FOUND THE HARD WAY, against a real file. A stored .ics is not a flat list of properties: an event with
* a TZID carries a VTIMEZONE block, and that block has DTSTART lines of its own — the rule for when
* summer time begins. A patch that takes the FIRST `DTSTART:` in the file rewrites the timezone
* definition, leaves the event's own start exactly where it was, and reports success. The calendar then
* shows the old time, and the file is quietly corrupt.
*
* So every write goes through here first. The properties it owns live inside `BEGIN:VEVENT` (or
* `BEGIN:VCARD`), and nothing outside is ever seen.
*
* THE FIRST component, when there are several. An .ics holding a recurring series plus overrides has one
* VEVENT per override, and the first is the master — which is what a list row shows and what an edit
* from it should change.
*/
function within(raw: string, component: 'VEVENT' | 'VCARD', edit: (body: string) => string): string {
const lines = raw.split(/\r?\n/);
const start = lines.indexOf(`BEGIN:${component}`);
const end = start === -1 ? -1 : lines.indexOf(`END:${component}`, start + 1);
// No wrapper — a bare fragment. Patch what there is rather than refusing.
if (start === -1 || end === -1) return edit(raw);
const body = edit(lines.slice(start + 1, end).join('\r\n'));
return [...lines.slice(0, start + 1), ...body.split(/\r?\n/), ...lines.slice(end)].join('\r\n');
}
/** A TEXT property: escaped, or removed when explicitly null. */
const text = (raw: string, prop: string, value: string | null | undefined) =>
value === undefined ? raw : setProperty(raw, prop, value === null ? null : escapeText(value));
export type EventPatch = {
summary?: string;
location?: string | null;
description?: string | null;
/** ISO instant, or `YYYY-MM-DD` for an all-day event. */
start?: string;
/** Same shape. For an all-day event this is the LAST day, inclusive — as a person means it. */
end?: string;
allDay?: boolean;
};
/**
* Apply a field patch to one .ics.
*
* TIMES ARE THE DELICATE PART. Three things have to move together and any one of them alone is a bug:
* the value's format (`20260901T090000Z` or `20260901`), the `VALUE=DATE` parameter that says which of
* those it is, and — for all-day — the exclusive DTEND. Changing a timed event to all-day rewrites both
* ends; changing only the end time leaves the start exactly as it was, TZID and all.
*/
export function patchEvent(raw: string, patch: EventPatch): string {
return within(raw, 'VEVENT', (body) => patchEventBody(body, raw, patch));
}
function patchEventBody(raw: string, whole: string, patch: EventPatch): string {
let out = text(raw, 'SUMMARY', patch.summary);
out = text(out, 'LOCATION', patch.location);
out = text(out, 'DESCRIPTION', patch.description);
const current: CalendarEvent | undefined = parseEvents(whole)[0];
const wasAllDay = Boolean(current?.allDay);
const allDay = patch.allDay ?? wasAllDay;
const movesTime = patch.start !== undefined || patch.end !== undefined || allDay !== wasAllDay;
if (!movesTime) return out;
const start = patch.start ?? current?.start;
if (!start) throw new Error('cannot move an event whose start could not be read');
// The file's all-day DTEND is EXCLUSIVE, so reusing it as-is would shift the event a day every time
// something else about it was edited. Normalise to the inclusive form the callers speak.
const existingEnd = current?.end && wasAllDay ? subDay(current.end) : current?.end;
const end = patch.end ?? existingEnd;
if (allDay) {
out = setProperty(out, 'DTSTART', dateStamp(start), ';VALUE=DATE');
out = setProperty(out, 'DTEND', dateStamp(addDay(end ?? start)), ';VALUE=DATE');
} else {
const startDate = new Date(start);
// An hour, matching what a new event with no end gets.
const endDate = end ? new Date(end) : new Date(startDate.getTime() + 60 * 60 * 1000);
out = setProperty(out, 'DTSTART', utcStamp(startDate), '');
out = setProperty(out, 'DTEND', utcStamp(endDate <= startDate ? startDate : endDate), '');
}
// Phones use SEQUENCE to tell an update from a duplicate, and LAST-MODIFIED is what a sync log shows.
const sequence = Number(/^SEQUENCE:(\d+)/m.exec(out)?.[1] ?? '0');
out = setProperty(out, 'SEQUENCE', String(sequence + 1), '');
return setProperty(out, 'LAST-MODIFIED', utcStamp(new Date()), '');
}
export type ContactPatch = {
fullName?: string;
emails?: string[];
phones?: string[];
org?: string | null;
title?: string | null;
};
/**
* Apply a field patch to one .vcf.
*
* `emails` and `phones` REPLACE the whole list, because that is the shape they are read in — what comes
* back from the listing is what goes into a patch. Sending one email to a card that had two removes the
* other, which is the only reading of "set the emails to this" that is not a guess.
*
* `N` follows `FN`, since a card whose display name and structured name disagree sorts under one and
* displays the other.
*/
export function patchContact(raw: string, patch: ContactPatch): string {
return within(raw, 'VCARD', (body) => patchContactBody(body, patch));
}
function patchContactBody(raw: string, patch: ContactPatch): string {
let out = raw;
if (patch.fullName !== undefined) {
const [family, ...given] = patch.fullName.trim().split(/\s+/).reverse();
out = setProperty(out, 'FN', escapeText(patch.fullName));
out = setProperty(out, 'N', `${escapeText(family ?? '')};${escapeText(given.reverse().join(' '))};;;`, '');
}
if (patch.emails !== undefined) out = setPropertyList(out, 'EMAIL', patch.emails, ';TYPE=INTERNET');
if (patch.phones !== undefined) out = setPropertyList(out, 'TEL', patch.phones, ';TYPE=CELL');
out = text(out, 'ORG', patch.org);
out = text(out, 'TITLE', patch.title);
// Not merely informational: some clients keep the copy with the later REV when two disagree.
return setProperty(out, 'REV', utcStamp(new Date()), '');
}
/** Which patcher a href needs. The extension is the only thing that distinguishes them. */
export const patchFor = (href: string, patch: EventPatch & ContactPatch, raw: string): string =>
href.endsWith('.vcf') ? patchContact(raw, patch) : patchEvent(raw, patch);
/** Present only so a caller can confirm what it read back. */
export const summarise = (href: string, raw: string) =>
href.endsWith('.vcf') ? parseContacts(raw)[0] : parseEvents(raw)[0];
+90 -94
View File
@@ -1,155 +1,151 @@
---
name: Calendar and contacts
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "cancel that", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the working recipes, the two credentials, and the timezone trap that puts every event an hour out.
version: 1
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "move that to 9", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the JSON routes, the one credential, and the timezone trap that puts every event an hour out.
version: 2
---
You can read and write the owner's calendars and address book directly. **Reading goes through Officer's
API. Writing goes through the DAV door.** Both use credentials you already have; there is nothing to ask
for and no password to be given.
You can read and write the owner's calendars and address books. **It is a JSON API and you already have
the key.** Nothing to ask for, no password to be given, no XML.
## The two doors
You do not write iCalendar or vCard by hand. You did once, and the recipes were long because the format
is full of things that fail silently — escaping, 75-octet line folding, an end date that is exclusive,
a timezone definition with dates of its own. All of that now happens on the server. Send fields.
**Read — Officer's own API, with `$OFFICER_AGENT_KEY`.** Parsed JSON, no XML.
## The one door
```bash
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
"$OFFICER_API_URL/api/caldav/_officer/collections"
```
**Write — the DAV door, HTTP Basic, the SAME key as the password.**
```bash
ME=$(curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
"$OFFICER_API_URL/api/auth/me" | python3 -c 'import json,sys; print(json.load(sys.stdin)["email"])')
AUTH="$ME:$OFFICER_AGENT_KEY"
OFFICER=(-H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST"
-H 'content-type: application/json')
curl -s "${OFFICER[@]}" "$OFFICER_API_URL/api/caldav/_officer/collections"
```
Your key is bound to ONE account — the owner's. You cannot reach anyone else's calendar with it and you
do not need to guard against doing so: another principal answers 403, and naming a different user in the
Basic header answers 401. Write the owner's own paths and stop thinking about it.
do not need to guard against doing so: another user's path answers 400. Write the owner's own paths and
stop thinking about it.
## Find the collections first — never guess a path
```bash
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
"$OFFICER_API_URL/api/caldav/_officer/collections"
# {"collections":[{"path":"/dav/1/personal/","displayName":"Personal","kind":"calendar", ...},
# {"path":"/dav/1/contacts/","displayName":"Contacts","kind":"addressbook"}]}
# {"collections":[{"path":"/dav/1/personal/","name":"personal","displayName":"Personal","kind":"calendar"},
# {"path":"/dav/1/contacts/","name":"contacts","displayName":"Contacts","kind":"addressbook"}]}
```
`kind` is `calendar` or `addressbook`. There are usually several calendars — **"Personal" is the default
for anything the owner does not place**, and Tasks holds VTODOs that show up in Reminders on a phone.
`kind` is `calendar` or `addressbook`. Pass the **`path`** to every route below. There are usually
several calendars — **"Personal" is the default for anything the owner does not place** — and Tasks holds
VTODOs, which show up in Reminders on a phone.
## THE TIMEZONE TRAP — read this before writing any time
The owner says "Saturday at 8". Calendars store UTC. **`date -u -d` does not convert** — `-u` makes GNU
date parse the input as UTC *and* print UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon. Every
event lands an hour out and nothing errors.
The owner says "Saturday at 8". The API takes UTC. **`date -u -d` does not convert** — `-u` makes GNU
date parse the input as UTC *and* print it as UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon.
Every event lands an hour out and nothing errors.
```bash
# WRONG — prints 20260829T200000Z
date -u -d 'saturday 20:00' +%Y%m%dT%H%M%SZ
# RIGHT — prints 20260829T190000Z, via epoch so the local zone is applied
date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ
# WRONG — prints 2026-08-29T20:00:00Z
date -u -d 'saturday 20:00' +%Y-%m-%dT%H:%M:%SZ
# RIGHT — prints 2026-08-29T19:00:00Z, via epoch so the local zone is applied
date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y-%m-%dT%H:%M:%SZ
```
The machine's clock is the owner's timezone. Parse what they said with plain `date -d`, convert through
`+%s`, and **say the LOCAL time back to them** when you confirm — never the UTC you stored.
## Create an event
The filename must be `<UID>.ics`, and iCalendar lines are **CRLF-terminated**. `DTSTAMP` is required.
```bash
UID="officer-$(date +%s)-$RANDOM"
S=$(date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ)
E=$(date -u -d "@$(date -d 'saturday 22:00' +%s)" +%Y%m%dT%H%M%SZ)
printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Officer//agent//EN\r\nBEGIN:VEVENT\r\nUID:$UID\r\nDTSTAMP:$(date -u +%Y%m%dT%H%M%SZ)\r\nDTSTART:$S\r\nDTEND:$E\r\nSUMMARY:Dinner with my wife\r\nLOCATION:Cervejaria Ramiro\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" \
| curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' --data-binary @- \
"$OFFICER_API_URL/dav/1/personal/$UID.ics"
# 201 created
```
An **all-day** event uses dates, not times, and DTEND is the day AFTER:
`DTSTART;VALUE=DATE:20260829` with `DTEND;VALUE=DATE:20260830`.
## Look something up
"When am I having dinner with the CEO?" — read, filter, answer in local time.
```bash
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
"$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F"
# {"events":[{"uid":"…","summary":"Dinner with my wife","start":"2026-08-29T19:00:00Z","end":"…",
# "location":"Cervejaria Ramiro","allDay":false}]}
curl -s "${OFFICER[@]}" "$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F"
# {"events":[{"uid":"…","href":"/dav/1/personal/….ics","summary":"Dinner with my wife",
# "start":"2026-08-29T19:00:00Z","location":"Cervejaria Ramiro","allDay":false}]}
```
`start` and `end` are UTC. **Convert before speaking**: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`.
Contacts are the same shape at `_officer/contacts`, returning `fullName`, `emails` and `phones`.
Search every calendar, not just Personal — the answer is often in another one. `location` is where
"where did I set it up" is answered from; if it is empty, say so rather than inventing somewhere.
**Search every calendar, not just Personal** — the answer is often in another one. `start` and `end` are
UTC, so convert before speaking: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`.
`rrule` present means the event REPEATS and the listed `start` is the series start, not the next
occurrence. Do not compute occurrences yourself — say it repeats and give the rule in words.
## Move an event
**`href` is how you change or delete a thing, and it only ever comes from a read.** Never build one. A
file is only conventionally named after its UID: everything a phone wrote is named something else, and a
constructed href finds nothing or, worse, finds something.
**Same UID, same filename, new times.** A new UID creates a second event and leaves the first in place,
which is the most common way this goes wrong.
## Add an event
```bash
# fetch the current copy, so nothing else in it is lost
curl -s -u "$AUTH" "$OFFICER_API_URL/dav/1/personal/$UID.ics" > /tmp/ev.ics
# then PUT a version with new DTSTART/DTEND and SEQUENCE bumped by one
curl -s "${OFFICER[@]}" -X POST "$OFFICER_API_URL/api/caldav/_officer/event" \
-d '{"collection":"/dav/1/personal/","summary":"Dinner with my wife","location":"Cervejaria Ramiro",
"start":"2026-08-29T19:00:00Z","end":"2026-08-29T21:00:00Z","allDay":false}'
# {"href":"/dav/1/personal/<uuid>.ics"}
```
Bump `SEQUENCE` (add `SEQUENCE:1`, or increment what is there). Phones use it to tell an update from a
duplicate. A successful update answers **204**, not 201 — 201 means you created something new, so check
your filename.
All-day takes plain dates and no times: `{"start":"2026-08-29","allDay":true}`. `end` is the LAST day as
a person means it — a two-day trip is `"start":"2026-08-29","end":"2026-08-30"`. No end means one hour,
or one day.
## Delete an event, or a contact
## Add a contact
```bash
curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X DELETE "$OFFICER_API_URL/dav/1/personal/$UID.ics"
# 200
curl -s "${OFFICER[@]}" -X POST "$OFFICER_API_URL/api/caldav/_officer/contact" \
-d '{"collection":"/dav/1/contacts/","fullName":"Peter Griffin",
"emails":["peter@quahog.example"],"phones":["+351912345678"],"org":"Pawtucket Brewery"}'
```
**Find the UID by reading first — never guess.** The file is normally `<UID>.ics`, and if that 404s, list
the collection and match on href:
`fullName` is what a phone displays and is required. Phone numbers keep their international prefix.
## Change something
One PATCH, with only the fields that change. Everything else in the file is left exactly as it was —
including alarms, attendees, recurrence rules and anything a phone put there that Officer has no idea
about.
```bash
curl -s -u "$AUTH" -X PROPFIND -H 'Depth: 1' "$OFFICER_API_URL/dav/1/personal/" | grep -o '<D:href>[^<]*'
curl -s "${OFFICER[@]}" -X PATCH "$OFFICER_API_URL/api/caldav/_officer/item" \
-d '{"href":"/dav/1/personal/….ics","start":"2026-08-29T20:00:00Z","end":"2026-08-29T22:00:00Z"}'
# {"ok":true,"item":{…the whole thing, as it now reads…}}
```
- **Events**: `summary`, `location`, `description`, `start`, `end`, `allDay`.
- **Contacts**: `fullName`, `emails`, `phones`, `org`, `title`.
- `null` REMOVES a field. `""` leaves it there and empty. Omit it to leave it alone.
- `emails` and `phones` replace the whole list. To add a number, send the existing ones back with the new
one — read first.
**Moving an event is a PATCH, not a new event.** Creating a second one and leaving the first is the most
common way this goes wrong, and the owner finds out when two of them ring.
**Confirm from `item`, not from what you sent.** It is the file re-read after the write.
## Delete
```bash
curl -s "${OFFICER[@]}" -X DELETE "$OFFICER_API_URL/api/caldav/_officer/item" \
-d '{"href":"/dav/1/personal/….ics"}'
```
**Confirm before deleting when the match is not exact.** "Cancel dinner on Saturday" with one dinner on
Saturday is unambiguous — do it. Two dinners, or a recurring one, is a question: deleting the wrong event
cannot be undone from here, and the owner will discover it when someone is stood up.
Saturday is unambiguous — do it. Two dinners, or a recurring one, is a question: this cannot be undone
from here, and the owner discovers it when somebody is stood up.
Deleting one occurrence of a REPEATING event is not a DELETE — it needs an EXDATE added to the series.
If that is what they meant, say so rather than removing every occurrence.
Deleting one occurrence of a REPEATING event is not a delete — it needs an EXDATE on the series. Say so
rather than removing every occurrence.
## Add or remove a contact
## What this API deliberately cannot do
Same shape, `.vcf` in the addressbook collection.
It writes ordinary events and ordinary cards. **Recurrence rules, alarms, attendees, EXDATEs and photos
are not in it** — not an oversight, but the same rule that makes a PATCH safe: a field the screens cannot
show is a field an editor should not invent.
```bash
UID="officer-$(date +%s)-$RANDOM"
printf "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:$UID\r\nFN:Peter Griffin\r\nN:Griffin;Peter;;;\r\nTEL;TYPE=CELL:+351912345678\r\nEMAIL;TYPE=INTERNET:peter@quahog.example\r\nEND:VCARD\r\n" \
| curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \
-H 'Content-Type: text/vcard; charset=utf-8' --data-binary @- \
"$OFFICER_API_URL/dav/1/contacts/$UID.vcf"
```
`FN` is what shows on a phone and is REQUIRED. `N` is `Family;Given;Middle;Prefix;Suffix` — fill what you
know and leave the rest empty rather than omitting the line. Read contacts back with
`_officer/contacts?collection=…`, which returns `fullName`, `emails` and `phones` already parsed.
When the owner genuinely needs one, the DAV door is still there — HTTP Basic, their email as the
username and `$OFFICER_AGENT_KEY` as the password, PUT the whole `.ics` to its href. Read the file first
with `_officer/item?href=…`, change what you must, and put it back with the same UID and the same
filename. **Bump `SEQUENCE`** so a phone reads an update rather than a duplicate. If you are doing this,
say so — it is the part where a mistake is silent.
## Confirm in one line, in their words
"Added — Dinner with my wife, Saturday 29 August, 20:00, Cervejaria Ramiro." Local time, the name they
used, and where. Not the UID, not the UTC, not the HTTP status.
used, and where. Not the href, not the UTC, not the HTTP status.
If a write fails, say what failed and stop. **Never retry a PUT with a new UID** — the usual cause of a
failure is that the first one partly worked, and a retry leaves two.
If a write fails, say what failed and stop. **Never retry a create after a failure** — the usual cause is
that the first one partly worked, and a retry leaves two.
+244 -8
View File
@@ -1,6 +1,8 @@
import type { CalendarEvent } from './shared';
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
import { useState } from 'react';
import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData';
import { useDavEdit, CREATING, type NewEvent } from './useDavEdit';
import { useTranslation } from 'officerdev';
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
@@ -26,16 +28,62 @@ const formatTime = (ev: CalendarEvent) => {
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
};
type Draft = { summary: string; location: string; date: string; start: string; end: string; allDay: boolean };
const pad = (n: number) => String(n).padStart(2, '0');
const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const localTime = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
/** The next whole hour, for an hour. What every calendar has trained people to expect from "new event". */
const blankDraft = (): Draft => {
const start = new Date(Date.now() + 60 * 60 * 1000);
start.setMinutes(0, 0, 0);
const end = new Date(start.getTime() + 60 * 60 * 1000);
return { summary: '', location: '', date: localDate(start), start: localTime(start), end: localTime(end), allDay: false };
};
/**
* The form's local fields become what the sidecar takes: an ISO instant, or a plain date for all-day.
*
* The clock is read as LOCAL — a person typing 14:00 means 14:00 where they are — and `toISOString`
* converts it, so what reaches the file is UTC and unambiguous.
*/
const toEvent = (d: Draft): NewEvent => {
const base = { summary: d.summary.trim(), location: d.location.trim() || undefined };
if (d.allDay) return { ...base, start: d.date, allDay: true };
const start = new Date(`${d.date}T${d.start}`);
let end = new Date(`${d.date}T${d.end}`);
// 23:00–01:00 is an event that crosses midnight, not one that ends two hours before it started.
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
return { ...base, start: start.toISOString(), end: end.toISOString(), allDay: false };
};
const FIELD = 'rounded-md border bg-background px-2 py-1 text-sm';
export const CalendarView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
// Keyed on the COLLECTION, so a save refreshes the list this event came from.
const edit = useDavEdit(['caldav', 'events', selected?.path ?? null]);
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState({ summary: '', location: '' });
const [confirming, setConfirming] = useState<string | null>(null);
const [draftNew, setDraftNew] = useState<Draft | null>(null);
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading events…" />;
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
// Not an early return any more: the header carries the only way to ADD an event, and a calendar that
// is empty or still loading is exactly when somebody wants it.
const notice = error
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
: isLoading
? 'Loading events…'
: events.length === 0
? `${selected.displayName} has no events.`
: null;
const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
@@ -45,11 +93,109 @@ export const CalendarView = () => {
if (last && last.key === key) last.events.push(ev);
else days.push({ key, events: [ev] });
}
// Newest day at the top. A calendar accumulates years, and an ascending list opens on whatever was
// imported first — scrolling to the bottom to find this week is the wrong default. Within a day the
// order stays chronological, because that is what a day IS.
days.reverse();
const submitNew = (e: React.FormEvent) => {
e.preventDefault();
if (!draftNew?.summary.trim()) return;
void edit.createEvent(selected.path, toEvent(draftNew)).then((ok) => ok && setDraftNew(null));
};
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
{days.map((day) => (
<div className="flex h-full flex-col">
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
<div className="text-xs text-muted-foreground">
{events.length} {events.length === 1 ? 'event' : 'events'}
</div>
</div>
<button
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
>
<Plus className="h-3.5 w-3.5" />
New event
</button>
</header>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
{/* Title, when, and where. No recurrence and no alarms: this form writes a minimal VEVENT, and
a field the list cannot show afterwards is a field that silently disappears. */}
{draftNew && (
<form onSubmit={submitNew} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<input
autoFocus
value={draftNew.summary}
onChange={(e) => setDraftNew({ ...draftNew, summary: e.target.value })}
placeholder="Title"
className={FIELD}
/>
<div className="flex flex-wrap items-center gap-2">
<input
type="date"
value={draftNew.date}
onChange={(e) => setDraftNew({ ...draftNew, date: e.target.value })}
className={`${FIELD} tabular-nums`}
/>
{!draftNew.allDay && (
<>
<input
type="time"
value={draftNew.start}
onChange={(e) => setDraftNew({ ...draftNew, start: e.target.value })}
className={`${FIELD} tabular-nums`}
/>
<span className="text-xs text-muted-foreground">to</span>
<input
type="time"
value={draftNew.end}
onChange={(e) => setDraftNew({ ...draftNew, end: e.target.value })}
className={`${FIELD} tabular-nums`}
/>
</>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={draftNew.allDay}
onChange={(e) => setDraftNew({ ...draftNew, allDay: e.target.checked })}
/>
All day
</label>
</div>
<input
value={draftNew.location}
onChange={(e) => setDraftNew({ ...draftNew, location: e.target.value })}
placeholder="Where"
className={`${FIELD} text-xs`}
/>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={edit.busy === CREATING || !draftNew.summary.trim()}
className="flex items-center gap-1 rounded-md bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary disabled:opacity-50"
>
<Check className="h-3 w-3" /> {edit.busy === CREATING ? 'Creating…' : 'Create'}
</button>
<button
type="button"
onClick={() => setDraftNew(null)}
className="rounded-md px-2 py-1.5 text-xs text-muted-foreground"
>
Cancel
</button>
{edit.error && <span className="text-xs text-destructive">{edit.error}</span>}
</div>
</form>
)}
{notice && <Placeholder text={notice} />}
{days.map((day) => (
<section key={day.key} className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{formatDay(day.key)}
@@ -74,6 +220,53 @@ export const CalendarView = () => {
)}
</div>
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
{/* The editor is the title and the place, and nothing else. Those are the two fields a
person actually corrects; times and recurrence are where an editor does damage, and
a half-editor that silently rewrites an RRULE is worse than none. */}
{editing === ev.href && (
<form
className="mt-2 flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!ev.href) return;
// `null` removes the line; an empty string would leave an empty LOCATION behind.
void edit
.save(ev.href, { summary: draft.summary, location: draft.location || null })
.then((ok) => ok && setEditing(null));
}}
>
<input
autoFocus
value={draft.summary}
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
placeholder="Title"
className="rounded-md border bg-background px-2 py-1 text-sm"
/>
<input
value={draft.location}
onChange={(e) => setDraft((d) => ({ ...d, location: e.target.value }))}
placeholder="Where"
className="rounded-md border bg-background px-2 py-1 text-xs"
/>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={edit.busy === ev.href}
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
>
<Check className="h-3 w-3" /> {edit.busy === ev.href ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setEditing(null)}
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</form>
)}
{ev.location && (
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" />
@@ -84,10 +277,53 @@ export const CalendarView = () => {
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
)}
</div>
{/* Only for an event we can actually address. An item with no href came from somewhere
that did not give us one, and offering a delete that cannot work is worse than not
offering it. */}
{ev.href && editing !== ev.href && (
<div className="flex shrink-0 items-start gap-1">
{confirming === ev.href ? (
<>
<button
onClick={() => void edit.remove(ev.href!).then(() => setConfirming(null))}
disabled={edit.busy === ev.href}
title="Delete this event"
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
<Check className="h-3.5 w-3.5" />
</button>
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<button
onClick={() => {
setDraft({ summary: ev.summary ?? '', location: ev.location ?? '' });
setEditing(ev.href!);
}}
title="Edit"
className="rounded p-1 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirming(ev.href!)}
title="Delete"
className="rounded p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
)}
</article>
))}
</section>
))}
))}
</div>
</div>
</div>
);
+7 -7
View File
@@ -3,13 +3,13 @@ import type { DavCollection } from './shared';
import { Link, useSearchParams } from 'react-router';
import { CalendarDays, Contact as ContactIcon, Smartphone } from 'lucide-react';
import { davCollectionPath, COLLECTION_PARAM } from './shared';
import { useDavCollections } from './useDavData';
import { useDavCollections, matchCollection } from './useDavData';
import { AppPasswordsModal, useAppPasswordsModal } from '../AppPasswordsModal';
import { useTranslation } from 'officerdev';
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
//
// Rows are real links carrying ?collection=<dav path>, so a calendar is a bookmarkable address and the
// Rows are real links carrying ?collection=<name>, so a calendar is a bookmarkable address and the
// right-hand panel reads the same URL rather than being told over a channel. Active state is compared
// against the URL directly instead of NavLink's isActive, because every row shares one pathname and
// differs only in the query string.
@@ -28,9 +28,9 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
const [, setSyncOpen] = useAppPasswordsModal();
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
// Nothing in the URL means the view falls back to the first collection — highlight the same one.
const activePath = collections.some((c) => c.path === fromUrl) ? fromUrl : (collections[0]?.path ?? null);
// Resolved the same way the right-hand panel resolves it, so the highlight cannot disagree with what
// is on screen — including the fallback to the first collection when the URL names nothing.
const active = matchCollection(collections, params.get(COLLECTION_PARAM));
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
@@ -50,11 +50,11 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{collections.map((collection) => {
const isActive = collection.path === activePath;
const isActive = collection.path === active?.path;
return (
<Link
key={collection.path}
to={davCollectionPath(base, collection.path)}
to={davCollectionPath(base, collection.name)}
className={`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
+207 -9
View File
@@ -1,5 +1,7 @@
import { Building2, Mail, Phone, User } from 'lucide-react';
import { useState } from 'react';
import { Building2, Mail, Phone, User, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
import { useDavContacts, useSelectedCollection } from './useDavData';
import { useDavEdit, CREATING } from './useDavEdit';
import { useTranslation } from 'officerdev';
// Right panel of /contacts: the selected address book as cards, sorted by name.
@@ -16,23 +18,116 @@ const initials = (name: string) =>
.map((part) => part[0]?.toUpperCase() ?? '')
.join('') || '?';
const FIELD = 'rounded-md border bg-background px-2 py-1 text-sm';
const blankDraft = () => ({ fullName: '', email: '', phone: '' });
export const ContactsView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
const edit = useDavEdit(['caldav', 'contacts', selected?.path ?? null]);
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState({ fullName: '', email: '', phone: '' });
const [confirming, setConfirming] = useState<string | null>(null);
const [draftNew, setDraftNew] = useState<ReturnType<typeof blankDraft> | null>(null);
if (loadingCollections) return <Placeholder text="Loading address books…" />;
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading contacts…" />;
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
// Not an early return: the header carries the only way to ADD a card, and an empty address book is
// exactly when somebody wants it.
const notice = error
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
: isLoading
? 'Loading contacts…'
: contacts.length === 0
? `${selected.displayName} has no contacts.`
: null;
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
const submitNew = (e: React.FormEvent) => {
e.preventDefault();
if (!draftNew?.fullName.trim()) return;
void edit
.createContact(selected.path, {
fullName: draftNew.fullName.trim(),
emails: [draftNew.email].filter(Boolean),
phones: [draftNew.phone].filter(Boolean),
})
.then((ok) => ok && setDraftNew(null));
};
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
{sorted.map((contact) => (
<div className="flex h-full flex-col">
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
<div className="text-xs text-muted-foreground">
{contacts.length} {contacts.length === 1 ? 'contact' : 'contacts'}
</div>
</div>
<button
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
>
<Plus className="h-3.5 w-3.5" />
New contact
</button>
</header>
<div className="flex-1 overflow-y-auto">
{/* Name, one email, one phone. A card can hold far more, and a CardDAV client can put it there —
this is the form for adding somebody from their business card, not for modelling a person. */}
{draftNew && (
<form onSubmit={submitNew} className="mx-auto flex max-w-4xl flex-col gap-3 px-6 pt-6">
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<input
autoFocus
value={draftNew.fullName}
onChange={(e) => setDraftNew({ ...draftNew, fullName: e.target.value })}
placeholder="Name"
className={FIELD}
/>
<div className="flex flex-wrap gap-2">
<input
value={draftNew.email}
onChange={(e) => setDraftNew({ ...draftNew, email: e.target.value })}
placeholder="Email"
className={`${FIELD} min-w-0 flex-1 text-xs`}
/>
<input
value={draftNew.phone}
onChange={(e) => setDraftNew({ ...draftNew, phone: e.target.value })}
placeholder="Phone"
className={`${FIELD} min-w-0 flex-1 text-xs tabular-nums`}
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={edit.busy === CREATING || !draftNew.fullName.trim()}
className="flex items-center gap-1 rounded-md bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary disabled:opacity-50"
>
<Check className="h-3 w-3" /> {edit.busy === CREATING ? 'Creating…' : 'Create'}
</button>
<button
type="button"
onClick={() => setDraftNew(null)}
className="rounded-md px-2 py-1.5 text-xs text-muted-foreground"
>
Cancel
</button>
{edit.error && <span className="text-xs text-destructive">{edit.error}</span>}
</div>
</div>
</form>
)}
{notice && <Placeholder text={notice} />}
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
{sorted.map((contact) => (
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
{initials(contact.fullName)}
@@ -65,9 +160,112 @@ export const ContactsView = () => {
<span className="truncate">{phone}</span>
</a>
))}
{/* Name, one email, one phone — the three fields worth correcting from a list. A card can
hold several of each; editing rewrites the FIRST and leaves the rest untouched, which
is why `setProperty` replaces a line rather than rebuilding the card. */}
{editing === contact.href && (
<form
className="mt-2 flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!contact.href) return;
/* The form shows the FIRST email and phone; a card can hold several. Sending just
the one would delete the rest, so the others are carried through untouched. */
void edit
.save(contact.href, {
fullName: draft.fullName,
emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean),
phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean),
})
.then((ok) => ok && setEditing(null));
}}
>
<input
autoFocus
value={draft.fullName}
onChange={(e) => setDraft((d) => ({ ...d, fullName: e.target.value }))}
placeholder="Name"
className="rounded-md border bg-background px-2 py-1 text-sm"
/>
<input
value={draft.email}
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
placeholder="Email"
className="rounded-md border bg-background px-2 py-1 text-xs"
/>
<input
value={draft.phone}
onChange={(e) => setDraft((d) => ({ ...d, phone: e.target.value }))}
placeholder="Phone"
className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums"
/>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={edit.busy === contact.href}
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
>
<Check className="h-3 w-3" /> {edit.busy === contact.href ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setEditing(null)}
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</form>
)}
</div>
</article>
))}
{contact.href && editing !== contact.href && (
<div className="flex shrink-0 items-start gap-1">
{confirming === contact.href ? (
<>
<button
onClick={() => void edit.remove(contact.href!).then(() => setConfirming(null))}
disabled={edit.busy === contact.href}
title="Delete this contact"
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
<Check className="h-3.5 w-3.5" />
</button>
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<button
onClick={() => {
setDraft({
fullName: contact.fullName ?? '',
email: contact.emails[0] ?? '',
phone: contact.phones[0] ?? '',
});
setEditing(contact.href!);
}}
title="Edit"
className="rounded p-1 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirming(contact.href!)}
title="Delete"
className="rounded p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
)}
</article>
))}
</div>
</div>
</div>
);
+18 -6
View File
@@ -4,9 +4,12 @@
// see src/servers/sidecar/caldav/{collections,ical}.ts, which is where they are produced. The sidecar
// parses the iCalendar/vCard itself so nothing here ever sees DAV XML.
/** One calendar or address book. `path` is the DAV path (/dav/<userId>/<name>/) and doubles as the id. */
/** One calendar or address book. `path` addresses it over DAV; `name` is what the URL selects it by. */
export type DavCollection = {
/** The DAV path, `/dav/<userId>/<name>/`. What every API call takes. */
path: string;
/** The last segment of that path. Unique within a user, and the only part worth putting in a URL. */
name: string;
displayName: string;
kind: 'calendar' | 'addressbook';
color?: string;
@@ -14,6 +17,8 @@ export type DavCollection = {
export type CalendarEvent = {
uid: string;
/** The DAV href — where this event lives. What edit and delete address; never derived from the uid. */
href?: string;
summary: string;
description?: string;
location?: string;
@@ -27,6 +32,8 @@ export type CalendarEvent = {
export type DavContact = {
uid: string;
/** The DAV href — where this contact lives. What edit and delete address; never derived from the uid. */
href?: string;
fullName: string;
emails: string[];
phones: string[];
@@ -36,12 +43,17 @@ export type DavContact = {
};
/**
* The query param both screens select with. A collection id contains slashes, so it cannot be a path
* segment without escaping — and per the navigation rules the selection still has to live in the URL,
* so it is a query param on the screen you are already on.
* The query param both screens select with. Per the navigation rules the selection lives in the URL, and
* a query param rather than a path segment because the detail IS the screen — there is nothing to route to.
*
* IT CARRIES THE NAME, NOT THE PATH. `?collection=personal`, not
* `?collection=%2Fdav%2F1%2Fpersonal%2F`. The path is `/dav/<userId>/<name>/`, so the escaped version
* spent six characters on slashes to say `personal` and put the owner's user id in a URL people paste at
* each other. The name is unique within a user, which is all a selector needs; the full path is
* recovered from the collections list, which the screen has already loaded.
*/
export const COLLECTION_PARAM = 'collection';
/** The one place the two screens' URLs are spelled, so nav links and guards cannot drift. */
export const davCollectionPath = (base: '/calendar' | '/contacts', collection: string) =>
`${base}?${COLLECTION_PARAM}=${encodeURIComponent(collection)}`;
export const davCollectionPath = (base: '/calendar' | '/contacts', name: string) =>
`${base}?${COLLECTION_PARAM}=${encodeURIComponent(name)}`;
+16 -2
View File
@@ -62,6 +62,21 @@ export function useDavContacts(collection: string | null) {
return { contacts: query.data?.contacts ?? EMPTY_CONTACTS, isLoading: query.isLoading, error: query.error };
}
/**
* Resolve what the URL asked for against what this user has.
*
* The param is the collection NAME. It also accepts a full DAV path, because that is what the URL used
* to carry and somebody's bookmark still has one — a one-line fallback against a link that would
* otherwise silently land on the wrong calendar.
*
* No match falls back to the first collection rather than to nothing, so a stale or mistyped link shows
* a calendar instead of an empty screen.
*/
export function matchCollection(collections: DavCollection[], fromUrl: string | null): DavCollection | null {
if (!fromUrl) return collections[0] ?? null;
return collections.find((c) => c.name === fromUrl || c.path === fromUrl) ?? collections[0] ?? null;
}
/**
* The collection the URL selects, or the first one of that kind once the list arrives. Both panels of a
* screen call this and get the same answer, because the answer is derived from the URL and the query
@@ -70,7 +85,6 @@ export function useDavContacts(collection: string | null) {
export function useSelectedCollection(kind: DavCollection['kind']) {
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
const selected = collections.find((c) => c.path === fromUrl) ?? collections[0] ?? null;
const selected = matchCollection(collections, params.get(COLLECTION_PARAM));
return { selected, collections, isLoading };
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
/**
* Creating, changing and deleting one DAV object, in FIELDS.
*
* Nothing here knows what an .ics looks like, and that is the point. This used to read the object's
* text, patch it in the browser and put it back, which meant the browser carried its own copy of the
* iCalendar escaping and 75-octet folding rules — a second place to be subtly wrong about a format
* neither screen displays. That work moved to the sidecar (see sidecar/patch.ts), which held the only
* other copy, and the agent skill now calls the same routes instead of writing a third one in bash.
*
* The guarantee it makes is unchanged: a patch replaces only the lines it owns, so alarms, attendees,
* recurrence rules and every unknown `X-` property survive an edit made from a form that cannot show them.
*/
export type NewEvent = {
summary: string;
location?: string;
/** An ISO instant for a timed event; `YYYY-MM-DD` when `allDay`. */
start: string;
end?: string;
allDay: boolean;
};
export type NewContact = {
fullName: string;
emails?: string[];
phones?: string[];
org?: string;
title?: string;
};
/** What a patch may set. Absent leaves a field alone; `null` removes it. */
export type ItemPatch = {
summary?: string;
location?: string | null;
description?: string | null;
start?: string;
end?: string;
allDay?: boolean;
fullName?: string;
emails?: string[];
phones?: string[];
org?: string | null;
title?: string | null;
};
/** `busy` holds the href being written; a create has no href yet, so it gets a sentinel of its own. */
export const CREATING = 'creating';
export function useDavEdit(invalidateKey: unknown[]) {
const client = useClient();
const qc = useQueryClient();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: invalidateKey });
/** Every verb has the same shape: mark busy, do it, refresh, and report rather than throw. */
const run = async (key: string, fallback: string, work: () => Promise<unknown>) => {
setBusy(key);
setError(null);
try {
await work();
await refresh();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : fallback);
return false;
} finally {
setBusy(null);
}
};
return {
busy,
error,
save: (href: string, patch: ItemPatch) =>
run(href, 'Could not save.', () => client.patch('/caldav/_officer/item', { href, ...patch })),
remove: (href: string) => run(href, 'Could not delete.', () => client.delete('/caldav/_officer/item', { href })),
createEvent: (collection: string, event: NewEvent) =>
run(CREATING, 'Could not create.', () => client.post('/caldav/_officer/event', { collection, ...event })),
createContact: (collection: string, contact: NewContact) =>
run(CREATING, 'Could not create.', () => client.post('/caldav/_officer/contact', { collection, ...contact })),
};
}