summaryrefslogtreecommitdiff
path: root/pim/doc/design.md
diff options
context:
space:
mode:
Diffstat (limited to 'pim/doc/design.md')
-rw-r--r--pim/doc/design.md143
1 files changed, 143 insertions, 0 deletions
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 <days>
+ 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/<uid>/ 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.