summaryrefslogtreecommitdiff
path: root/pim/doc/design.md
blob: 98de04cb125e8b55f1330f9ab65b3966d95e3c91 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
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.