diff --git a/sidecar/collections.ts b/sidecar/collections.ts index 02adb0b..84bb872 100644 --- a/sidecar/collections.ts +++ b/sidecar/collections.ts @@ -1,4 +1,4 @@ -import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './ical'; +import { buildEvent, parseContacts, parseEvents, type CalendarEvent, type Contact, type NewEvent } from './ical'; // The JSON door: what Officer's own web UI reads. // @@ -11,8 +11,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 +83,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. @@ -150,3 +157,26 @@ export async function listContacts(fetcher: Fetcher, path: string): Promise 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 }; +} diff --git a/sidecar/ical.test.ts b/sidecar/ical.test.ts new file mode 100644 index 0000000..4afbbb6 --- /dev/null +++ b/sidecar/ical.test.ts @@ -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)); + }); +}); diff --git a/sidecar/ical.ts b/sidecar/ical.ts index 38e6ba1..c0aa1ff 100644 --- a/sidecar/ical.ts +++ b/sidecar/ical.ts @@ -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[] { @@ -204,3 +208,98 @@ export function parseContacts(vcf: string): Contact[] { } return contacts; } + +// ─── Writing ──────────────────────────────────────────────────────────────────────────────────────── + +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'); +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** `20260827T140000Z`. Always UTC, so the file carries no timezone this codebase would have to resolve. */ +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`. */ +const dateStamp = (iso: string) => iso.slice(0, 10).replace(/-/g, ''); + +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'; +} diff --git a/sidecar/index.ts b/sidecar/index.ts index 4ec791c..ce2b2d9 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -1,7 +1,7 @@ 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 } from './collections'; import { DATA_PATH } from '@@/data-path'; import { API_URL } from '@@/officer-url.mjs'; @@ -232,6 +232,44 @@ const server = Bun.serve({ 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 }); + } + } + 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. @@ -247,7 +285,7 @@ const server = Bun.serve({ } /* - Writing, from Officer's own screens. + On the writing routes above. ── Why the href is required and never derived ── diff --git a/skills/calendar-and-contacts/SKILL.md b/skills/calendar-and-contacts/SKILL.md index 55eead3..6aa741b 100644 --- a/skills/calendar-and-contacts/SKILL.md +++ b/skills/calendar-and-contacts/SKILL.md @@ -59,6 +59,26 @@ The machine's clock is the owner's timezone. Parse what they said with plain `da ## Create an event +**The short way, for an ordinary event.** One POST, and the sidecar writes the file — it mints the UID +and the filename, converts to UTC, escapes the text and folds any line over 75 octets. All the parts +below that are easy to get subtly wrong. + +```bash +curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ + -H 'content-type: application/json' -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/.ics"} +``` + +All-day takes plain dates and no times: `{"start":"2026-08-29","allDay":true}`. `end` is the LAST day as +a person means it — the exclusive DTEND is this route's problem, not yours. + +It writes a minimal VEVENT and nothing else. **Anything with a recurrence rule, an alarm or attendees +has to be written by hand**, below. + +## Create an event by hand + The filename must be `.ics`, and iCalendar lines are **CRLF-terminated**. `DTSTAMP` is required. ```bash @@ -73,7 +93,12 @@ printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Officer//agent//EN\r\nBEGIN: ``` An **all-day** event uses dates, not times, and DTEND is the day AFTER: -`DTSTART;VALUE=DATE:20260829` with `DTEND;VALUE=DATE:20260830`. +`DTSTART;VALUE=DATE:20260829` with `DTEND;VALUE=DATE:20260830`. Writing the same day produces a +zero-length event that some clients simply do not draw. + +Two more that bite when writing by hand, and are why the JSON route above exists: a comma, semicolon or +backslash inside a SUMMARY must be backslash-escaped or it splits the value, and a content line over 75 +octets must be folded onto a continuation line starting with one space. ## Look something up diff --git a/web/dav/CalendarView.tsx b/web/dav/CalendarView.tsx index 2d56dc0..7df53f5 100644 --- a/web/dav/CalendarView.tsx +++ b/web/dav/CalendarView.tsx @@ -1,8 +1,8 @@ import type { CalendarEvent } from './shared'; import { useState } from 'react'; -import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X } from 'lucide-react'; +import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X, Plus } from 'lucide-react'; import { useCalendarEvents, useSelectedCollection } from './useDavData'; -import { useDavEdit, setProperty } from './useDavEdit'; +import { useDavEdit, setProperty, 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 @@ -28,6 +28,39 @@ 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'); @@ -37,12 +70,20 @@ export const CalendarView = () => { const [editing, setEditing] = useState(null); const [draft, setDraft] = useState({ summary: '', location: '' }); const [confirming, setConfirming] = useState(null); + const [draftNew, setDraftNew] = useState(null); if (loadingCollections) return ; if (!selected) return ; - if (error) return ; - if (isLoading) return ; - if (events.length === 0) return ; + + // 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[] }> = []; @@ -52,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.create(selected.path, toEvent(draftNew)).then((ok) => ok && setDraftNew(null)); + }; return ( -
-
- {days.map((day) => ( +
+
+
+
{selected.displayName}
+
+ {events.length} {events.length === 1 ? 'event' : 'events'} +
+
+ +
+ +
+
+ {/* 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 && ( +
+ setDraftNew({ ...draftNew, summary: e.target.value })} + placeholder="Title" + className={FIELD} + /> +
+ setDraftNew({ ...draftNew, date: e.target.value })} + className={`${FIELD} tabular-nums`} + /> + {!draftNew.allDay && ( + <> + setDraftNew({ ...draftNew, start: e.target.value })} + className={`${FIELD} tabular-nums`} + /> + to + setDraftNew({ ...draftNew, end: e.target.value })} + className={`${FIELD} tabular-nums`} + /> + + )} + +
+ setDraftNew({ ...draftNew, location: e.target.value })} + placeholder="Where" + className={`${FIELD} text-xs`} + /> +
+ + + {edit.error && {edit.error}} +
+
+ )} + + {notice && } + {days.map((day) => (

{formatDay(day.key)} @@ -184,7 +323,8 @@ export const CalendarView = () => { ))}

- ))} + ))} +
); diff --git a/web/dav/CollectionNav.tsx b/web/dav/CollectionNav.tsx index dcce31f..547e35d 100644 --- a/web/dav/CollectionNav.tsx +++ b/web/dav/CollectionNav.tsx @@ -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=, so a calendar is a bookmarkable address and the +// Rows are real links carrying ?collection=, 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 (
@@ -50,11 +50,11 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {