summaryrefslogtreecommitdiff
path: root/pim/doc
diff options
context:
space:
mode:
Diffstat (limited to 'pim/doc')
-rw-r--r--pim/doc/design.md143
-rw-r--r--pim/doc/gotchas.md62
-rw-r--r--pim/doc/todo.md44
3 files changed, 249 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.
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/<uid>/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.