From 8a3d2c99bff60cb775ebc42516c0c3912d54ba3d Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Sat, 22 Aug 2026 13:16:49 -0400 Subject: pim: personal information management, starting with a calendar A calendar as a file tree, and tools that only know the tree: events/date/yyyy/mm/dd/hhmm-summary as lived events/uuid// as stored ctl query alarm changed lib/cal owns all of that. A backend supplies events and, where its protocol allows, takes changes back -- six methods. cmd/icalfs is the first: it reads .ics files from a directory and nothing else, because fetching is rc/fetch's job and hget already exists. That keeps net/http out of the binary and makes a subscribed calendar and a local one the same thing. The tools are rc on purpose. If the tree needs a compiled program to be useful, the tree is the wrong shape. Three things were added to the tree because the rc port needed them: a path from an occurrence to its event, epoch seconds beside RFC3339, and a numeric slot for all-day events so test(1) can compare it. ctl reports caps, so a tool can say "read only" instead of trying and failing. A published .ics is read only: nowhere to PUT, and no METHOD:REQUEST to reply to. CalDAV would be read write rsvp schedule, and that is the next backend. Co-Authored-By: Claude Opus 5 --- pim/doc/design.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 pim/doc/design.md (limited to 'pim/doc/design.md') diff --git a/pim/doc/design.md b/pim/doc/design.md new file mode 100644 index 0000000..98de04c --- /dev/null +++ b/pim/doc/design.md @@ -0,0 +1,143 @@ +# ical/fs + +A calendar as a file tree. The tree is the interface; the backend is not. + +## Why a file server + +Everything a calendar client does -- listing a day, expanding a recurring +event, waiting for an alarm -- is a file operation if you let it be. Put +the protocol in one process, publish a namespace, and every tool that can +`ls`, `cat` and `grep` is a calendar client. + +The corollary matters more: the namespace is what other programs depend +on, so a backend can be swapped without anything above noticing. `ical/fs` +reads local `.ics` files today. A `jmap/fs` posting the same tree is a +different binary, not a rewrite of everything that reads it. + +## The tree + + /mnt/pim/ + ctl read: state. write: refresh, window + alarm blocking read; one line per alarm fired + changed blocking read; one line per rebuild + query write a query, read the answer + events/ + date/YYYY/MM/DD/HHMM-summary + one file per occurrence, expanded + uuid// one directory per event, un-expanded + summary start end location description + rrule organizer attendees uid raw + +Both views live under `events/`, in their own sub-namespaces so that a +UID can never collide with the view. `date/` is the calendar as lived -- +recurrences already expanded, one file per occurrence, sorted by the +filename, and it is the path a person walks: `ls +/mnt/pim/events/date/2026/08/19` is your day. `uuid/` is the calendar as +stored, keyed for programs rather than people. + +Occurrence files carry a `key: value` header so tools can parse them +without knowing iCalendar: + + summary: Dinner + start: 2026-08-20T18:30:00Z + end: 2026-08-20T19:30:00Z + location: somewhere with a semicolon; here + uid: oneoff@test + +A blank line ends the header; anything after it is the description. + +## Decisions + +**Expansion lives in the fs, above the backend seam.** It is the single +most valuable thing the server does. Below the seam, every backend +reimplements it; above the fs, every consumer reimplements it badly. +Recurrence is expanded once, into a bounded window, and published as +files. + +**The window is bounded and explicit.** RRULE is unbounded, so eager +expansion is impossible. `ctl` carries the window; the default is 400 +days either side of now. + +**Occurrences are regular files, not symlinks.** 9P2000 has no symlinks. +Each occurrence file repeats what a reader needs so that `cat` on a day +is useful on its own. + +**The query file works like /net/cs.** Open it, write ndb-style +`attr=value` terms, read back one path per line. It answers the question +the tree is bad at -- "every event with this attendee" -- without +inventing a database or a second format. The tree already indexes time, +which is the dimension people actually ask about; `query` covers the +rest. + + % echo 'attendee=michael from=2026-08-19' >/mnt/pim/query + % cat /mnt/pim/query + +Holding one fd across the write and the read is the correct usage, as +with cs. But `echo >query; cat query` opens twice, and that is how it +will be used from rc, so the last answer is also served to a fid that has +none of its own. + +**A gui learns about new data from `changed`, not by polling.** A read +blocks until the tree has been rebuilt and then returns a line, so a +watcher re-walks only when there is something to re-walk. `-r` makes the +server re-fetch and reload on an interval; without it nothing refreshes +by itself and `echo refresh >ctl` is the only trigger. + +**The alarm file blocks; the plumber broadcasts.** A read of `alarm` +blocks until the next alarm is due. That is one-to-one -- the reader +consumes the event. Fan-out to several listeners belongs on a plumb port, +following the `seemail` precedent that `upas` and `faces` already use. +Not yet implemented. + +**Model on JSCalendar, not iCalendar.** iCalendar maps into JSCalendar +more easily than the reverse, so the tree should not encode iCalendar's +quirks -- folded lines, embedded VTIMEZONE -- into an interface meant to +outlive them. + +## Names + +`ical/` is the backend layer: `ical/fs` speaks iCalendar. A JMAP backend +would be `jmap/fs`, CalDAV `caldav/fs`. Binaries are named for the +protocol they speak. + +`pim/` is the tool layer: `pim/agenda` and friends know only the tree, and +work over whichever backend is mounted. + +`/mnt/pim` is the stable name the tools depend on. Not `cal/` -- `/bin/cal` +is a file, so `cal/fs` cannot exist as a path, and taking the name of a +forty-year-old tool that needs nothing, for a program that needs a +network, invites a comparison that is not worth having. Anyone who wants +it can `bind /bin/pim/agenda /bin/cal`. + +## Tested against a real calendar + +3419 VEVENTs, 6.6MB, from Google's `basic.ics` export. What that data +taught, which a hand-written fixture did not: + +- **1016 of 3419 UIDs are duplicates.** Google materialises occurrences of + a series as separate VEVENTs carrying `RECURRENCE-ID`. They must be + attached to the series they override, or they collide in `events/` and + double-count in `when/`. +- **An override must be emitted on its own terms**, not only when the + parent rule regenerates its time -- otherwise occurrences the rule no + longer produces are silently lost. That was 42 events here. +- **Occurrences must be filed by local wall-clock time.** Real calendars + mix zones freely: 1159 events carry `TZID=America/New_York`, 2178 are + plain UTC. Filing each under its own zone makes a day neither sort by + time nor contain the right events. +- **Names collide.** Two events in the same minute with the same summary + are ordinary. Every generated name is uniquified. +- **`time/tzdata` must be imported.** `TZID=` is resolved with + `time.LoadLocation`, and 9front has no zoneinfo tree, so without the + embedded copy every zoned event is silently mistimed. + +Fetch, parse and expand of the whole 6.6MB on the guest: 4.7s wall, +407ms of it parsing, 9ms expanding 3881 occurrences. + +## Written in Go + +The calendar problem is a parser fed by strangers. Go removes that entire +bug class, and `go-ical` and `rrule-go` remove most of the work: line +folding, escaping, RRULE with BYSETPOS, timezones through 2045 via +`time/tzdata`, all off the shelf. See `doc/gotchas.md` for what that +costs and what it takes to build. -- cgit v1.2.3