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 +++++++++++++++++++++++++++++++++++++++++++++++++++++ pim/doc/gotchas.md | 62 +++++++++++++++++++++++ pim/doc/todo.md | 44 +++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 pim/doc/design.md create mode 100644 pim/doc/gotchas.md create mode 100644 pim/doc/todo.md (limited to 'pim/doc') 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. diff --git a/pim/doc/gotchas.md b/pim/doc/gotchas.md new file mode 100644 index 0000000..cc3892a --- /dev/null +++ b/pim/doc/gotchas.md @@ -0,0 +1,62 @@ +# Go on 9front + +All verified on `lab.qcow2`, not inferred. + +## Go 1.24.x is broken on plan9/amd64 + +Every binary dies before `main`: + + M structure uses sizeclass 1792/0x700 bytes; incompatible with mutex flag mask 0x3ff + fatal error: runtime.m memory alignment too small for spinbit mutex + runtime.lockVerifyMSize() lock_spinbit.go:97 + +The spinbit mutex landed in 1.24 and `runtime.m` falls in a sizeclass that +cannot meet its alignment. Tested: **1.23.11 ok, 1.24.4 broken, 1.25.14 ok, +1.27.0 ok**. The host's installed Go is 1.24.4 -- precisely the broken one -- +so `mk.sh` pins `GOTOOLCHAIN=go1.27.0`. + +## TLS needs a CA bundle you supply + +Go's x509 looks only at `/sys/lib/tls/ca.pem` on plan9, and 9front ships +none: + + SystemCertPool ERR: open /sys/lib/tls/ca.pem: file does not exist + +Copy any bundle there and HTTPS works -- verified TLS 1.3 with full cert +verification. Already installed on `lab.qcow2`. + +## Networking is /net, not a helper program + +Go opens `/net/tcp/clone`, writes `connect`, and resolves through +`/net/cs` and `/net/dns` (`net/fd_plan9.go`, `net/ipsock_plan9.go`). No +`exec.Command`, no webfs. It is what `dial(2)` does. + +## No native graphics yet + +`9fans.net/go/draw` is a complete libdraw port and *builds* for +`GOOS=plan9`, but at runtime it does + + cmd := exec.Command(devdraw, os.Args[0], "(devdraw)") + +which is plan9port's helper. 9front has no `devdraw`. A native transport +means opening `/dev/draw` and reading `/dev/mouse`, `/dev/kbd` -- plain +file I/O, no cgo, a few hundred lines under an already-complete library. +Nobody has written it. + +## Building + +Cross-compile from Linux; do not put a toolchain on the guest. A native +plan9/amd64 toolchain builds fine via `bootstrap.bash` (needs a bootstrap +Go >= 1.24.6), but it is 249MB unpacked -- `compile` alone is 27MB -- +against a 3MB `ical/fs`. + +Getting binaries in, with an HTTP server on the host: + + hget http://10.0.2.2:8099/fs > /tmp/icalfs # qemu user-net host + +## The guest clock is skewed by the host's timezone + +`run.sh` passes `-rtc base=localtime`, so the guest's idea of *UTC* equals +the host's *local* time. With the host on EDT the guest is 4h behind real +UTC. Anything time-sensitive must be generated in the guest's frame -- +take `date -n` from the guest, not from the host. diff --git a/pim/doc/todo.md b/pim/doc/todo.md new file mode 100644 index 0000000..1e34157 --- /dev/null +++ b/pim/doc/todo.md @@ -0,0 +1,44 @@ +# ical/fs todo + +## Next + +- **rsvp**. `partstat` is not yet in the tree. It should be writable, and + the write should be the whole user interface: `echo ACCEPTED + >/mnt/pim/events/uuid//partstat`. Transport stays inside the server -- + iMIP mail via `upas/marshal` for the ics backend, a JMAP method call + for `jmap/fs`. `pim/rsvp` writes the file and knows nothing else. +- **plumb port for alarms**, following `seemail`. The blocking `alarm` + file is one-to-one; a plumb port gives fan-out so `pim/alertcat`, a + bell and a logger can all see the same alarm. +- **`pim/next`** -- print the next event, one line, for a window label. +- **the slug is lossy**. `Go Home LTD & Kissinger -> API` becomes + `Go-Home-LTD-_-Kissinger--_-API`; every non-alphanumeric collapses to + `_`, so names are ugly and not reversible. The summary is intact inside + the file, but the filename could be kinder. +- **`pim/free`** -- free/busy over a range. +- **plumb rules** -- click a date, `agenda` opens that day. + +## Backends + +- `jmap/fs` against Fastmail. JSCalendar is JSON, so no parser; the work + is OAuth2 and the method surface. No Go library implements JMAP + calendars -- `rockorager/go-jmap` is core+mail only. +- `caldav/fs` via `emersion/go-webdav`. Untested on plan9, but its + transport is `net/http`, which is verified working. + +## Known gaps + +- Absolute VALARM triggers (`TRIGGER;VALUE=DATE-TIME`) are ignored; only + relative ones fire. `RELATED=END`, `DURATION`+`REPEAT` unhandled. + Note Google's `basic.ics` exports **no VALARM at all**, so alarms need a + backend that carries them. +- Embedded `VTIMEZONE` definitions are ignored. `TZID=` is resolved by + IANA name through Go's `time/tzdata` instead, which is correct for + Google (`America/New_York`) but will fail on a server that emits + Windows-style zone names or a zone not in the IANA database. +- Only `STATUS:CANCELLED` cancels an occurrence. `METHOD:CANCEL` is not + handled. +- The whole tree is rebuilt on refresh. Fine at this size, not forever. +- Directory listing order from go9p is non-deterministic (it iterates a + map to build the child list). `ls` sorts, so it does not show, but do + not depend on order. -- cgit v1.2.3