# 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.