summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:49 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:49 -0400
commit8a3d2c99bff60cb775ebc42516c0c3912d54ba3d (patch)
treefd136a3b7927146763cdc11e6be07c71f92bb92c
parent9aacce8b3b54060d0037eca897856d7273e6e5e8 (diff)
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/<uid>/ 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 <noreply@anthropic.com>
-rw-r--r--pim/.gitignore3
-rw-r--r--pim/cmd/cal9/main.go1131
-rw-r--r--pim/cmd/cal9/menu.go267
-rw-r--r--pim/cmd/icalfs/config.go137
-rw-r--r--pim/cmd/icalfs/main.go222
-rw-r--r--pim/doc/design.md143
-rw-r--r--pim/doc/gotchas.md62
-rw-r--r--pim/doc/todo.md44
-rw-r--r--pim/go.mod17
-rw-r--r--pim/go.sum25
-rw-r--r--pim/lib/cal/backend.go41
-rw-r--r--pim/lib/cal/duration.go79
-rw-r--r--pim/lib/cal/ical.go317
-rw-r--r--pim/lib/cal/query.go175
-rw-r--r--pim/lib/cal/server.go325
-rw-r--r--pim/lib/cal/tree.go217
-rw-r--r--pim/man/ical.4269
-rwxr-xr-xpim/mk.sh22
-rwxr-xr-xpim/rc/agenda88
-rw-r--r--pim/rc/calendars38
-rwxr-xr-xpim/rc/fetch56
-rwxr-xr-xpim/rc/find77
-rwxr-xr-xpim/rc/month4
-rwxr-xr-xpim/rc/next81
-rwxr-xr-xpim/rc/show115
-rwxr-xr-xpim/rc/showwin10
-rwxr-xr-xpim/rc/today4
-rwxr-xr-xpim/rc/week4
-rwxr-xr-xpim/rc/who87
-rw-r--r--pim/test/cal/work.ics40
30 files changed, 4100 insertions, 0 deletions
diff --git a/pim/.gitignore b/pim/.gitignore
new file mode 100644
index 0000000..28b5357
--- /dev/null
+++ b/pim/.gitignore
@@ -0,0 +1,3 @@
+bin/
+cmd/*/icalfs
+cmd/*/cal9
diff --git a/pim/cmd/cal9/main.go b/pim/cmd/cal9/main.go
new file mode 100644
index 0000000..aa9a370
--- /dev/null
+++ b/pim/cmd/cal9/main.go
@@ -0,0 +1,1131 @@
+// cal: a calendar over the pim filesystem.
+//
+// Day, week and month views; < > step by the current unit; the wheel rolls
+// the time axis. There is no minimise button: rio has no iconify, so
+// "compact" is driven by how small you make the window instead.
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "9front/gui/draw"
+)
+
+const (
+ mtpt = "/mnt/pim"
+ fontpath = "/lib/font/bit/lucidasans/unicode.8.font"
+
+ pad = 6
+ gutter = 52 // hour-label column
+ spanMin = 11 * 60
+ compactH = 90 // below this window height, show the one-line view
+)
+
+type view int
+
+const (
+ vDay view = iota
+ vWeek
+ vMonth
+)
+
+var viewName = map[view]string{vDay: "Day", vWeek: "Week", vMonth: "Month"}
+
+// One colour per calendar. The fill is pale enough to take black text in
+// the day and week views; the ink is the same hue darkened, for the month
+// grid where events are text on the background rather than in a box.
+var calPalette = []struct{ fill, ink uint32 }{
+ {0x8888CCFF, 0x333388FF}, // purpleblue, the original event colour
+ {0x88CC88FF, 0x226622FF}, // green
+ {0xE0B080FF, 0x805000FF}, // tan
+ {0xCC8888FF, 0x883333FF}, // red
+ {0x88CCCCFF, 0x226666FF}, // cyan
+ {0xCCCC88FF, 0x666622FF}, // olive
+}
+
+type event struct {
+ min int // minutes past midnight
+ title string
+ file string
+ cal string // which calendar it came from
+}
+
+// selLine is one line of panel text, recorded so it can be selected.
+type selLine struct {
+ r draw.Rectangle
+ text string
+}
+
+// hit is a clickable region recorded during redraw.
+type hit struct {
+ r draw.Rectangle
+ do func(*state)
+}
+
+type state struct {
+ d *draw.Display
+ win *draw.Image
+ f *draw.Font
+ col map[string]*draw.Image
+ view view
+ cals []string // every calendar mounted under mtpt
+ on map[string]bool // the ones being shown
+ pick time.Time // the day whose list is in the panel; zero for none
+
+ // Text selection in the panel, by line. Character granularity would
+ // need the font metrics per glyph; whole lines are what you want to
+ // snarf out of a calendar anyway.
+ panelR draw.Rectangle
+ backAt draw.Rectangle
+ openAt draw.Rectangle
+ selLines []selLine
+ selA int // first selected line, -1 for none
+ selB int
+ at time.Time // the anchor day
+ top int // first visible minute, day/week views
+ evs map[string][]event
+ hits []hit
+ sel *event // the opened event, if any
+ mc *draw.Mousectl
+ quit bool
+}
+
+func now() time.Time { return time.Now() }
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ // -n: report what would be loaded and exit, so the calendar
+ // plumbing can be checked without a display
+ dry := false
+ want := vDay
+ args := os.Args[1:]
+ for len(args) > 0 {
+ switch {
+ case args[0] == "-n":
+ dry, args = true, args[1:]
+ case args[0] == "-v" && len(args) > 1:
+ switch args[1] {
+ case "day":
+ want = vDay
+ case "week":
+ want = vWeek
+ case "month":
+ want = vMonth
+ default:
+ return fmt.Errorf("usage: cal [-n] [-v day|week|month] [YYYY-MM-DD]")
+ }
+ args = args[2:]
+ default:
+ goto done
+ }
+ }
+done:
+
+ s := &state{view: want, at: time.Now(), top: 8 * 60, selA: -1, selB: -1}
+ s.cals = calendars()
+ s.on = map[string]bool{}
+ for _, c := range s.cals {
+ s.on[c] = true
+ }
+ if len(args) > 0 {
+ t, err := time.Parse("2006-01-02", args[0])
+ if err != nil {
+ return fmt.Errorf("usage: cal [-n] [-v day|week|month] [YYYY-MM-DD]")
+ }
+ s.at = t
+ }
+
+ if dry {
+ fmt.Printf("calendars: %v\n", s.cals)
+ for _, c := range s.cals {
+ evs, err := readDay(c, s.at)
+ fmt.Printf("%s: %d events on %s (err %v)\n",
+ c, len(evs), s.at.Format("2006-01-02"), err)
+ }
+ s.load()
+ k := s.at.Format("2006-01-02")
+ fmt.Printf("merged %s: %d\n", k, len(s.evs[k]))
+ for _, e := range s.evs[k] {
+ fmt.Printf(" %02d:%02d %-40s [%s]\n", e.min/60, e.min%60, e.title, e.cal)
+ }
+ return nil
+ }
+
+ var err error
+ if s.d, err = draw.Init("/dev"); err != nil {
+ return err
+ }
+ defer s.d.Close()
+ if s.f, err = s.d.OpenFont(fontpath); err != nil {
+ return err
+ }
+ s.col = map[string]*draw.Image{}
+ for k, v := range map[string]uint32{
+ "bg": 0xFFFFEAFF, // acme body
+ "tag": 0xEAFFFFFF, // acme tag
+ "rule": 0x99994CFF,
+ "ink": 0x000000FF,
+ "event": 0x8888CCFF, // DPurpleblue
+ "today": 0xFFFFAAFF, // DPaleyellow
+ "pick": 0xDDE4FFFF, // the anchor day, when it is not today
+ "snarf": 0xAAC4FFFF, // selected text, on its way to /dev/snarf
+ "now": 0xCC0000FF,
+ "border": 0x8888CCFF,
+ } {
+ if s.col[k], err = s.d.Color(v); err != nil {
+ return err
+ }
+ }
+ // a fill and an ink for every calendar, wrapping if there are more
+ // calendars than colours
+ for i := range s.cals {
+ p := calPalette[i%len(calPalette)]
+ if s.col[fmt.Sprintf("fill%d", i)], err = s.d.Color(p.fill); err != nil {
+ return err
+ }
+ if s.col[fmt.Sprintf("ink%d", i)], err = s.d.Color(p.ink); err != nil {
+ return err
+ }
+ }
+
+ if s.win, err = s.d.Window("/dev"); err != nil {
+ return err
+ }
+
+ s.load()
+ s.redraw()
+
+ mc, err := draw.OpenMouse("/dev")
+ if err != nil {
+ return err
+ }
+ defer mc.Close()
+ s.mc = mc
+
+ // Consume the keyboard. Without this rio keeps its line editor on the
+ // window and paints what you type over the drawing.
+ kb, err := draw.OpenKeyboard("/dev")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "cal: keyboard: %v\n", err)
+ } else {
+ defer kb.Close()
+ }
+ var keys <-chan rune
+ if kb != nil {
+ keys = kb.C
+ }
+
+ var wasDown, dragging bool
+ var pressed draw.Point
+ for {
+ select {
+ case m, ok := <-mc.C:
+ if !ok {
+ return nil
+ }
+ if m.Buttons&4 != 0 {
+ // Button 3 is the menu button on this system; Exit lives
+ // in there rather than owning the whole button.
+ s.menu(m.Point, mc)
+ if s.quit {
+ return nil
+ }
+ wasDown = false
+ continue
+ }
+ switch {
+ case m.Buttons&8 != 0: // wheel up
+ s.scroll(-30)
+ case m.Buttons&16 != 0: // wheel down
+ s.scroll(30)
+ }
+ down := m.Buttons&1 != 0
+ switch {
+ case down && !wasDown:
+ // A press inside the panel may be the start of a text
+ // selection, so it is not resolved until the release.
+ if i := s.lineAt(m.Point); i >= 0 && !s.inBack(m.Point) {
+ s.selA, s.selB = i, i
+ pressed = m.Point
+ dragging = true
+ s.redraw()
+ } else {
+ s.clearSel()
+ s.click(m.Point)
+ }
+ case down && dragging:
+ if i := s.lineAt(m.Point); i >= 0 && i != s.selB {
+ s.selB = i
+ s.redraw()
+ }
+ case !down && dragging:
+ dragging = false
+ if s.selA == s.selB {
+ // no drag: it was a click after all
+ s.clearSel()
+ s.click(pressed)
+ } else {
+ s.snarfSel()
+ }
+ }
+ wasDown = down
+ case r, ok := <-keys:
+ if !ok {
+ keys = nil
+ continue
+ }
+ switch r {
+ case 'q', 0x7F: // q or Del
+ return nil
+ case 't':
+ s.at = time.Now()
+ s.top = 8 * 60
+ s.load()
+ s.redraw()
+ case 'h':
+ s.step(-1)
+ case 'l':
+ s.step(1)
+ case 'd':
+ s.view = vDay
+ s.load()
+ s.redraw()
+ case 'w':
+ s.view = vWeek
+ s.load()
+ s.redraw()
+ case 'm':
+ s.view = vMonth
+ s.load()
+ s.redraw()
+ case 0x1B: // Esc clears whatever is showing
+ s.sel = nil
+ s.pick = time.Time{}
+ s.clearSel()
+ s.redraw()
+ }
+ case <-mc.Resize:
+ if s.win, err = s.d.Reattach("/dev", s.win); err != nil {
+ return err
+ }
+ s.redraw()
+ }
+ }
+}
+
+// step moves the anchor by one unit of the current view.
+func (s *state) step(n int) {
+ switch s.view {
+ case vDay:
+ s.at = s.at.AddDate(0, 0, n)
+ case vWeek:
+ s.at = s.at.AddDate(0, 0, 7*n)
+ case vMonth:
+ s.at = s.at.AddDate(0, n, 0)
+ }
+ s.load()
+ s.redraw()
+}
+
+// scroll rolls the time axis in day/week; in month it rolls whole weeks,
+// which is the only thing "up and down" can mean on a grid of days.
+func (s *state) scroll(mins int) {
+ if s.view == vMonth {
+ s.at = s.at.AddDate(0, 0, 7*sign(mins))
+ s.load()
+ s.redraw()
+ return
+ }
+ s.top += mins
+ if s.top < 0 {
+ s.top = 0
+ }
+ if s.top > 24*60-spanMin {
+ s.top = 24*60 - spanMin
+ }
+ s.redraw()
+}
+
+func sign(n int) int {
+ if n < 0 {
+ return -1
+ }
+ return 1
+}
+
+func (s *state) click(p draw.Point) {
+ for _, h := range s.hits {
+ if p.X >= h.r.Min.X && p.X < h.r.Max.X && p.Y >= h.r.Min.Y && p.Y < h.r.Max.Y {
+ h.do(s)
+ return
+ }
+ }
+}
+
+// ---------------------------------------------------------------- loading
+
+// days returns the days the current view covers.
+func (s *state) days() []time.Time {
+ switch s.view {
+ case vWeek:
+ start := s.at.AddDate(0, 0, -weekday(s.at))
+ out := make([]time.Time, 7)
+ for i := range out {
+ out[i] = start.AddDate(0, 0, i)
+ }
+ return out
+ case vMonth:
+ first := time.Date(s.at.Year(), s.at.Month(), 1, 0, 0, 0, 0, s.at.Location())
+ start := first.AddDate(0, 0, -weekday(first))
+ out := make([]time.Time, 42) // 6 weeks, the usual grid
+ for i := range out {
+ out[i] = start.AddDate(0, 0, i)
+ }
+ return out
+ }
+ return []time.Time{s.at}
+}
+
+func weekday(t time.Time) int { return int(t.Weekday()) }
+
+func (s *state) load() {
+ s.evs = map[string][]event{}
+ for _, d := range s.days() {
+ var all []event
+ for _, c := range s.cals {
+ if !s.on[c] {
+ continue
+ }
+ e, err := readDay(c, d)
+ if err != nil {
+ continue
+ }
+ all = append(all, e...)
+ }
+ if len(all) > 0 {
+ sort.Slice(all, func(i, j int) bool {
+ if all[i].min == all[j].min {
+ return all[i].title < all[j].title
+ }
+ return all[i].min < all[j].min
+ })
+ s.evs[d.Format("2006-01-02")] = all
+ }
+ }
+}
+
+// calendars names every live calendar. mntgen leaves the directory
+// behind when a server goes away, so a name only counts if something
+// still answers for ctl.
+func calendars() []string {
+ ents, err := os.ReadDir(path.Join(mtpt, "calendars"))
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, e := range ents {
+ n := e.Name()
+ if _, err := os.Stat(path.Join(mtpt, "calendars", n, "ctl")); err == nil {
+ out = append(out, n)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func readDay(cal string, t time.Time) ([]event, error) {
+ dir := path.Join(mtpt, "calendars", cal, "events", "date",
+ t.Format("2006"), t.Format("01"), t.Format("02"))
+ f, err := os.Open(dir)
+ if err != nil {
+ return nil, nil // a day with nothing on it is not an error
+ }
+ defer f.Close()
+ names, err := f.Readdirnames(-1)
+ if err != nil {
+ return nil, err
+ }
+ var evs []event
+ for _, n := range names {
+ if len(n) < 5 || n[4] != '-' {
+ continue
+ }
+ hh, e1 := strconv.Atoi(n[0:2])
+ mm, e2 := strconv.Atoi(n[2:4])
+ if e1 != nil || e2 != nil {
+ continue
+ }
+ ev := event{
+ min: hh*60 + mm,
+ title: strings.ReplaceAll(n[5:], "-", " "),
+ file: path.Join(dir, n),
+ cal: cal,
+ }
+ if t := summary(ev.file); t != "" {
+ ev.title = t
+ }
+ evs = append(evs, ev)
+ }
+ sort.Slice(evs, func(i, j int) bool { return evs[i].min < evs[j].min })
+ return evs, nil
+}
+
+// summary reads only the header block, not the whole body.
+func summary(file string) string {
+ f, err := os.Open(file)
+ if err != nil {
+ return ""
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := sc.Text()
+ if line == "" {
+ break
+ }
+ if v, ok := strings.CutPrefix(line, "summary:"); ok {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+// ---------------------------------------------------------------- drawing
+
+// fit truncates s to w pixels.
+// calKey names the colour registered for a calendar, falling back to the
+// generic event colour for anything that appeared since startup.
+func (s *state) calKey(what, cal string) string {
+ for i, c := range s.cals {
+ if c == cal {
+ k := fmt.Sprintf("%s%d", what, i)
+ if _, ok := s.col[k]; ok {
+ return k
+ }
+ }
+ }
+ if what == "fill" {
+ return "event"
+ }
+ return "ink"
+}
+
+func (s *state) fit(str string, w int32) string {
+ if s.f.Width(str) <= w {
+ return str
+ }
+ r := []rune(str)
+ for len(r) > 1 {
+ r = r[:len(r)-1]
+ if s.f.Width(string(r)+"..") <= w {
+ return string(r) + ".."
+ }
+ }
+ return ""
+}
+
+func (s *state) text(p draw.Point, c string, str string) {
+ s.f.String(s.win, p, s.col[c], str)
+}
+
+func (s *state) fill(r draw.Rectangle, c string) {
+ draw.Draw(s.win, r, s.col[c], nil, draw.ZP)
+}
+
+// button draws a labelled box and records its hit region.
+func (s *state) button(r draw.Rectangle, label string, on bool, do func(*state)) {
+ bg := "tag"
+ if on {
+ bg = "today"
+ }
+ s.fill(r, bg)
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+1), "border")
+ s.fill(draw.Rect(r.Min.X, r.Max.Y-1, r.Max.X, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Min.X+1, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Max.X-1, r.Min.Y, r.Max.X, r.Max.Y), "border")
+ w := s.f.Width(label)
+ s.text(draw.Point{X: r.Min.X + (r.Dx()-w)/2, Y: r.Min.Y + 3}, "ink", label)
+ s.hits = append(s.hits, hit{r, do})
+}
+
+func (s *state) redraw() {
+ s.hits = s.hits[:0]
+ r := s.win.Rect()
+ s.fill(r, "bg")
+
+ if r.Dy() < compactH {
+ s.drawCompact(r)
+ s.d.Flush()
+ return
+ }
+
+ hdr := s.drawHeader(r)
+ body := draw.Rect(r.Min.X, hdr.Max.Y+pad, r.Max.X, r.Max.Y-pad)
+ if (s.sel != nil || (!s.pick.IsZero() && s.view != vDay)) && body.Dy() > 160 {
+ split := body.Max.Y - body.Dy()*2/5
+ s.drawDetail(draw.Rect(body.Min.X, split, body.Max.X, body.Max.Y))
+ body.Max.Y = split - pad
+ }
+ switch s.view {
+ case vDay:
+ s.drawTimeGrid(body, []time.Time{s.at})
+ case vWeek:
+ s.drawTimeGrid(body, s.days())
+ case vMonth:
+ s.drawMonth(body)
+ }
+ s.d.Flush()
+}
+
+func (s *state) drawHeader(r draw.Rectangle) draw.Rectangle {
+ h := int32(s.f.Height) + 8
+ hdr := draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+h)
+ s.fill(hdr, "tag")
+
+ // Buttons, right to left: Month Week Day > <
+ x := r.Max.X - pad
+ mk := func(label string, on bool, do func(*state)) {
+ w := s.f.Width(label) + 16
+ br := draw.Rect(x-w, hdr.Min.Y+2, x, hdr.Max.Y-2)
+ s.button(br, label, on, do)
+ x = br.Min.X - 4
+ }
+ for _, v := range []view{vMonth, vWeek, vDay} {
+ vv := v
+ mk(viewName[v], s.view == v, func(st *state) {
+ st.view = vv
+ if vv == vDay {
+ st.pick = time.Time{}
+ }
+ st.load()
+ st.redraw()
+ })
+ }
+ x -= 6
+ mk(">", false, func(st *state) { st.step(1) })
+ mk("<", false, func(st *state) { st.step(-1) })
+ x -= 6
+ mk("Today", false, func(st *state) {
+ st.at = time.Now()
+ st.top = 8 * 60
+ st.load()
+ st.redraw()
+ })
+
+ s.text(draw.Point{X: r.Min.X + pad, Y: hdr.Min.Y + 4}, "ink", s.fit(s.title(), x-r.Min.X-pad*2))
+ return hdr
+}
+
+func (s *state) title() string {
+ switch s.view {
+ case vWeek:
+ d := s.days()
+ return d[0].Format("2 Jan") + " - " + d[6].Format("2 Jan 2006")
+ case vMonth:
+ return s.at.Format("January 2006")
+ }
+ return s.at.Format("Monday 2 January 2006")
+}
+
+// drawTimeGrid renders one or more day columns against an hour axis.
+func (s *state) drawTimeGrid(r draw.Rectangle, days []time.Time) {
+ top, bot := r.Min.Y+int32(s.f.Height)+4, r.Max.Y
+ if bot <= top {
+ return
+ }
+ pxPerMin := float64(bot-top) / float64(spanMin)
+ yOf := func(m int) int32 { return top + int32(float64(m-s.top)*pxPerMin) }
+
+ // Hour rules across the whole body.
+ for m := (s.top/60 + 1) * 60; m < s.top+spanMin; m += 60 {
+ y := yOf(m)
+ s.fill(draw.Rect(r.Min.X+gutter, y, r.Max.X-pad, y+1), "rule")
+ s.text(draw.Point{X: r.Min.X + pad, Y: y - int32(s.f.Height)/2}, "ink",
+ fmt.Sprintf("%02d:00", m/60))
+ }
+
+ colw := (r.Dx() - gutter - pad) / int32(len(days))
+ today := time.Now().Format("2006-01-02")
+ for i, d := range days {
+ x0 := r.Min.X + gutter + int32(i)*colw
+ cr := draw.Rect(x0, top, x0+colw-2, bot)
+ key := d.Format("2006-01-02")
+
+ if len(days) > 1 {
+ lab := d.Format("Mon 2")
+ c := "ink"
+ hdr := draw.Rect(cr.Min.X, r.Min.Y, cr.Max.X, top-2)
+ switch {
+ case !s.pick.IsZero() && key == s.pick.Format("2006-01-02"):
+ s.fill(hdr, "pick")
+ case key == time.Now().Format("2006-01-02"):
+ s.fill(hdr, "today")
+ }
+ s.text(draw.Point{X: cr.Min.X + 4, Y: r.Min.Y}, c, s.fit(lab, colw-8))
+ dd := d
+ s.hits = append(s.hits, hit{hdr, func(st *state) { st.pickDay(dd) }})
+ }
+
+ for _, e := range s.evs[key] {
+ if e.min < s.top || e.min > s.top+spanMin {
+ continue
+ }
+ y := yOf(e.min)
+ box := draw.Rect(cr.Min.X+2, y+1, cr.Max.X, y+int32(s.f.Height)+6)
+ if box.Max.Y > bot {
+ continue
+ }
+ s.fill(box, s.calKey("fill", e.cal))
+ lab := fmt.Sprintf("%02d:%02d %s", e.min/60, e.min%60, e.title)
+ s.text(draw.Point{X: box.Min.X + 4, Y: box.Min.Y + 2}, "ink",
+ s.fit(lab, box.Dx()-8))
+ ev := e
+ s.hits = append(s.hits, hit{box, func(st *state) {
+ st.sel = &ev
+ st.redraw()
+ }})
+ }
+
+ // Now line.
+ if key == today {
+ m := time.Now().Hour()*60 + time.Now().Minute()
+ if m >= s.top && m <= s.top+spanMin {
+ y := yOf(m)
+ s.fill(draw.Rect(cr.Min.X, y, cr.Max.X, y+2), "now")
+ }
+ }
+ }
+}
+
+// drawMonth renders a 6x7 grid of days with as many titles as fit.
+func (s *state) drawMonth(r draw.Rectangle) {
+ days := s.days()
+ cw := r.Dx() / 7
+ ch := r.Dy() / 6
+ if cw < 20 || ch < 20 {
+ return
+ }
+ today := time.Now().Format("2006-01-02")
+ picked := ""
+ if !s.pick.IsZero() {
+ picked = s.pick.Format("2006-01-02")
+ }
+ lh := int32(s.f.Height) + 1
+ // A day cell covers its events, and click takes the first hit, so
+ // these are appended only once every event hit is already down.
+ var dayHits []hit
+ for i, d := range days {
+ cx := r.Min.X + int32(i%7)*cw
+ cy := r.Min.Y + int32(i/7)*ch
+ cell := draw.Rect(cx, cy, cx+cw-2, cy+ch-2)
+ key := d.Format("2006-01-02")
+
+ switch {
+ case key == picked:
+ s.fill(cell, "pick")
+ case key == today:
+ s.fill(cell, "today")
+ }
+ if d.Month() != s.at.Month() {
+ // Outside the anchor month: leave it on the background.
+ s.fill(draw.Rect(cell.Min.X, cell.Min.Y, cell.Max.X, cell.Min.Y+1), "rule")
+ } else {
+ s.fill(draw.Rect(cell.Min.X, cell.Min.Y, cell.Max.X, cell.Min.Y+1), "rule")
+ }
+ s.text(draw.Point{X: cell.Min.X + 3, Y: cell.Min.Y + 2}, "ink", d.Format("2"))
+
+ y := cell.Min.Y + 2 + lh
+ for _, e := range s.evs[key] {
+ if y+lh > cell.Max.Y {
+ break
+ }
+ lab := fmt.Sprintf("%02d:%02d %s", e.min/60, e.min%60, e.title)
+ s.text(draw.Point{X: cell.Min.X + 3, Y: y}, s.calKey("ink", e.cal),
+ s.fit(lab, cell.Dx()-6))
+ ev := e
+ s.hits = append(s.hits, hit{
+ draw.Rect(cell.Min.X, y, cell.Max.X, y+lh),
+ func(st *state) { st.sel = &ev; st.redraw() },
+ })
+ y += lh
+ }
+
+ dd := d
+ dayHits = append(dayHits, hit{cell, func(st *state) { st.pickDay(dd) }})
+ }
+ s.hits = append(s.hits, dayHits...)
+}
+
+func min(a, b int) int { if a < b { return a }; return b }
+func max(a, b int) int { if a > b { return a }; return b }
+
+func (s *state) clearSel() { s.selA, s.selB = -1, -1 }
+
+// openCal starts another cal in its own window. A second view is a
+// second process here, not a second window inside this one.
+func (s *state) openCal(v view, d time.Time) {
+ s.wctl(fmt.Sprintf("new -dx 820 -dy 620 cal9 -v %s %s",
+ strings.ToLower(viewName[v]), d.Format("2006-01-02")))
+}
+
+// openEvent pins one event in a window of its own.
+func (s *state) openEvent(e *event) {
+ s.wctl(fmt.Sprintf("new -dx 540 -dy 360 pim/showwin %s", e.file))
+}
+
+// inBack reports whether a point is on the panel's Back button, which
+// must act as a button rather than start a selection.
+func (s *state) inBack(p draw.Point) bool {
+ for _, r := range []draw.Rectangle{s.backAt, s.openAt} {
+ if p.X >= r.Min.X && p.X < r.Max.X && p.Y >= r.Min.Y && p.Y < r.Max.Y {
+ return true
+ }
+ }
+ return false
+}
+
+// lineAt finds the panel line under a point, -1 if there is none.
+func (s *state) lineAt(p draw.Point) int {
+ for i, l := range s.selLines {
+ if p.X >= l.r.Min.X && p.X < l.r.Max.X && p.Y >= l.r.Min.Y && p.Y < l.r.Max.Y {
+ return i
+ }
+ }
+ return -1
+}
+
+// snarfSel puts the selected lines on /dev/snarf.
+func (s *state) snarfSel() {
+ if s.selA < 0 {
+ return
+ }
+ var b strings.Builder
+ for i := min(s.selA, s.selB); i <= max(s.selA, s.selB) && i < len(s.selLines); i++ {
+ b.WriteString(strings.TrimRight(s.selLines[i].text, " "))
+ b.WriteByte('\n')
+ }
+ if err := draw.Snarf("/dev", b.String()); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: snarf: %v\n", err)
+ }
+}
+
+// pickDay puts a whole day in the panel below. A month cell only has
+// room for the first few events, and this is how you see the rest without
+// losing the month.
+func (s *state) pickDay(d time.Time) {
+ s.pick = d
+ s.sel = nil
+ s.redraw()
+}
+
+// drawCompact is what you get by making the window small: the next thing
+// due. No minimise button, because rio has no iconify to hook one to.
+func (s *state) drawCompact(r draw.Rectangle) {
+ s.fill(r, "tag")
+ now := time.Now()
+ key := now.Format("2006-01-02")
+ cur := now.Hour()*60 + now.Minute()
+ var next *event
+ for i, e := range s.evs[key] {
+ if e.min >= cur {
+ next = &s.evs[key][i]
+ break
+ }
+ }
+ y := r.Min.Y + 2
+ if next == nil {
+ s.text(draw.Point{X: r.Min.X + pad, Y: y}, "ink",
+ s.fit("nothing else today", r.Dx()-pad*2))
+ return
+ }
+ in := next.min - cur
+ s.text(draw.Point{X: r.Min.X + pad, Y: y}, "ink",
+ s.fit(fmt.Sprintf("%02d:%02d %s", next.min/60, next.min%60, next.title), r.Dx()-pad*2))
+ s.text(draw.Point{X: r.Min.X + pad, Y: y + int32(s.f.Height) + 2}, "ink",
+ s.fit(fmt.Sprintf("in %d min", in), r.Dx()-pad*2))
+}
+
+// drawDetail shows the opened event. The date tree carries only the basics;
+// everything else -- attendees, organizer, location, the full description --
+// lives in the per-event directory in the uuid tree, which the "event:"
+// header points at relative to the day directory.
+func (s *state) drawDetail(r draw.Rectangle) {
+ s.fill(r, "tag")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+1), "border")
+ s.panelR = r
+ s.selLines = s.selLines[:0]
+
+ y := r.Min.Y + 4
+ lh := int32(s.f.Height) + 2
+ line := func(c, str string) {
+ if y+lh > r.Max.Y {
+ return
+ }
+ lr := draw.Rect(r.Min.X, y, r.Max.X, y+lh)
+ i := len(s.selLines)
+ if s.selA >= 0 && i >= min(s.selA, s.selB) && i <= max(s.selA, s.selB) {
+ s.fill(lr, "snarf")
+ }
+ s.selLines = append(s.selLines, selLine{lr, str})
+ s.text(draw.Point{X: r.Min.X + pad, Y: y}, c, s.fit(str, r.Dx()-pad*2))
+ y += lh
+ }
+
+ // A real button, like the ones in the header, rather than a line of
+ // text that happens to be clickable.
+ // Back, and Open beside it. Open means "put this in a window of its
+ // own and leave it there".
+ bar := func(back func(*state), open func(*state)) {
+ x := r.Min.X + pad
+ mk := func(label string, do func(*state)) draw.Rectangle {
+ bw := s.f.Width(label) + 20
+ br := draw.Rect(x, y, x+bw, y+lh+2)
+ s.button(br, label, false, do)
+ x = br.Max.X + 6
+ return br
+ }
+ s.backAt = mk("Back", back)
+ s.openAt = mk("Open", open)
+ y = s.openAt.Max.Y + 4
+ }
+
+ // A day was clicked and no single event chosen: list the day.
+ if s.sel == nil {
+ key := s.pick.Format("2006-01-02")
+ evs := s.evs[key]
+ // Back from a day means no panel at all; the month is behind it.
+ d := s.pick
+ bar(func(st *state) { st.pick = time.Time{}; st.clearSel(); st.redraw() },
+ func(st *state) { st.openCal(vDay, d) })
+ line("ink", s.pick.Format("Monday 2 January 2006")+
+ fmt.Sprintf(" (%d)", len(evs)))
+ y += 3
+ for _, e := range evs {
+ ev := e
+ top := y
+ lab := fmt.Sprintf("%02d:%02d %s", e.min/60, e.min%60, e.title)
+ if e.min == 0 && strings.HasPrefix(path.Base(e.file), "0000-allday-") {
+ lab = "all day " + e.title
+ }
+ line(s.calKey("ink", e.cal), lab)
+ if y > top {
+ s.hits = append(s.hits, hit{
+ draw.Rect(r.Min.X, top, r.Max.X, y),
+ func(st *state) { st.sel = &ev; st.redraw() },
+ })
+ }
+ }
+ if len(evs) == 0 {
+ line("ink", "nothing")
+ }
+ return
+ }
+
+ e := s.sel
+ hdrs, body := readEvent(e.file)
+
+ // A way back up. Coming from a month cell there is otherwise nothing
+ // to click but the menu, and no sign of which day this was.
+ day, haveDay := dayOf(hdrs["start"])
+ if haveDay && s.view != vDay {
+ d := day
+ ev := e
+ bar(func(st *state) { st.pick = d; st.sel = nil; st.clearSel(); st.redraw() },
+ func(st *state) { st.openEvent(ev) })
+ line("ink", d.Format("Monday 2 January 2006"))
+ y += 3
+ } else {
+ // In day view the grid behind is already the whole day, so
+ // there is nothing to go back to but the grid itself.
+ ev := e
+ bar(func(st *state) {
+ st.sel = nil
+ st.pick = time.Time{}
+ st.clearSel()
+ st.redraw()
+ }, func(st *state) { st.openEvent(ev) })
+ if haveDay {
+ line("ink", day.Format("Monday 2 January 2006"))
+ y += 3
+ }
+ }
+
+ line("ink", hdrs["summary"])
+ if st, en := hdrs["start"], hdrs["end"]; st != "" {
+ when := clock(st) + " - " + clock(en)
+ if !haveDay {
+ when = st
+ }
+ if hdrs["rrule"] != "" {
+ when += " (repeats)"
+ }
+ line("ink", when)
+ }
+
+ det := uuidDir(e.file, hdrs["event"])
+ if loc := strings.TrimSpace(field(det, "location")); loc != "" {
+ line("ink", "at "+loc)
+ }
+ if org := strings.TrimSpace(field(det, "organizer")); org != "" {
+ line("ink", "organizer: "+org)
+ }
+ if att := attendees(det); len(att) > 0 {
+ y += 3
+ line("ink", fmt.Sprintf("%d attendees", len(att)))
+ for _, a := range att {
+ line("ink", " "+a)
+ }
+ }
+
+ if strings.TrimSpace(body) == "" {
+ body = field(det, "description")
+ }
+ if strings.TrimSpace(body) != "" {
+ y += 3
+ for _, w := range wrap(s, body, r.Dx()-pad*2) {
+ line("ink", w)
+ }
+ }
+}
+
+// uuidDir resolves the "event:" cross-link, which is relative to the day
+// directory the event file sits in.
+func uuidDir(eventFile, link string) string {
+ if link == "" {
+ return ""
+ }
+ return path.Clean(path.Join(path.Dir(eventFile), link))
+}
+
+// field reads one file out of the event's uuid directory.
+func field(dir, name string) string {
+ if dir == "" {
+ return ""
+ }
+ b, err := os.ReadFile(path.Join(dir, name))
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+// attendees renders the tab-separated STATUS/name/email rows compactly, with
+// the status as a leading mark so a long list still scans.
+func attendees(dir string) []string {
+ raw := field(dir, "attendees")
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ mark := map[string]string{
+ "ACCEPTED": "+",
+ "DECLINED": "-",
+ "TENTATIVE": "~",
+ "NEEDS-ACTION": "?",
+ }
+ var out []string
+ for _, ln := range strings.Split(strings.TrimRight(raw, "\n"), "\n") {
+ f := strings.Split(ln, "\t")
+ if len(f) == 0 || strings.TrimSpace(ln) == "" {
+ continue
+ }
+ st := strings.TrimSpace(f[0])
+ m, ok := mark[st]
+ if !ok {
+ m = "."
+ }
+ who := st
+ if len(f) > 1 && strings.TrimSpace(f[1]) != "" {
+ who = strings.TrimSpace(f[1])
+ } else if len(f) > 2 {
+ who = strings.TrimSpace(f[2])
+ }
+ out = append(out, m+" "+who)
+ }
+ return out
+}
+
+// clock pulls HH:MM out of an RFC3339 timestamp without parsing it; the
+// filesystem already guarantees the shape.
+// dayOf returns the date part of a start header as a time, so the event
+// panel can say which day it is on and offer a way back to that day.
+func dayOf(ts string) (time.Time, bool) {
+ if len(ts) < 10 {
+ return time.Time{}, false
+ }
+ t, err := time.ParseInLocation("2006-01-02", ts[:10], time.Local)
+ if err != nil {
+ return time.Time{}, false
+ }
+ return t, true
+}
+
+func clock(ts string) string {
+ if i := strings.IndexByte(ts, 'T'); i >= 0 && len(ts) >= i+6 {
+ return ts[i+1 : i+6]
+ }
+ return ts
+}
+
+// readEvent splits the header block from the body.
+func readEvent(file string) (map[string]string, string) {
+ h := map[string]string{}
+ b, err := os.ReadFile(file)
+ if err != nil {
+ return h, ""
+ }
+ txt := string(b)
+ i := strings.Index(txt, "\n\n")
+ head, body := txt, ""
+ if i >= 0 {
+ head, body = txt[:i], txt[i+2:]
+ }
+ for _, ln := range strings.Split(head, "\n") {
+ if k, v, ok := strings.Cut(ln, ":"); ok {
+ h[strings.TrimSpace(k)] = strings.TrimSpace(v)
+ }
+ }
+ return h, body
+}
+
+// wrap breaks text to fit w pixels, keeping existing line breaks.
+func wrap(s *state, text string, w int32) []string {
+ var out []string
+ for _, para := range strings.Split(text, "\n") {
+ if strings.TrimSpace(para) == "" {
+ out = append(out, "")
+ continue
+ }
+ cur := ""
+ for _, word := range strings.Fields(para) {
+ try := word
+ if cur != "" {
+ try = cur + " " + word
+ }
+ if s.f.Width(try) <= w {
+ cur = try
+ continue
+ }
+ if cur != "" {
+ out = append(out, cur)
+ }
+ cur = word
+ }
+ if cur != "" {
+ out = append(out, cur)
+ }
+ }
+ return out
+}
diff --git a/pim/cmd/cal9/menu.go b/pim/cmd/cal9/menu.go
new file mode 100644
index 0000000..4443b00
--- /dev/null
+++ b/pim/cmd/cal9/menu.go
@@ -0,0 +1,267 @@
+package main
+
+import (
+ "fmt"
+ "time"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "9front/gui/draw"
+)
+
+// A button-3 menu, the way page and vdir do it: press 3, drag, release on an
+// item. acme's plumb-on-3 is the outlier on this system, so plumbing lives
+// in here as an item rather than owning the button.
+type menuItem struct {
+ label string
+ do func(*state)
+}
+
+func (s *state) menu(at draw.Point, mc *draw.Mousectl) {
+ items := s.menuItems()
+ if len(items) == 0 {
+ return
+ }
+
+ lh := int32(s.f.Height) + 4
+ var w int32
+ for _, it := range items {
+ if x := s.f.Width(it.label) + 20; x > w {
+ w = x
+ }
+ }
+ h := lh*int32(len(items)) + 4
+ win := s.win.Rect()
+ r := draw.Rect(at.X, at.Y, at.X+w, at.Y+h)
+ // Keep it on screen.
+ if r.Max.X > win.Max.X {
+ r = r.Add(draw.Point{X: win.Max.X - r.Max.X})
+ }
+ if r.Max.Y > win.Max.Y {
+ r = r.Add(draw.Point{Y: win.Max.Y - r.Max.Y})
+ }
+
+ sel := -1
+ paint := func() {
+ s.fill(r, "tag")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+1), "border")
+ s.fill(draw.Rect(r.Min.X, r.Max.Y-1, r.Max.X, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Min.X+1, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Max.X-1, r.Min.Y, r.Max.X, r.Max.Y), "border")
+ for i, it := range items {
+ ir := draw.Rect(r.Min.X+1, r.Min.Y+2+int32(i)*lh, r.Max.X-1, r.Min.Y+2+int32(i+1)*lh)
+ if i == sel {
+ s.fill(ir, "today")
+ }
+ s.text(draw.Point{X: ir.Min.X + 8, Y: ir.Min.Y + 2}, "ink", it.label)
+ }
+ s.d.Flush()
+ }
+ itemAt := func(p draw.Point) int {
+ if p.X < r.Min.X || p.X >= r.Max.X {
+ return -1
+ }
+ i := int((p.Y - r.Min.Y - 2) / lh)
+ if i < 0 || i >= len(items) || p.Y < r.Min.Y+2 {
+ return -1
+ }
+ return i
+ }
+ paint()
+
+ // Track until button 3 comes back up.
+ for m := range mc.C {
+ if n := itemAt(m.Point); n != sel {
+ sel = n
+ paint()
+ }
+ if m.Buttons&4 == 0 {
+ if sel >= 0 {
+ items[sel].do(s)
+ } else {
+ s.redraw()
+ }
+ return
+ }
+ }
+}
+
+func (s *state) menuItems() []menuItem {
+ items := []menuItem{}
+ if s.sel != nil {
+ items = append(items, menuItem{"Plumb", func(st *state) {
+ if err := st.plumb(st.sel); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: plumb: %v\n", err)
+ }
+ st.redraw()
+ }})
+ items = append(items, menuItem{"Open event", func(st *state) {
+ st.openEvent(st.sel)
+ }})
+ items = append(items, menuItem{"Close event", func(st *state) {
+ st.sel = nil
+ st.redraw()
+ }})
+ }
+ if s.sel == nil && !s.pick.IsZero() {
+ items = append(items, menuItem{"Close day", func(st *state) {
+ st.pick = time.Time{}
+ st.redraw()
+ }})
+ }
+ // one toggle per calendar, so a crowded work calendar can be put
+ // aside without unmounting anything
+ for _, c := range s.cals {
+ c := c
+ mark := "[ ] "
+ if s.on[c] {
+ mark = "[x] "
+ }
+ items = append(items, menuItem{mark + c, func(st *state) {
+ st.on[c] = !st.on[c]
+ st.load()
+ st.redraw()
+ }})
+ }
+ // A second view of the calendar is a second cal, in its own window.
+ items = append(items, menuItem{"Open " + strings.ToLower(viewName[s.view]),
+ func(st *state) { st.openCal(st.view, st.at) }})
+ if !s.pick.IsZero() && s.view != vDay {
+ d := s.pick
+ items = append(items, menuItem{"Open " + d.Format("2 Jan"),
+ func(st *state) { st.openCal(vDay, d) }})
+ }
+ items = append(items,
+ menuItem{"Today", func(st *state) {
+ st.at = now()
+ st.top = 8 * 60
+ st.load()
+ st.redraw()
+ }},
+ // rio has no iconify, so "compact" is a resize request: shrink the
+ // window and the small-window view takes over by itself.
+ menuItem{"Compact", func(st *state) {
+ r := st.win.Rect()
+ st.wctl(fmt.Sprintf("resize -r %d %d %d %d",
+ r.Min.X, r.Min.Y, r.Min.X+320, r.Min.Y+72))
+ }},
+ menuItem{"Restore", func(st *state) {
+ r := st.win.Rect()
+ st.wctl(fmt.Sprintf("resize -r %d %d %d %d",
+ r.Min.X, r.Min.Y, r.Min.X+820, r.Min.Y+620))
+ }},
+ menuItem{"Move", func(st *state) { st.track(st.mc, false) }},
+ menuItem{"Resize", func(st *state) { st.track(st.mc, true) }},
+ menuItem{"Hide", func(st *state) { st.wctl("hide") }},
+ menuItem{"Exit", func(st *state) { st.quit = true }},
+ )
+ return items
+}
+
+// wctl asks rio to do something to our window. Errors are worth showing:
+// outside rio there is no wctl and the menu items simply do nothing.
+func (s *state) wctl(cmd string) {
+ f, err := os.OpenFile("/dev/wctl", os.O_WRONLY, 0)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "cal: wctl: %v\n", err)
+ return
+ }
+ defer f.Close()
+ if _, err := f.WriteString(cmd); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: wctl %q: %v\n", cmd, err)
+ }
+}
+
+var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
+
+// plumb sends the event's join link to the plumber, falling back to the
+// event file itself, which lands in acme.
+func (s *state) plumb(e *event) error {
+ data := e.file
+ if body, err := os.ReadFile(e.file); err == nil {
+ if m := urlRe.Find(body); m != nil {
+ data = strings.TrimRight(string(m), ".,)")
+ }
+ }
+ return exec.Command("/bin/plumb", data).Run()
+}
+
+// track implements Move and Resize ourselves. rio's wctl has no "let the
+// user sweep" verb -- only move/resize with an explicit rectangle -- so we
+// follow the pointer and write a new rect as it goes, and rio does the
+// actual work. Any button press drops the window where it is.
+//
+// rio hands out a new image whenever the window's screen rect changes, so
+// this loop has to service resize events as well as motion, or every write
+// would be drawing into a stale image.
+func (s *state) track(mc *draw.Mousectl, resize bool) {
+ r := s.win.Rect()
+ w, h := r.Dx(), r.Dy()
+ grab := draw.Point{} // pointer offset within the window, for Move
+ first := true
+ last := r
+
+ for {
+ select {
+ case m, ok := <-mc.C:
+ if !ok {
+ return
+ }
+ if first {
+ grab = draw.Point{X: m.X - r.Min.X, Y: m.Y - r.Min.Y}
+ first = false
+ continue
+ }
+ if m.Buttons != 0 { // any press drops it
+ s.redraw()
+ return
+ }
+ var nr draw.Rectangle
+ if resize {
+ nr = draw.Rect(r.Min.X, r.Min.Y, m.X, m.Y)
+ if nr.Dx() < 120 {
+ nr.Max.X = nr.Min.X + 120
+ }
+ if nr.Dy() < 60 {
+ nr.Max.Y = nr.Min.Y + 60
+ }
+ } else {
+ min := draw.Point{X: m.X - grab.X, Y: m.Y - grab.Y}
+ nr = draw.Rect(min.X, min.Y, min.X+w, min.Y+h)
+ }
+ // Only bother rio when it would actually change something.
+ if abs(nr.Min.X-last.Min.X)+abs(nr.Min.Y-last.Min.Y)+
+ abs(nr.Max.X-last.Max.X)+abs(nr.Max.Y-last.Max.Y) < 4 {
+ continue
+ }
+ last = nr
+ verb := "move"
+ if resize {
+ verb = "resize"
+ }
+ s.wctl(fmt.Sprintf("%s -r %d %d %d %d", verb,
+ nr.Min.X, nr.Min.Y, nr.Max.X, nr.Max.Y))
+
+ case <-mc.Resize:
+ win, err := s.d.Reattach("/dev", s.win)
+ if err != nil {
+ return
+ }
+ s.win = win
+ r = s.win.Rect()
+ if !resize {
+ w, h = r.Dx(), r.Dy()
+ }
+ s.redraw()
+ }
+ }
+}
+
+func abs(n int32) int32 {
+ if n < 0 {
+ return -n
+ }
+ return n
+}
diff --git a/pim/cmd/icalfs/config.go b/pim/cmd/icalfs/config.go
new file mode 100644
index 0000000..b081743
--- /dev/null
+++ b/pim/cmd/icalfs/config.go
@@ -0,0 +1,137 @@
+package main
+
+import (
+ "pim/lib/cal"
+
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+// A Cal is one calendar the server keeps current.
+//
+// The config is per-user, in ndb's attribute-pair syntax, one calendar
+// per line:
+//
+// cal=work me=you@work.example
+// cal=home me=you@home.example,alias@home.example
+//
+// In iTIP your identity is the mailto: in your own ATTENDEE line -- there
+// is no separate field for it -- so a calendar has to be told whose it
+// is before anything can reply on its behalf. Aliases are listed because
+// you may be invited at one address and send from another.
+//
+// Fetching is not this server's business. A subscribed calendar is a
+// file somebody else wrote; see pim/fetch(1).
+//
+// It lives in $home/lib/pim by default. The urls of private calendars
+// are secrets, which is the other reason they belong in a file rather
+// than in argv where ps(1) would show them.
+type Cal struct {
+ Name string
+ Refresh time.Duration // how often to re-stat, if the config says
+ File string
+ Me []string // the addresses that count as you on this calendar
+
+ mu sync.Mutex
+ last time.Time
+ err string
+}
+
+func (c *Cal) status() (time.Time, string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.last, c.err
+}
+
+func (c *Cal) note(t time.Time, err error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.last = t
+ if err != nil {
+ c.err = err.Error()
+ } else {
+ c.err = ""
+ }
+}
+
+// readConfig parses the calendar list. Blank lines and lines beginning
+// with # are ignored; everything else is a tuple of attr=value pairs.
+func readConfig(path, dir string) ([]*Cal, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var cals []*Cal
+ for n, line := range strings.Split(string(b), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ c := &Cal{Refresh: 15 * time.Minute}
+ for _, f := range strings.Fields(line) {
+ k, v, ok := strings.Cut(f, "=")
+ if !ok {
+ return nil, fmt.Errorf("%s:%d: %q is not attr=value", path, n+1, f)
+ }
+ v = strings.Trim(v, `"'`)
+ switch k {
+ case "cal":
+ c.Name = v
+ case "url":
+ // fetching moved out; the url belongs to the script
+ // that writes the file
+ cal.Warnf("%s:%d: url= is ignored, see pim/fetch", path, n+1)
+ case "me":
+ for _, a := range strings.Split(v, ",") {
+ if a = strings.TrimSpace(a); a != "" {
+ c.Me = append(c.Me, a)
+ }
+ }
+ case "refresh":
+ d, err := time.ParseDuration(v)
+ if err != nil || d <= 0 {
+ return nil, fmt.Errorf("%s:%d: bad refresh %q", path, n+1, v)
+ }
+ c.Refresh = d
+ default:
+ return nil, fmt.Errorf("%s:%d: unknown attribute %q", path, n+1, k)
+ }
+ }
+ if c.Name == "" {
+ return nil, fmt.Errorf("%s:%d: no cal= name", path, n+1)
+ }
+ c.File = filepath.Join(dir, c.Name+".ics")
+ cals = append(cals, c)
+ }
+ if len(cals) == 0 {
+ return nil, fmt.Errorf("%s: no calendars", path)
+ }
+ return cals, nil
+}
+
+// pick selects one calendar by name. A server serves exactly one; the
+// config lists them all so that whatever starts them has a single place
+// to read.
+func pick(cals []*Cal, name string) (*Cal, error) {
+ if name == "" {
+ if len(cals) == 1 {
+ return cals[0], nil
+ }
+ var names []string
+ for _, c := range cals {
+ names = append(names, c.Name)
+ }
+ return nil, fmt.Errorf("which calendar? -N one of: %s",
+ strings.Join(names, " "))
+ }
+ for _, c := range cals {
+ if c.Name == name {
+ return c, nil
+ }
+ }
+ return nil, fmt.Errorf("no calendar named %q", name)
+}
diff --git a/pim/cmd/icalfs/main.go b/pim/cmd/icalfs/main.go
new file mode 100644
index 0000000..c155252
--- /dev/null
+++ b/pim/cmd/icalfs/main.go
@@ -0,0 +1,222 @@
+// ical/fs serves a directory of .ics files as a 9p file system.
+//
+// It does not fetch anything. A subscribed calendar is a file somebody
+// else wrote -- pim/fetch(1), a svc entry, an editor -- which is why a
+// local calendar and a subscribed one are the same thing here. Writing
+// a file and poking ctl is the whole interface for keeping it current.
+//
+// It is one backend behind pim/lib/cal, which owns the tree. Files on
+// disk are read-only as far as scheduling goes: there is nowhere to PUT
+// and no METHOD:REQUEST to reply to, so this backend reports "read" and
+// nothing above it has to guess.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "pim/lib/cal"
+)
+
+func fatal(format string, a ...interface{}) {
+ cal.Warnf(format, a...)
+ os.Exit(1)
+}
+
+// backend reads a directory of .ics files.
+type backend struct {
+ c *Cal // nil when serving -d alone
+ dir string
+ poll time.Duration
+
+ mu sync.Mutex
+ evs []*cal.Event
+ sig string // what the directory looked like when last loaded
+ last time.Time
+ err string
+}
+
+func (b *backend) Name() string {
+ if b.c != nil {
+ return b.c.Name
+ }
+ return "calendar"
+}
+
+// A published feed can only be read. See pim/doc/design.md.
+func (b *backend) Caps() string { return "read" }
+
+// Refresh is how often to re-stat the directory. Whoever writes the
+// files should poke ctl instead; this only catches a hand edit.
+func (b *backend) Refresh() time.Duration { return b.poll }
+
+func (b *backend) Status() (time.Time, string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.last, b.err
+}
+
+func (b *backend) Describe() string {
+ s := fmt.Sprintf("dir %s\n", b.dir)
+ if b.c != nil && len(b.c.Me) > 0 {
+ s += "me " + joinComma(b.c.Me) + "\n"
+ }
+ return s
+}
+
+// Sync reloads if the directory has changed. Name, size and mtime are
+// enough to notice: a rewrite that keeps all three identical is a
+// rewrite of identical content.
+func (b *backend) Sync() (bool, error) {
+ sig, err := dirsig(b.dir)
+ b.note(time.Now(), err)
+ if err != nil {
+ return false, err
+ }
+ b.mu.Lock()
+ same := sig == b.sig && b.evs != nil
+ b.mu.Unlock()
+ if same {
+ return false, nil
+ }
+ evs, err := cal.LoadDir(b.dir)
+ if err != nil {
+ return false, err
+ }
+ b.mu.Lock()
+ b.evs, b.sig = evs, sig
+ b.mu.Unlock()
+ return true, nil
+}
+
+// dirsig summarises every .ics in dir.
+func dirsig(dir string) (string, error) {
+ names, err := filepath.Glob(filepath.Join(dir, "*.ics"))
+ if err != nil {
+ return "", err
+ }
+ sort.Strings(names)
+ var b strings.Builder
+ for _, n := range names {
+ fi, err := os.Stat(n)
+ if err != nil {
+ continue
+ }
+ fmt.Fprintf(&b, "%s:%d:%d\n", n, fi.Size(), fi.ModTime().UnixNano())
+ }
+ return b.String(), nil
+}
+
+func (b *backend) Events() ([]*cal.Event, error) {
+ b.mu.Lock()
+ evs := b.evs
+ b.mu.Unlock()
+ if evs != nil {
+ return evs, nil
+ }
+ evs, err := cal.LoadDir(b.dir)
+ if err != nil {
+ return nil, err
+ }
+ b.mu.Lock()
+ b.evs = evs
+ b.mu.Unlock()
+ return evs, nil
+}
+
+func (b *backend) note(t time.Time, err error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.last = t
+ if err != nil {
+ b.err = err.Error()
+ } else {
+ b.err = ""
+ }
+}
+
+func joinComma(a []string) string {
+ s := ""
+ for i, x := range a {
+ if i > 0 {
+ s += ","
+ }
+ s += x
+ }
+ return s
+}
+
+func main() {
+ cal.Argv0 = "ical/fs"
+ var (
+ dir = flag.String("d", ".", "directory of .ics files")
+ name = flag.String("s", "", "service name to post in /srv (default ical.$user.$pid)")
+ days = flag.Int("w", 400, "expansion window in days")
+ dry = flag.Bool("n", false, "load and report, do not serve")
+ conf = flag.String("c", "", "calendar config (default $home/lib/pim)")
+ which = flag.String("N", "", "which calendar in the config to serve")
+ poll = flag.Duration("r", 0, "re-stat the directory this often (0: only on ctl refresh)")
+ )
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr,
+ "usage: ical/fs [-n] [-c config] [-N name] [-d dir] [-s srv] [-w days] [-r poll]\n")
+ os.Exit(2)
+ }
+ flag.Parse()
+
+ user := os.Getenv("user")
+ if user == "" {
+ user = "glenda"
+ }
+ if *name == "" {
+ // as rio(1) and plumb(1) name theirs, so several may run at once
+ *name = fmt.Sprintf("ical.%s.%d", user, os.Getpid())
+ }
+
+ path := *conf
+ if path == "" {
+ path = filepath.Join(os.Getenv("home"), "lib", "pim")
+ if _, err := os.Stat(path); err != nil {
+ path = "" // no config is fine: -d alone works
+ }
+ }
+
+ // One server serves one calendar; several calendars means several
+ // servers, each mounted at its own name under /mnt/pim/calendars.
+ be := &backend{dir: *dir, poll: *poll}
+ if *which != "" {
+ // a name even without a config: -o still wants to be called
+ // something other than "calendar" under calendars/
+ be.c = &Cal{Name: *which}
+ }
+ if path != "" {
+ cals, err := readConfig(path, *dir)
+ if err != nil {
+ fatal("%v", err)
+ }
+ if be.c, err = pick(cals, *which); err != nil {
+ fatal("%v", err)
+ }
+ }
+
+ s := cal.New(be, cal.Config{
+ User: user, Srv: *name, Conf: path,
+ Window: time.Duration(*days) * 24 * time.Hour,
+ })
+
+ if *dry {
+ if err := s.Report(); err != nil {
+ fatal("%v", err)
+ }
+ return
+ }
+ if err := s.Serve(); err != nil {
+ fatal("%v", err)
+ }
+}
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.
diff --git a/pim/go.mod b/pim/go.mod
new file mode 100644
index 0000000..242ba7b
--- /dev/null
+++ b/pim/go.mod
@@ -0,0 +1,17 @@
+module pim
+
+go 1.23
+
+// cal9 draws through the pure-Go /dev/draw layer next door
+replace 9front/gui => ../gui
+
+require (
+ 9fans.net/go v0.0.2 // indirect
+ 9front/gui v0.0.0-00010101000000-000000000000 // indirect
+ github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d // indirect
+ github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 // indirect
+ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
+ github.com/fhs/mux9p v0.3.1 // indirect
+ github.com/knusbaum/go9p v1.18.0 // indirect
+ github.com/teambition/rrule-go v1.8.2 // indirect
+)
diff --git a/pim/go.sum b/pim/go.sum
new file mode 100644
index 0000000..0154ae4
--- /dev/null
+++ b/pim/go.sum
@@ -0,0 +1,25 @@
+9fans.net/go v0.0.2 h1:RYM6lWITV8oADrwLfdzxmt8ucfW6UtP9v1jg4qAbqts=
+9fans.net/go v0.0.2/go.mod h1:lfPdxjq9v8pVQXUMBCx5EO5oLXWQFlKRQgs1kEkjoIM=
+github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d h1:xH/U6K+HYxh1480TkQYRqRO8F2RJsg+R6wFiVJzdldg=
+github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d/go.mod h1:UKp8dv9aeaZoQFWin7eQXtz89iHly1YAFZNn3MCutmQ=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
+github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+github.com/fhs/mux9p v0.3.1 h1:x1UswUWZoA9vrA02jfisndCq3xQm+wrQUxUt5N99E08=
+github.com/fhs/mux9p v0.3.1/go.mod h1:F4hwdenmit0WDoNVT2VMWlLJrBVCp/8UhzJa7scfjEQ=
+github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok=
+github.com/hanwen/go-fuse/v2 v2.0.3/go.mod h1:0EQM6aH2ctVpvZ6a+onrQ/vaykxh2GH7hy3e13vzTUY=
+github.com/knusbaum/go9p v1.18.0 h1:/Y67RNvNKX1ZV1IOdnO1lIetiF0X+CumOyvEc0011GI=
+github.com/knusbaum/go9p v1.18.0/go.mod h1:HtMoJKqZUe1Oqag5uJqG5RKQ9gWPSP+wolsnLLv44r8=
+github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
+github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/pim/lib/cal/backend.go b/pim/lib/cal/backend.go
new file mode 100644
index 0000000..6dcacc2
--- /dev/null
+++ b/pim/lib/cal/backend.go
@@ -0,0 +1,41 @@
+package cal
+
+import "time"
+
+// A Backend supplies a calendar's events and, where the protocol allows
+// it, accepts changes back.
+//
+// Everything above this line -- the tree, recurrence expansion, ctl,
+// query, alarm, changed, the 9p service -- is the same whether the
+// events arrived as a published .ics, over CalDAV or over JMAP. Only
+// fetching and writing differ, so only fetching and writing live here.
+type Backend interface {
+ // Name of the calendar, as it appears in ctl.
+ Name() string
+
+ // Caps says what this backend can do, so that a tool can report
+ // "read only" rather than trying and failing. The vocabulary is
+ // read, write, rsvp, schedule; see pim/doc/design.md.
+ Caps() string
+
+ // Refresh is how often to poll. Zero means never.
+ Refresh() time.Duration
+
+ // Status reports when the backend last synced and the error, if
+ // any, from that attempt.
+ Status() (time.Time, string)
+
+ // Sync brings the backend up to date and reports whether anything
+ // actually changed. A backend that cannot tell should say true --
+ // the cost is a needless rebuild, not a wrong answer.
+ Sync() (bool, error)
+
+ // Events returns the calendar as it now stands.
+ Events() ([]*Event, error)
+}
+
+// Describer is implemented by backends with more to say in ctl: the
+// source directory, the url, whatever identifies where events came from.
+type Describer interface {
+ Describe() string
+}
diff --git a/pim/lib/cal/duration.go b/pim/lib/cal/duration.go
new file mode 100644
index 0000000..4217ab7
--- /dev/null
+++ b/pim/lib/cal/duration.go
@@ -0,0 +1,79 @@
+package cal
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// parseDuration parses an RFC 5545 duration: [+-]P[nW][nD][T[nH][nM][nS]].
+// A leading '-' means before the reference time, so "-PT15M" is -15m.
+func parseDuration(s string) (time.Duration, error) {
+ orig := s
+ neg := false
+ switch {
+ case strings.HasPrefix(s, "-"):
+ neg, s = true, s[1:]
+ case strings.HasPrefix(s, "+"):
+ s = s[1:]
+ }
+ if !strings.HasPrefix(s, "P") {
+ return 0, fmt.Errorf("not a duration: %q", orig)
+ }
+ s = s[1:]
+
+ var d time.Duration
+ inTime := false
+ num := ""
+ for _, r := range s {
+ switch {
+ case r >= '0' && r <= '9':
+ num += string(r)
+ continue
+ case r == 'T':
+ inTime = true
+ continue
+ }
+ if num == "" {
+ return 0, fmt.Errorf("unit %q with no count in %q", r, orig)
+ }
+ n, err := strconv.Atoi(num)
+ if err != nil {
+ return 0, fmt.Errorf("bad count in %q", orig)
+ }
+ num = ""
+ var unit time.Duration
+ switch r {
+ case 'W':
+ unit = 7 * 24 * time.Hour
+ case 'D':
+ unit = 24 * time.Hour
+ case 'H':
+ if !inTime {
+ return 0, fmt.Errorf("H outside time part in %q", orig)
+ }
+ unit = time.Hour
+ case 'M':
+ if !inTime {
+ return 0, fmt.Errorf("M outside time part in %q", orig)
+ }
+ unit = time.Minute
+ case 'S':
+ if !inTime {
+ return 0, fmt.Errorf("S outside time part in %q", orig)
+ }
+ unit = time.Second
+ default:
+ return 0, fmt.Errorf("unknown unit %q in %q", r, orig)
+ }
+ d += time.Duration(n) * unit
+ }
+ if num != "" {
+ return 0, fmt.Errorf("trailing count in %q", orig)
+ }
+ if neg {
+ d = -d
+ }
+ return d, nil
+}
diff --git a/pim/lib/cal/ical.go b/pim/lib/cal/ical.go
new file mode 100644
index 0000000..f52b333
--- /dev/null
+++ b/pim/lib/cal/ical.go
@@ -0,0 +1,317 @@
+package cal
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+ _ "time/tzdata" // TZID= resolution; 9front has no zoneinfo
+
+ "github.com/emersion/go-ical"
+ "github.com/teambition/rrule-go"
+)
+
+// Event is one VEVENT, with its recurrence set resolved but not expanded.
+type Event struct {
+ UID string
+ Summary string
+ Location string
+ Description string
+ Start time.Time
+ End time.Time
+ AllDay bool
+ RRule string
+ Alarms []time.Duration // relative to start; negative means before
+ Raw string
+ RecurID time.Time // set when this event overrides one instance
+ Cancelled bool
+ Organizer string
+ Attendees []Attendee
+ master *Event // set on an override: the series it belongs to
+ set *rrule.Set // nil when the event does not recur
+ overrides map[int64]*Event // by RECURRENCE-ID, unix seconds
+}
+
+// Attendee is one ATTENDEE line, reduced to what a person wants to see.
+type Attendee struct {
+ Name string // CN
+ Email string // the mailto: value, stripped
+ Partstat string // NEEDS-ACTION, ACCEPTED, DECLINED, TENTATIVE
+ Role string
+}
+
+// Instance is one occurrence of an Event at a concrete time.
+type Instance struct {
+ Ev *Event
+ Start time.Time
+ End time.Time
+}
+
+// Duration of a single occurrence.
+func (e *Event) dur() time.Duration {
+ if e.End.IsZero() || !e.End.After(e.Start) {
+ return time.Hour
+ }
+ return e.End.Sub(e.Start)
+}
+
+// Instances returns every occurrence starting within [t0, t1).
+//
+// An overridden occurrence is emitted from the override, not from the
+// recurrence rule, because the override may have moved it into or out of
+// the window, or cancelled it outright.
+func (e *Event) Instances(t0, t1 time.Time) []Instance {
+ var out []Instance
+ in := func(t time.Time) bool { return !t.Before(t0) && t.Before(t1) }
+
+ if e.set == nil {
+ if in(e.Start) {
+ out = append(out, Instance{e, e.Start, e.Start.Add(e.dur())})
+ }
+ } else {
+ for _, t := range e.set.Between(t0, t1, true) {
+ if _, ok := e.overrides[t.Unix()]; ok {
+ continue // the override speaks for this occurrence
+ }
+ out = append(out, Instance{e, t, t.Add(e.dur())})
+ }
+ }
+ for _, ov := range e.overrides {
+ if ov.Cancelled || !in(ov.Start) {
+ continue
+ }
+ out = append(out, Instance{ov, ov.Start, ov.Start.Add(ov.dur())})
+ }
+ return out
+}
+
+// loadDir reads every .ics file in dir and returns the events it contains.
+// LoadDir reads every .ics file in dir.
+func LoadDir(dir string) ([]*Event, error) {
+ names, err := filepath.Glob(filepath.Join(dir, "*.ics"))
+ if err != nil {
+ return nil, err
+ }
+ sort.Strings(names)
+ var evs []*Event
+ for _, name := range names {
+ f, err := os.Open(name)
+ if err != nil {
+ Warnf("%s: %v", name, err)
+ continue
+ }
+ cal, err := ical.NewDecoder(f).Decode()
+ f.Close()
+ if err != nil {
+ Warnf("%s: %v", name, err)
+ continue
+ }
+ for _, c := range cal.Events() {
+ ev, err := newEvent(&c)
+ if err != nil {
+ Warnf("%s: %v", name, err)
+ continue
+ }
+ evs = append(evs, ev)
+ }
+ }
+ return link(evs), nil
+}
+
+// link attaches RECURRENCE-ID events to the series they override.
+// An override with no matching series is kept as an event of its own.
+func link(evs []*Event) []*Event {
+ masters := make(map[string]*Event, len(evs))
+ for _, e := range evs {
+ if e.RecurID.IsZero() {
+ masters[e.UID] = e
+ }
+ }
+ out := make([]*Event, 0, len(masters))
+ for _, e := range evs {
+ if e.RecurID.IsZero() {
+ out = append(out, e)
+ continue
+ }
+ m, ok := masters[e.UID]
+ if !ok {
+ out = append(out, e) // orphan; stands alone
+ continue
+ }
+ e.master = m
+ if m.overrides == nil {
+ m.overrides = make(map[int64]*Event)
+ }
+ m.overrides[e.RecurID.Unix()] = e
+ }
+ return out
+}
+
+func newEvent(ev *ical.Event) (*Event, error) {
+ e := &Event{}
+ e.UID, _ = ev.Props.Text(ical.PropUID)
+ if e.UID == "" {
+ return nil, fmt.Errorf("event with no UID")
+ }
+ e.Summary, _ = ev.Props.Text(ical.PropSummary)
+ e.Location, _ = ev.Props.Text(ical.PropLocation)
+ e.Description, _ = ev.Props.Text(ical.PropDescription)
+
+ start, err := ev.DateTimeStart(time.Local)
+ if err != nil {
+ return nil, fmt.Errorf("%s: bad DTSTART: %v", e.UID, err)
+ }
+ e.Start = start
+ if end, err := ev.DateTimeEnd(time.Local); err == nil {
+ e.End = end
+ }
+ if p := ev.Props.Get(ical.PropDateTimeStart); p != nil {
+ e.AllDay = p.ValueType() == ical.ValueDate
+ }
+ if p := ev.Props.Get(ical.PropRecurrenceRule); p != nil {
+ e.RRule = p.Value
+ }
+ if p := ev.Props.Get(ical.PropRecurrenceID); p != nil {
+ if t, err := parseICSTime(p.Value, time.Local); err == nil {
+ e.RecurID = t
+ } else if t, err := ev.Props.DateTime(ical.PropRecurrenceID, time.Local); err == nil {
+ e.RecurID = t
+ }
+ }
+ if st, err := ev.Props.Text(ical.PropStatus); err == nil {
+ e.Cancelled = strings.EqualFold(st, "CANCELLED")
+ }
+ if p := ev.Props.Get(ical.PropOrganizer); p != nil {
+ e.Organizer = person(p)
+ }
+ for _, p := range ev.Props.Values(ical.PropAttendee) {
+ e.Attendees = append(e.Attendees, Attendee{
+ Name: p.Params.Get(ical.ParamCommonName),
+ Email: strings.TrimPrefix(p.Value, "mailto:"),
+ Partstat: p.Params.Get(ical.ParamParticipationStatus),
+ Role: p.Params.Get(ical.ParamRole),
+ })
+ }
+ e.Raw = rawOf(ev.Component)
+ e.Alarms = alarmsOf(ev.Component)
+
+ if err := e.buildSet(ev); err != nil {
+ Warnf("%s: %v", e.UID, err)
+ }
+ return e, nil
+}
+
+// buildSet assembles the recurrence set from RRULE, EXDATE and RDATE.
+func (e *Event) buildSet(ev *ical.Event) error {
+ opt, err := ev.Props.RecurrenceRule()
+ if err != nil {
+ return fmt.Errorf("bad RRULE: %v", err)
+ }
+ exd := ev.Props.Values(ical.PropExceptionDates)
+ rdt := ev.Props.Values(ical.PropRecurrenceDates)
+ if opt == nil && len(exd) == 0 && len(rdt) == 0 {
+ return nil
+ }
+ set := &rrule.Set{}
+ set.DTStart(e.Start)
+ if opt != nil {
+ opt.Dtstart = e.Start
+ r, err := rrule.NewRRule(*opt)
+ if err != nil {
+ return fmt.Errorf("bad RRULE: %v", err)
+ }
+ set.RRule(r)
+ }
+ for _, p := range exd {
+ for _, t := range dateList(p, e.Start.Location()) {
+ set.ExDate(t)
+ }
+ }
+ for _, p := range rdt {
+ for _, t := range dateList(p, e.Start.Location()) {
+ set.RDate(t)
+ }
+ }
+ e.set = set
+ return nil
+}
+
+// dateList parses the comma-separated date list in EXDATE/RDATE.
+func dateList(p ical.Prop, loc *time.Location) []time.Time {
+ var out []time.Time
+ for _, s := range strings.Split(p.Value, ",") {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ continue
+ }
+ t, err := parseICSTime(s, loc)
+ if err != nil {
+ Warnf("bad %s %q: %v", p.Name, s, err)
+ continue
+ }
+ out = append(out, t)
+ }
+ return out
+}
+
+func parseICSTime(s string, loc *time.Location) (time.Time, error) {
+ for _, f := range []string{"20060102T150405Z", "20060102T150405", "20060102"} {
+ l := loc
+ if strings.HasSuffix(f, "Z") {
+ l = time.UTC
+ }
+ if t, err := time.ParseInLocation(f, s, l); err == nil {
+ return t, nil
+ }
+ }
+ return time.Time{}, fmt.Errorf("unrecognised time")
+}
+
+// alarmsOf returns the relative triggers of every VALARM child.
+// Absolute triggers are ignored for now; see doc/design.md.
+func alarmsOf(c *ical.Component) []time.Duration {
+ var out []time.Duration
+ for _, child := range c.Children {
+ if child.Name != ical.CompAlarm {
+ continue
+ }
+ p := child.Props.Get(ical.PropTrigger)
+ if p == nil {
+ continue
+ }
+ d, err := parseDuration(p.Value)
+ if err != nil {
+ continue
+ }
+ out = append(out, d)
+ }
+ return out
+}
+
+func rawOf(c *ical.Component) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "BEGIN:%s\n", c.Name)
+ var names []string
+ for name := range c.Props {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ for _, p := range c.Props[name] {
+ fmt.Fprintf(&b, "%s:%s\n", p.Name, p.Value)
+ }
+ }
+ fmt.Fprintf(&b, "END:%s\n", c.Name)
+ return b.String()
+}
+
+// person renders ORGANIZER as a name, falling back to the address.
+func person(p *ical.Prop) string {
+ addr := strings.TrimPrefix(p.Value, "mailto:")
+ if cn := p.Params.Get(ical.ParamCommonName); cn != "" && cn != addr {
+ return cn + " <" + addr + ">"
+ }
+ return addr
+}
diff --git a/pim/lib/cal/query.go b/pim/lib/cal/query.go
new file mode 100644
index 0000000..325c9a0
--- /dev/null
+++ b/pim/lib/cal/query.go
@@ -0,0 +1,175 @@
+package cal
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/knusbaum/go9p/fs"
+ "github.com/knusbaum/go9p/proto"
+)
+
+// The query file works like /net/cs: open it, write a query, read the
+// answer back on the same fd.
+//
+// % echo 'attendee=joe@example.com' >/mnt/pim/query
+// % cat /mnt/pim/query
+//
+// A query is ndb-style attr=value pairs, all of which must match:
+//
+// summary= substring of the summary, case-insensitive
+// attendee= substring of any attendee's name or address
+// organizer= substring of the organizer
+// location= substring of the location
+// uid= substring of the uid
+// from= YYYY-MM-DD, occurrences on or after this day
+// to= YYYY-MM-DD, occurrences before this day
+//
+// It answers with one path per line, which is what pim/show takes.
+// Holding the fd across the write and the read is the correct way to
+// use it, as with cs. But "echo ... >query; cat query" opens twice, and
+// that is how people will actually use it from rc, so the last answer is
+// also kept and served to a fid that has none of its own.
+type queryFile struct {
+ mu sync.Mutex
+ res map[uint64][]byte
+ last []byte
+}
+
+// index is one occurrence and the path it was published at.
+type index struct {
+ path string
+ in Instance
+}
+
+func (s *Server) addQuery() {
+ q := &queryFile{res: make(map[uint64][]byte)}
+ st := s.fsys.NewStat("query", s.user, s.user, 0666)
+ base := fs.NewStaticFile(st, []byte(""))
+ s.root.AddChild(&fs.WrappedFile{
+ File: base,
+ WriteF: func(fid uint64, off uint64, data []byte) (uint32, error) {
+ out, err := s.query(string(data))
+ if err != nil {
+ return 0, err
+ }
+ q.mu.Lock()
+ q.res[fid] = []byte(out)
+ q.last = []byte(out)
+ q.mu.Unlock()
+ return uint32(len(data)), nil
+ },
+ ReadF: func(fid uint64, off uint64, count uint64) ([]byte, error) {
+ q.mu.Lock()
+ b, ok := q.res[fid]
+ if !ok {
+ b = q.last
+ }
+ q.mu.Unlock()
+ if off >= uint64(len(b)) {
+ return []byte{}, nil
+ }
+ end := off + count
+ if end > uint64(len(b)) {
+ end = uint64(len(b))
+ }
+ return b[off:end], nil
+ },
+ CloseF: func(fid uint64) error {
+ q.mu.Lock()
+ delete(q.res, fid)
+ q.mu.Unlock()
+ return nil
+ },
+ })
+}
+
+func (s *Server) query(q string) (string, error) {
+ var from, to time.Time
+ terms := map[string]string{}
+
+ for _, f := range strings.Fields(strings.TrimSpace(q)) {
+ k, v, ok := strings.Cut(f, "=")
+ if !ok {
+ return "", fmt.Errorf("query: %q is not attr=value", f)
+ }
+ switch k {
+ case "summary", "attendee", "organizer", "location", "uid":
+ terms[k] = strings.ToLower(v)
+ case "from", "to":
+ t, err := time.ParseInLocation("2006-01-02", v, time.Local)
+ if err != nil {
+ return "", fmt.Errorf("query: bad date %q", v)
+ }
+ if k == "from" {
+ from = t
+ } else {
+ to = t
+ }
+ default:
+ return "", fmt.Errorf("query: unknown attribute %q", k)
+ }
+ }
+ if len(terms) == 0 && from.IsZero() && to.IsZero() {
+ return "", fmt.Errorf("query: nothing to match")
+ }
+
+ s.mu.Lock()
+ idx := s.index
+ s.mu.Unlock()
+
+ var b strings.Builder
+ for _, e := range idx {
+ if !from.IsZero() && e.in.Start.Before(from) {
+ continue
+ }
+ if !to.IsZero() && !e.in.Start.Before(to) {
+ continue
+ }
+ if match(e.in.Ev, terms) {
+ fmt.Fprintf(&b, "%s\n", e.path)
+ }
+ }
+ return b.String(), nil
+}
+
+func match(ev *Event, terms map[string]string) bool {
+ has := func(hay, needle string) bool {
+ return strings.Contains(strings.ToLower(hay), needle)
+ }
+ for k, v := range terms {
+ switch k {
+ case "summary":
+ if !has(ev.Summary, v) {
+ return false
+ }
+ case "location":
+ if !has(ev.Location, v) {
+ return false
+ }
+ case "organizer":
+ if !has(ev.Organizer, v) {
+ return false
+ }
+ case "uid":
+ if !has(ev.UID, v) {
+ return false
+ }
+ case "attendee":
+ found := false
+ for _, a := range ev.Attendees {
+ if has(a.Name, v) || has(a.Email, v) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+var _ = proto.DMDIR
diff --git a/pim/lib/cal/server.go b/pim/lib/cal/server.go
new file mode 100644
index 0000000..f9da81e
--- /dev/null
+++ b/pim/lib/cal/server.go
@@ -0,0 +1,325 @@
+// Package cal serves a calendar as a file tree.
+//
+// The tree is the interface and it is the same for every backend:
+//
+// ctl read: state. write: refresh, window <days>
+// query write a query, read the answer, as with cs(8)
+// alarm blocking read; one line per alarm due
+// changed blocking read; one line per rebuild
+// events/date/yyyy/mm/dd/hhmm-summary
+// events/uuid/<uid>/...
+//
+// A backend supplies events and, where its protocol allows, takes
+// changes back. Everything else lives here.
+package cal
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/knusbaum/go9p"
+ "github.com/knusbaum/go9p/fs"
+)
+
+// Argv0 prefixes diagnostics. A command sets it to its own name.
+var Argv0 = "cal"
+
+func Warnf(format string, a ...interface{}) {
+ fmt.Fprintf(os.Stderr, "%s: %s\n", Argv0, fmt.Sprintf(format, a...))
+}
+
+// Server presents one calendar.
+type Server struct {
+ be Backend
+
+ fsys *fs.FS
+ root *fs.StaticDir
+ user string
+
+ window time.Duration
+
+ // /srv records only a name, an owner and a mode, so a server that
+ // wants to be identifiable has to say so itself.
+ srv string
+ conf string
+ pid int
+ wd string
+ started time.Time
+
+ mu sync.Mutex
+ evs []*Event
+ evdir map[*Event]string // event -> its directory name under uuid/
+ index []index // every published occurrence, for query
+ gen int
+ alarm fs.Stream
+ changed fs.Stream
+}
+
+// Config is what a command must decide and the library will not.
+type Config struct {
+ User string
+ Srv string // service name posted in /srv
+ Conf string // path of the config file, for ctl
+ Window time.Duration // how far either side of now to expand
+}
+
+// New builds a server over a backend, without serving it.
+func New(be Backend, c Config) *Server {
+ fsys, root := fs.NewFS(c.User, c.User, 0555)
+ s := &Server{
+ be: be, fsys: fsys, root: root, user: c.User,
+ window: c.Window, srv: c.Srv, conf: c.Conf,
+ pid: os.Getpid(), started: time.Now(),
+ }
+ s.wd, _ = os.Getwd()
+ s.addCtl()
+ s.addAlarm()
+ s.addQuery()
+ s.addChanged()
+ return s
+}
+
+// Serve syncs once, publishes the tree, and keeps it current for as long
+// as the process runs. Nothing outside ever asks for a reload; watchers
+// read changed instead.
+func (s *Server) Serve() error {
+ if _, err := s.be.Sync(); err != nil {
+ Warnf("%s: %v", s.be.Name(), err)
+ }
+ if err := s.Reload(); err != nil {
+ return err
+ }
+ if d := s.be.Refresh(); d > 0 {
+ go s.poll(d)
+ }
+ Warnf("serving /srv/%s", s.srv)
+ return go9p.PostSrv(s.srv, s.fsys.Server())
+}
+
+// Report loads and says what was found, without serving.
+func (s *Server) Report() error {
+ if _, err := s.be.Sync(); err != nil {
+ return err
+ }
+ t := time.Now()
+ evs, err := s.be.Events()
+ if err != nil {
+ return err
+ }
+ load := time.Since(t)
+
+ t = time.Now()
+ now := time.Now()
+ insts := expand(evs, now.Add(-s.window), now.Add(s.window))
+ exp := time.Since(t)
+
+ var recur, alarms int
+ for _, e := range evs {
+ if e.set != nil {
+ recur++
+ }
+ alarms += len(e.Alarms)
+ }
+ fmt.Printf("calendar %s\n", s.be.Name())
+ fmt.Printf("caps %s\n", s.be.Caps())
+ fmt.Printf("events %d\n", len(evs))
+ fmt.Printf("recurring %d\n", recur)
+ fmt.Printf("alarms %d\n", alarms)
+ fmt.Printf("instances %d (window +/-%d days)\n",
+ len(insts), int(s.window/(24*time.Hour)))
+ fmt.Printf("load %v\n", load.Round(time.Millisecond))
+ fmt.Printf("expand %v\n", exp.Round(time.Millisecond))
+ return nil
+}
+
+func (s *Server) poll(every time.Duration) {
+ for {
+ time.Sleep(every)
+ changed, err := s.be.Sync()
+ if err != nil {
+ Warnf("%s: %v", s.be.Name(), err)
+ continue
+ }
+ if !changed {
+ continue // a poll that changes nothing wakes nobody
+ }
+ if err := s.Reload(); err != nil {
+ Warnf("%s: reload: %v", s.be.Name(), err)
+ }
+ }
+}
+
+// Reload rebuilds the whole tree from the backend's current events.
+func (s *Server) Reload() error {
+ evs, err := s.be.Events()
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ insts := expand(evs, now.Add(-s.window), now.Add(s.window))
+
+ s.mu.Lock()
+ s.evs = evs
+ s.index = nil
+ s.gen++
+ gen := s.gen
+ s.mu.Unlock()
+
+ s.evdir = nil
+ s.root.DeleteChild("events")
+ s.buildEvents(s.root, evs)
+ s.buildWhen(s.root, insts)
+
+ go s.schedule(gen, insts)
+ Warnf("loaded %d events, %d instances", len(evs), len(insts))
+ if s.changed != nil {
+ // wake anything watching, so a gui knows to re-walk
+ s.changed.Write([]byte(fmt.Sprintf("reload %d events %d instances %d\n",
+ gen, len(evs), len(insts))))
+ }
+ return nil
+}
+
+func (s *Server) addCtl() {
+ st := s.fsys.NewStat("ctl", s.user, s.user, 0666)
+ base := fs.NewStaticFile(st, []byte(""))
+ s.root.AddChild(&fs.WrappedFile{
+ File: base,
+ ReadF: func(fid uint64, off uint64, count uint64) ([]byte, error) {
+ b := []byte(s.ctlText())
+ if off >= uint64(len(b)) {
+ return []byte{}, nil
+ }
+ end := off + count
+ if end > uint64(len(b)) {
+ end = uint64(len(b))
+ }
+ return b[off:end], nil
+ },
+ WriteF: func(fid uint64, off uint64, data []byte) (uint32, error) {
+ if err := s.control(string(data)); err != nil {
+ return 0, err
+ }
+ return uint32(len(data)), nil
+ },
+ })
+}
+
+func (s *Server) ctlText() string {
+ s.mu.Lock()
+ n := len(s.evs)
+ s.mu.Unlock()
+
+ text := fmt.Sprintf(
+ "srv /srv/%s\npid %d\nuser %s\nstarted %s\n"+
+ "args %s\nwd %s\nconfig %s\n"+
+ "window %d\nevents %d\ncaps %s\n",
+ s.srv, s.pid, s.user, s.started.Format(time.RFC3339),
+ strings.Join(os.Args, " "), s.wd, orNone(s.conf),
+ int(s.window/(24*time.Hour)), n, s.be.Caps())
+
+ if d, ok := s.be.(Describer); ok {
+ text += d.Describe()
+ }
+ when, err := s.be.Status()
+ line := fmt.Sprintf("cal %s refresh %v fetched %s",
+ s.be.Name(), s.be.Refresh(), when.Format(time.RFC3339))
+ if err != "" {
+ line += " error " + err
+ }
+ return text + line + "\n"
+}
+
+func (s *Server) control(cmd string) error {
+ f := strings.Fields(cmd)
+ if len(f) == 0 {
+ return nil
+ }
+ switch f[0] {
+ case "refresh":
+ if _, err := s.be.Sync(); err != nil {
+ return err
+ }
+ return s.Reload()
+ case "window":
+ if len(f) != 2 {
+ return fmt.Errorf("usage: window days")
+ }
+ n, err := strconv.Atoi(f[1])
+ if err != nil || n <= 0 {
+ return fmt.Errorf("bad window %q", f[1])
+ }
+ s.mu.Lock()
+ s.window = time.Duration(n) * 24 * time.Hour
+ s.mu.Unlock()
+ return s.Reload()
+ }
+ return fmt.Errorf("unknown command %q", f[0])
+}
+
+// addAlarm creates the blocking alarm file. A read blocks until the next
+// alarm is due; every reader gets every alarm.
+func (s *Server) addAlarm() {
+ stream := fs.NewBlockingStream(8)
+ st := s.fsys.NewStat("alarm", s.user, s.user, 0444)
+ s.alarm = stream
+ s.root.AddChild(fs.NewStreamFile(st, stream))
+}
+
+// addChanged creates the changed file. A read blocks until the tree has
+// been rebuilt, so a gui learns to walk it again without polling.
+func (s *Server) addChanged() {
+ stream := fs.NewBlockingStream(8)
+ st := s.fsys.NewStat("changed", s.user, s.user, 0444)
+ s.changed = stream
+ s.root.AddChild(fs.NewStreamFile(st, stream))
+}
+
+type firing struct {
+ at time.Time
+ inst Instance
+}
+
+// schedule fires the alarms for one generation of the tree, and exits as
+// soon as a later reload has bumped the generation.
+func (s *Server) schedule(gen int, insts []Instance) {
+ now := time.Now()
+ var fs_ []firing
+ for _, in := range insts {
+ for _, d := range in.Ev.Alarms {
+ at := in.Start.Add(d)
+ if at.After(now) {
+ fs_ = append(fs_, firing{at, in})
+ }
+ }
+ }
+ sort.Slice(fs_, func(i, j int) bool { return fs_[i].at.Before(fs_[j].at) })
+
+ for _, f := range fs_ {
+ if d := time.Until(f.at); d > 0 {
+ time.Sleep(d)
+ }
+ s.mu.Lock()
+ stale := gen != s.gen
+ s.mu.Unlock()
+ if stale {
+ return
+ }
+ s.alarm.Write([]byte(fmt.Sprintf("%s\t%s\t%s\n",
+ f.at.Format(time.RFC3339),
+ f.inst.Start.Format(time.RFC3339),
+ f.inst.Ev.Summary)))
+ }
+}
+
+func orNone(s string) string {
+ if s == "" {
+ return "(none)"
+ }
+ return s
+}
diff --git a/pim/lib/cal/tree.go b/pim/lib/cal/tree.go
new file mode 100644
index 0000000..cd9e9d2
--- /dev/null
+++ b/pim/lib/cal/tree.go
@@ -0,0 +1,217 @@
+package cal
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/knusbaum/go9p/fs"
+ "github.com/knusbaum/go9p/proto"
+)
+
+// slug makes a string safe to use as one path element.
+func slug(s string) string {
+ s = strings.TrimSpace(s)
+ var b strings.Builder
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
+ b.WriteRune(r)
+ case r == '-', r == '.', r == '_':
+ b.WriteRune(r)
+ case r == ' ':
+ b.WriteRune('-')
+ default:
+ b.WriteRune('_')
+ }
+ }
+ out := b.String()
+ if out == "" {
+ out = "unnamed"
+ }
+ if len(out) > 64 {
+ out = out[:64]
+ }
+ return out
+}
+
+// uniqueName returns name, or name-2, name-3 ... if it is already taken.
+// Real calendars do collide: two events at the same minute with the same
+// summary, or several orphaned overrides sharing one UID.
+func uniqueName(parent *fs.StaticDir, name string) string {
+ kids := parent.Children()
+ if _, taken := kids[name]; !taken {
+ return name
+ }
+ for i := 2; ; i++ {
+ try := fmt.Sprintf("%s-%d", name, i)
+ if _, taken := kids[try]; !taken {
+ return try
+ }
+ }
+}
+
+func (s *Server) file(dir *fs.StaticDir, name, content string) {
+ st := s.fsys.NewStat(name, s.user, s.user, 0444)
+ dir.AddChild(fs.NewStaticFile(st, []byte(content)))
+}
+
+func (s *Server) subdir(parent *fs.StaticDir, name string) *fs.StaticDir {
+ if c, ok := parent.Children()[name]; ok {
+ if d, ok := c.(*fs.StaticDir); ok {
+ return d
+ }
+ }
+ st := s.fsys.NewStat(name, s.user, s.user, 0555|proto.DMDIR)
+ d := fs.NewStaticDir(st)
+ parent.AddChild(d)
+ return d
+}
+
+func tfmt(t time.Time, allDay bool) string {
+ if allDay {
+ return t.Format("2006-01-02")
+ }
+ return t.Format(time.RFC3339)
+}
+
+// buildEvents populates events/uuid/<uid>/ with one directory per event.
+func (s *Server) buildEvents(root *fs.StaticDir, evs []*Event) {
+ d := s.subdir(s.subdir(root, "events"), "uuid")
+ for _, e := range evs {
+ name := slug(e.UID)
+ if !e.RecurID.IsZero() {
+ // an override with no series of its own to hang under
+ name += "-" + e.RecurID.Format("20060102T150405")
+ }
+ name = uniqueName(d, name)
+ if s.evdir == nil {
+ s.evdir = make(map[*Event]string)
+ }
+ s.evdir[e] = name
+ ed := s.subdir(d, name)
+ s.file(ed, "summary", e.Summary+"\n")
+ s.file(ed, "start", tfmt(e.Start, e.AllDay)+"\n")
+ if !e.End.IsZero() {
+ s.file(ed, "end", tfmt(e.End, e.AllDay)+"\n")
+ }
+ if e.Location != "" {
+ s.file(ed, "location", e.Location+"\n")
+ }
+ if e.Description != "" {
+ s.file(ed, "description", e.Description+"\n")
+ }
+ if e.RRule != "" {
+ s.file(ed, "rrule", e.RRule+"\n")
+ }
+ if e.Organizer != "" {
+ s.file(ed, "organizer", e.Organizer+"\n")
+ }
+ if len(e.Attendees) > 0 {
+ s.file(ed, "attendees", attendeeText(e.Attendees))
+ }
+ s.file(ed, "uid", e.UID+"\n")
+ s.file(ed, "raw", e.Raw)
+ }
+}
+
+// buildWhen populates events/date/YYYY/MM/DD/ with one file per occurrence.
+func (s *Server) buildWhen(root *fs.StaticDir, insts []Instance) {
+ w := s.subdir(s.subdir(root, "events"), "date")
+ for _, in := range insts {
+ // File by local wall-clock time. Events arrive in a mix of
+ // zones -- TZID=America/New_York here, UTC there -- and if the
+ // path keeps each event's own zone then a day's files neither
+ // sort by time nor land on the right day. All-day events are
+ // floating and must not be shifted.
+ st := in.Start
+ if !in.Ev.AllDay {
+ st = st.Local()
+ }
+ y := s.subdir(w, st.Format("2006"))
+ m := s.subdir(y, st.Format("01"))
+ d := s.subdir(m, st.Format("02"))
+
+ // The first field is always four digits so that shell tools can
+ // compare it numerically; all-day events sort to the top of the
+ // day and are still marked as such.
+ name := st.Format("1504") + "-" + slug(in.Ev.Summary)
+ if in.Ev.AllDay {
+ name = "0000-allday-" + slug(in.Ev.Summary)
+ }
+ // An overridden occurrence belongs to its series' directory.
+ ev := in.Ev
+ if ev.master != nil {
+ ev = ev.master
+ }
+ fname := uniqueName(d, name)
+ s.file(d, fname, instText(in, s.evdir[ev]))
+ s.index = append(s.index, index{
+ // relative to the server's root: it cannot know where it
+ // has been mounted, and under /mnt/pim/calendars/<name>
+ // it would guess wrong
+ path: fmt.Sprintf("events/date/%s/%s", st.Format("2006/01/02"), fname),
+ in: in,
+ })
+ }
+}
+
+func instText(in Instance, evdir string) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "summary: %s\n", in.Ev.Summary)
+ fmt.Fprintf(&b, "start: %s\n", tfmt(in.Start, in.Ev.AllDay))
+ fmt.Fprintf(&b, "end: %s\n", tfmt(in.End, in.Ev.AllDay))
+ if in.Ev.Location != "" {
+ fmt.Fprintf(&b, "location: %s\n", in.Ev.Location)
+ }
+ // Epoch seconds as well as RFC3339: rc has no way to parse the
+ // latter, but date(1) formats the former.
+ fmt.Fprintf(&b, "epoch: %d\n", in.Start.Unix())
+ fmt.Fprintf(&b, "epochend: %d\n", in.End.Unix())
+ fmt.Fprintf(&b, "uid: %s\n", in.Ev.UID)
+ // 9P2000 has no symlinks, so publish the path instead. A tool must
+ // never have to reproduce the server's slug rules to find this.
+ if evdir != "" {
+ fmt.Fprintf(&b, "event: ../../../../uuid/%s\n", evdir)
+ }
+ if in.Ev.RRule != "" {
+ fmt.Fprintf(&b, "rrule: %s\n", in.Ev.RRule)
+ }
+ if in.Ev.Description != "" {
+ fmt.Fprintf(&b, "\n%s\n", strings.TrimRight(in.Ev.Description, "\n"))
+ }
+ return b.String()
+}
+
+// expand returns every instance in [t0,t1), sorted by start time.
+func expand(evs []*Event, t0, t1 time.Time) []Instance {
+ var out []Instance
+ for _, e := range evs {
+ out = append(out, e.Instances(t0, t1)...)
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Start.Equal(out[j].Start) {
+ return out[i].Ev.Summary < out[j].Ev.Summary
+ }
+ return out[i].Start.Before(out[j].Start)
+ })
+ return out
+}
+
+// attendeeText is one attendee per line: status, name, address.
+func attendeeText(as []Attendee) string {
+ var b strings.Builder
+ for _, a := range as {
+ st := a.Partstat
+ if st == "" {
+ st = "UNKNOWN"
+ }
+ name := a.Name
+ if name == "" {
+ name = a.Email
+ }
+ fmt.Fprintf(&b, "%-12s\t%s\t%s\n", st, name, a.Email)
+ }
+ return b.String()
+}
diff --git a/pim/man/ical.4 b/pim/man/ical.4
new file mode 100644
index 0000000..022749f
--- /dev/null
+++ b/pim/man/ical.4
@@ -0,0 +1,269 @@
+.TH ICAL 4
+.SH NAME
+ical \- calendar file system
+.SH SYNOPSIS
+.B ical/fs
+[
+.B -n
+] [
+.B -c
+.I config
+] [
+.B -d
+.I dir
+] [
+.B -s
+.I service
+] [
+.B -M
+.I mtpt
+] [
+.B -w
+.I days
+] [
+.B -u
+.I url
+] [
+.B -U
+.I file
+] [
+.B -a
+.I addr
+]
+.SH DESCRIPTION
+.I Ical/fs
+presents a calendar as a file tree.
+It reads iCalendar
+.RB ( .ics )
+files from
+.I dir
+(default
+.BR . ),
+expands recurring events, and posts a service file descriptor in
+.BR /srv/\fIservice .
+.PP
+The default service name is
+.BI ical. user . pid\fR,
+as
+.IR rio (1)
+and
+.IR plumb (1)
+name theirs, so that several may run at once.
+A name given with
+.B -s
+is used unchanged, for a singleton something else expects to find by
+name.
+The name posted is written to standard error.
+.I Ical/fs
+does not mount itself; a start script would say
+.IP
+.EX
+ical/fs &
+mount /srv/ical.$user.$apid /mnt/pim
+.EE
+.PP
+Calendars named in
+.I config
+(default
+.BR $home/lib/pim )
+are fetched over HTTP and kept current without being asked.
+Each line is a tuple of attribute-value pairs in the syntax of
+.IR ndb (6):
+.IP
+.EX
+cal=work url=https://... refresh=15m
+.EE
+.PP
+.B Refresh
+defaults to 15 minutes.
+A fetch whose content is unchanged rebuilds nothing.
+The
+.B -u
+and
+.B -U
+flags name a single url directly, the latter reading it from
+.IR file ;
+the url of a private calendar is a secret and belongs in a file
+rather than in the arguments, where
+.IR ps (1)
+would show it.
+.PP
+The
+.B -w
+flag sets how many days either side of now recurrences are expanded
+into, default 400.
+.B -M
+is the mount point reported in query answers, default
+.BR /mnt/pim .
+.B -a
+also serves 9P on a TCP address.
+.B -n
+loads the calendars, reports what was found, and exits.
+.PP
+The top level contains the files
+.BR ctl ,
+.BR query ,
+.BR alarm ,
+.BR changed ,
+and the directory
+.BR events .
+.SS Events
+.B Events/date
+holds one file per occurrence, at
+.BI events/date/ yyyy/mm/dd/hhmm-summary\fR.
+Times are local, zero filled, and four digits wide, so that a day
+sorts by name.
+All day events are named
+.BI 0000-allday- summary\fR.
+Each file holds a header of
+.BI attribute :\ value
+lines, a blank line, and the description:
+.IP
+.EX
+summary: API WG
+start: 2026-08-19T10:00:00-04:00
+end: 2026-08-19T11:05:00-04:00
+epoch: 1787148000
+epochend: 1787151900
+uid: 1avg0u8b0k5v4bqokqfvgr157v@google.com
+event: ../../../../uuid/1avg0u8b0k5v4bqokqfvgr157v_google.com
+.EE
+.PP
+.B Epoch
+and
+.B epochend
+are seconds, for
+.IR date (1).
+.B Event
+is the path of the event this occurrence belongs to; 9P has no
+symbolic links.
+.PP
+.B Events/uuid
+holds one directory per event, named for its
+.BR uid ,
+containing the files
+.BR summary ,
+.BR start ,
+.BR end ,
+.BR location ,
+.BR description ,
+.BR rrule ,
+.BR organizer ,
+.BR attendees ,
+.BR uid ,
+and
+.BR raw .
+Absent values have no file.
+.B Attendees
+holds one line per attendee: participation status, name, and address,
+separated by tabs.
+.B Raw
+is the event as it arrived.
+.SS Ctl
+Reading
+.B ctl
+reports the source directory, the expansion window, the number of
+events, and one line per calendar giving its refresh interval and the
+time of its last fetch.
+Writing to it accepts:
+.TF "\fLwindow\fI n\fL"
+.TP
+.B refresh
+Reload from
+.IR dir .
+.TP
+.BI window \ n
+Expand recurrences
+.I n
+days either side of now, and reload.
+.SS Query
+.B Query
+answers questions the tree does not index.
+Write a query, then read the answer, as with
+.IR cs (8);
+one path is returned per line.
+A query is a list of
+.IB attribute = value
+terms, all of which must match:
+.BR summary ,
+.BR attendee ,
+.BR organizer ,
+.BR location ,
+and
+.B uid
+match a substring, without regard to case;
+.B from
+and
+.B to
+bound the occurrence time and are written
+.BR yyyy-mm-dd .
+.IP
+.EX
+% echo 'attendee=michael from=2026-08-19' >/mnt/pim/query
+% cat /mnt/pim/query
+.EE
+.PP
+Holding one file descriptor across the write and the read is correct
+usage.
+The last answer is also returned to a descriptor that has none of its
+own, so that
+.B echo
+and
+.B cat
+work.
+.SS Alarm and changed
+A read of
+.B alarm
+blocks until an alarm is due and returns the alarm time, the start of
+the event, and its summary, separated by tabs.
+Only
+.B VALARM
+triggers relative to the start are honoured.
+.PP
+A read of
+.B changed
+blocks until the tree has been rebuilt.
+A program displaying a calendar should walk the tree again when it
+returns rather than poll.
+The whole tree is rebuilt, so open file descriptors should not be
+assumed to remain valid.
+.SH EXAMPLE
+Serve the calendars named in the config and mount them:
+.IP
+.EX
+% ical/fs -s pim
+% mount /srv/pim /mnt/pim
+% pim/agenda
+.EE
+.SH SOURCE
+.B /sys/src/pim
+.SH "SEE ALSO"
+.IR agenda (1),
+.IR date (1),
+.IR ndb (6),
+.IR cs (8)
+.PP
+Desruisseaux,
+``Internet Calendaring and Scheduling Core Object Specification'',
+RFC 5545.
+.SH BUGS
+Embedded
+.B VTIMEZONE
+definitions are ignored;
+.B TZID
+is resolved by IANA name.
+.PP
+.B RECURRENCE-ID
+overrides are applied, but
+.B METHOD:CANCEL
+is not.
+.PP
+Absolute alarm triggers,
+.BR RELATED=END ,
+and repeating alarms are ignored.
+.PP
+Nothing can be written but
+.B ctl
+and
+.BR query .
+Replying to an invitation is not yet possible.
diff --git a/pim/mk.sh b/pim/mk.sh
new file mode 100755
index 0000000..e86c17c
--- /dev/null
+++ b/pim/mk.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+# Cross-compile the calendar file servers for 9front.
+#
+# One library, one binary per protocol. They install as ical/fs,
+# caldav/fs and so on -- named for the protocol they speak, because a
+# backend is a protocol and protocols span data types.
+#
+# Go 1.24.x is broken on plan9/amd64 (spinbit mutex panic before main),
+# so pin a known-good toolchain.
+set -e
+: ${GOTOOLCHAIN:=go1.27.0}
+: ${GOOS:=plan9}
+: ${GOARCH:=amd64}
+export GOTOOLCHAIN GOOS GOARCH
+
+cd "$(dirname "$0")"
+mkdir -p bin
+for c in cmd/*; do
+ n=$(basename "$c")
+ go build -o "bin/$n" "./$c"
+done
+ls -l bin
diff --git a/pim/rc/agenda b/pim/rc/agenda
new file mode 100755
index 0000000..ec38ad2
--- /dev/null
+++ b/pim/rc/agenda
@@ -0,0 +1,88 @@
+#!/bin/rc
+# pim/agenda -- what is happening, across every mounted calendar.
+rfork e
+
+mtpt=/mnt/pim
+days=7
+off=0
+only=()
+
+while(~ $1 -*){
+ switch($1){
+ case -m
+ mtpt=$2; shift
+ case -n
+ days=$2; shift
+ case -o
+ off=$2; shift
+ case -c
+ only=($only $2); shift
+ case *
+ echo 'usage: agenda [-m mtpt] [-n days] [-o dayoffset] [-c cal]' >[1=2]
+ exit usage
+ }
+ shift
+}
+
+cals=$only
+if(~ $#cals 0)
+ cals=`{pim/calendars -m $mtpt}
+if(~ $#cals 0){
+ echo 'pim/agenda: no calendars under '^$mtpt^'/calendars' >[1=2]
+ exit nocal
+}
+# only worth naming the calendar when more than one is in play
+tag=0
+if(! ~ $#cals 1)
+ tag=1
+
+tmp=/tmp/agenda.$pid
+fn sigexit { rm -f $tmp }
+
+now=`{date -n}
+i=$off
+last=`{echo $off + $days | bc}
+n=0
+
+while(test $i -lt $last){
+ sec=`{echo $now + $i '*' 86400 | bc}
+ day=`{date -f YYYY/MM/DD $sec}
+
+ # name, calendar, summary, location -- one line per occurrence,
+ # keyed by the file name so a sort interleaves the calendars by time
+ {
+ for(c in $cals){
+ d=$mtpt/calendars/$c/events/date/$day
+ if(test -d $d)
+ for(f in $d/*){
+ b=`{basename $f}
+ sum=`{sed -n 's/^summary: //p' $f}
+ loc=`{sed -n 's/^location: //p' $f}
+ echo $"b^' '^$"c^' '^$"sum^' '^$"loc
+ }
+ }
+ } | sort >$tmp
+
+ if(test -s $tmp){
+ if(test $n -gt 0)
+ echo
+ n=1
+ date -f 'WWW DD MMM YYYY' $sec
+ awk -F' ' -v 'tag='^$tag '{
+ if ($1 ~ /^0000-allday-/)
+ t = "all day"
+ else
+ t = substr($1,1,2) ":" substr($1,3,2)
+ s = "\t" t "\t" $3
+ if ($4 != "")
+ s = s " (" $4 ")"
+ if (tag)
+ s = s "\t" $2
+ print s
+ }' $tmp
+ }
+ i=`{echo $i + 1 | bc}
+}
+if(~ $n 0)
+ echo 'nothing in the next '^$days^' days'
+exit 0
diff --git a/pim/rc/calendars b/pim/rc/calendars
new file mode 100644
index 0000000..e1f191e
--- /dev/null
+++ b/pim/rc/calendars
@@ -0,0 +1,38 @@
+#!/bin/rc
+# pim/calendars -- name every mounted calendar, one per line.
+#
+# rc globs only metacharacters written literally in the source, so a
+# pattern held in a variable will not expand. Every tool that works
+# across calendars enumerates them through here instead.
+rfork e
+
+mtpt=/mnt/pim
+long=()
+while(~ $1 -*){
+ switch($1){
+ case -m
+ mtpt=$2; shift
+ case -l
+ long=1
+ case *
+ echo 'usage: calendars [-m mtpt] [-l]' >[1=2]
+ exit usage
+ }
+ shift
+}
+# mntgen leaves the directory behind when a server goes away, so an
+# empty name is not a calendar. A live one answers for ctl.
+for(c in `{ls -p $mtpt/calendars >[2]/dev/null})
+ if(test -f $mtpt/calendars/$c/ctl){
+ if(~ $#long 0)
+ echo $c
+ if(! ~ $#long 0){
+ # caps says what the backend can do: a published .ics is
+ # read-only, with no iMIP path and nothing to write to
+ caps=`{sed -n 's/^caps //p' $mtpt/calendars/$c/ctl}
+ if(~ $#caps 0)
+ caps=unknown
+ echo $c^' '^$"caps
+ }
+ }
+exit 0
diff --git a/pim/rc/fetch b/pim/rc/fetch
new file mode 100755
index 0000000..2208cc7
--- /dev/null
+++ b/pim/rc/fetch
@@ -0,0 +1,56 @@
+#!/bin/rc
+# pim/fetch -- refresh a subscribed calendar from its url.
+#
+# pim/fetch $home/lib/cal/work.url $home/lib/cal/work.ics \
+# /mnt/pim/calendars/work/ctl
+#
+# ical/fs does not fetch: a subscribed calendar is a file somebody else
+# wrote. This is that somebody. It writes the file only when the content
+# actually changed, then pokes ctl so the server reloads at once instead
+# of waiting to notice.
+#
+# The url of a private calendar is a secret, so it is read from a file
+# rather than passed as an argument where ps(1) would show it.
+rfork e
+
+if(! ~ $#* 2 && ! ~ $#* 3){
+ echo 'usage: fetch urlfile dest.ics [ctl]' >[1=2]
+ exit usage
+}
+urlfile=$1
+dest=$2
+ctl=$3
+
+if(! test -r $urlfile){
+ echo 'pim/fetch: cannot read '^$urlfile >[1=2]
+ exit nourl
+}
+url=`{sed -n '/^#/d; /^$/d; s/["'']//g; p; q' $urlfile}
+if(~ $#url 0){
+ echo 'pim/fetch: no url in '^$urlfile >[1=2]
+ exit nourl
+}
+
+tmp=$dest.new
+if(! hget $"url > $tmp){
+ rm -f $tmp
+ echo 'pim/fetch: fetch failed' >[1=2]
+ exit fetch
+}
+if(! test -s $tmp){
+ rm -f $tmp
+ echo 'pim/fetch: empty response' >[1=2]
+ exit empty
+}
+
+# a fetch that changes nothing must not rebuild the tree or wake watchers
+if(test -f $dest)
+ if(cmp -s $tmp $dest){
+ rm -f $tmp
+ exit 0
+ }
+mv $tmp $dest
+if(! ~ $#ctl 0)
+ if(test -f $ctl)
+ echo refresh >$ctl
+exit 0
diff --git a/pim/rc/find b/pim/rc/find
new file mode 100755
index 0000000..8ee162a
--- /dev/null
+++ b/pim/rc/find
@@ -0,0 +1,77 @@
+#!/bin/rc
+# pim/find -- ask every calendar a question, by attribute.
+#
+# pim/find attendee michael
+# pim/find summary standup from 2026-08-19 to 2026-08-26
+# pim/find -c work attendee michael
+#
+# Attributes are separate words, as ndb/query takes them: rc lexes a bare
+# attr=value as an assignment, so it cannot be an argument unquoted.
+rfork e
+
+mtpt=/mnt/pim
+long=()
+only=()
+
+while(~ $1 -*){
+ switch($1){
+ case -m
+ mtpt=$2; shift
+ case -c
+ only=($only $2); shift
+ case -l
+ long=1
+ case *
+ echo 'usage: find [-m mtpt] [-c cal] [-l] attr value ...' >[1=2]
+ exit usage
+ }
+ shift
+}
+if(~ $#* 0){
+ echo 'usage: find [-m mtpt] [-c cal] [-l] attr value ...' >[1=2]
+ exit usage
+}
+
+q=()
+while(! ~ $#* 0){
+ switch($1){
+ case '*=*'
+ q=($q $1)
+ shift
+ case *
+ if(~ $#* 1){
+ echo 'pim/find: '^$1^' has no value' >[1=2]
+ exit usage
+ }
+ q=($q $1^'='^$2)
+ shift; shift
+ }
+}
+
+cals=$only
+if(~ $#cals 0)
+ cals=`{pim/calendars -m $mtpt}
+
+# the server answers with paths relative to its own root, since it
+# cannot know where it was mounted
+hits=()
+for(c in $cals){
+ echo $q >$mtpt/calendars/$c/query
+ for(r in `{cat $mtpt/calendars/$c/query})
+ hits=($hits $mtpt/calendars/$c/$r)
+}
+
+if(~ $#hits 0)
+ exit 0
+if(~ $#long 0){
+ for(h in $hits)
+ echo $h
+ exit 0
+}
+for(h in $hits){
+ ep=`{sed -n 's/^epoch: //p' $h}
+ sum=`{sed -n 's/^summary: //p' $h}
+ when=`{date -f 'WWW DD MMM YYYY hh:mm' $"ep}
+ echo $"when^' '^$"sum
+}
+exit 0
diff --git a/pim/rc/month b/pim/rc/month
new file mode 100755
index 0000000..1fdbe6f
--- /dev/null
+++ b/pim/rc/month
@@ -0,0 +1,4 @@
+#!/bin/rc
+# pim/month -- the next thirty-one days.
+rfork e
+exec pim/agenda -n 31 $*
diff --git a/pim/rc/next b/pim/rc/next
new file mode 100755
index 0000000..0436cb3
--- /dev/null
+++ b/pim/rc/next
@@ -0,0 +1,81 @@
+#!/bin/rc
+# pim/next -- the next thing, one line, for a window label.
+rfork e
+
+# test(1) reads a leading zero as octal: 0700 compares as 448 and 0900
+# is a syntax error. Strip the padding before any arithmetic.
+fn num {
+ echo $1 | sed 's/^0*//; s/^$/0/'
+}
+
+mtpt=/mnt/pim
+only=()
+while(~ $1 -*){
+ switch($1){
+ case -m
+ mtpt=$2; shift
+ case -c
+ only=($only $2); shift
+ case *
+ echo 'usage: next [-m mtpt] [-c cal]' >[1=2]
+ exit usage
+ }
+ shift
+}
+cals=$only
+if(~ $#cals 0)
+ cals=`{pim/calendars -m $mtpt}
+
+now=`{date -n}
+hhmm=`{num `{date -f hhmm $now}}
+tmp=/tmp/next.$pid
+fn sigexit { rm -f $tmp }
+
+i=0
+while(test $i -lt 14){
+ sec=`{echo $now + $i '*' 86400 | bc}
+ day=`{date -f YYYY/MM/DD $sec}
+ {
+ for(c in $cals){
+ d=$mtpt/calendars/$c/events/date/$day
+ if(test -d $d)
+ for(f in $d/*){
+ b=`{basename $f}
+ sum=`{sed -n 's/^summary: //p' $f}
+ echo $"b^' '^$"c^' '^$"sum
+ }
+ }
+ } | sort >$tmp
+ if(test -s $tmp){
+ day=''
+ if(test $i -gt 0){
+ d=`{date -f 'WWW DD MMM' $sec}
+ day=$"d^' '
+ }
+ # awk prints the finished line: a value carried back through
+ # an rc variable would be split on its tabs and rejoined
+ # with spaces
+ out=/tmp/next.out.$pid
+ awk -F' ' -v 'first='^$i -v 'now='^$"hhmm -v 'day='^$"day '
+ {
+ t = $1 + 0
+ if (first > 0 || t >= now) {
+ split($1, a, "-")
+ when = substr(a[1],1,2) ":" substr(a[1],3,2)
+ if ($1 ~ /^0000-allday-/)
+ when = "all day"
+ printf "%s%s %s\n", day, when, $3
+ exit
+ }
+ }' $tmp >$out
+ if(test -s $out){
+ cat $out
+ rm -f $out $tmp
+ exit 0
+ }
+ rm -f $out
+ }
+ i=`{echo $i + 1 | bc}
+}
+echo 'nothing scheduled'
+exit 0
diff --git a/pim/rc/show b/pim/rc/show
new file mode 100755
index 0000000..da23973
--- /dev/null
+++ b/pim/rc/show
@@ -0,0 +1,115 @@
+#!/bin/rc
+# pim/show -- everything known about one event.
+#
+# Takes a path to an occurrence under events/date/, as printed by
+# "pim/agenda -p" or plumbed from a window, or a pattern to search for.
+rfork e
+
+mtpt=/mnt/pim
+days=90
+all=()
+only=()
+
+while(~ $1 -*){
+ switch($1){
+ case -m
+ mtpt=$2; shift
+ case -n
+ days=$2; shift
+ case -a
+ all=1
+ case -c
+ only=($only $2); shift
+ case *
+ echo 'usage: show [-m mtpt] [-n days] [-c cal] [-a] path|pattern' >[1=2]
+ exit usage
+ }
+ shift
+}
+if(~ $#* 0){
+ echo 'usage: show [-m mtpt] [-n days] [-c cal] [-a] path|pattern' >[1=2]
+ exit usage
+}
+pat=$"*
+
+fn field {
+ sed -n 's/^'^$2^': //p' $1
+}
+
+fn one {
+ f=$1
+ d=`{basename -d $f}
+ sum=`{field $f summary}
+ echo $"sum
+ echo $"sum | sed 's/./-/g'
+ ep=`{field $f epoch}
+ ee=`{field $f epochend}
+ if(~ $#ep 0)
+ sed -n 's/^start: /when /p' $f
+ if(! ~ $#ep 0){
+ # backquotes give a list and ^ distributes over it; flatten first
+ w=`{date -f 'WWW DD MMM YYYY hh:mm' $"ep}
+ x=`{date -f hh:mm $"ee}
+ echo 'when '^$"w^'-'^$"x
+ }
+ sed -n 's/^location: /where /p' $f
+ sed -n 's/^rrule: /repeats /p' $f
+ ev=`{field $f event}
+ if(! ~ $#ev 0){
+ e=$d/$"ev
+ if(test -f $e/organizer){
+ o=`{cat $e/organizer}
+ echo 'from '^$"o
+ }
+ if(test -f $e/attendees){
+ echo who
+ sed 's/^/ /' $e/attendees
+ }
+ echo 'event '^`{cleanname $"e}
+ }
+ echo 'at '^$"f
+ # a blank line ends the header; the rest is the description
+ sed -n '/^$/,$p' $f | sed 1d
+}
+
+fn search {
+ cals=$only
+ if(~ $#cals 0)
+ cals=`{pim/calendars -m $mtpt}
+ now=`{date -n}
+ i=0
+ while(test $i -lt $days){
+ sec=`{echo $now + $i '*' 86400 | bc}
+ day=`{date -f YYYY/MM/DD $sec}
+ for(c in $cals){
+ d=$mtpt/calendars/$c/events/date/$day
+ if(test -d $d)
+ grep -li '^summary: .*'^$"pat $d/* >[2]/dev/null
+ }
+ i=`{echo $i + 1 | bc}
+ }
+}
+
+if(test -f $"pat){
+ one $"pat
+ exit 0
+}
+
+hits=`{search}
+if(~ $#hits 0){
+ echo 'pim/show: nothing matching '^$"pat^' in the next '^$"days^' days' >[1=2]
+ exit notfound
+}
+if(! ~ $#all 0){
+ for(h in $hits){
+ one $h
+ echo
+ }
+ exit 0
+}
+one $hits(1)
+if(test $#hits -gt 1){
+ echo
+ echo '('^`{echo $#hits - 1 | bc}^' more; -a for all)'
+}
+exit 0
diff --git a/pim/rc/showwin b/pim/rc/showwin
new file mode 100755
index 0000000..d71546b
--- /dev/null
+++ b/pim/rc/showwin
@@ -0,0 +1,10 @@
+#!/bin/rc
+# pim/showwin -- show an event in a window and stay there.
+#
+# rio closes a window when its command exits, so this leaves an
+# interactive shell behind: the event stays up while you carry on
+# elsewhere, and the window is still a window.
+rfork e
+pim/show $*
+echo
+exec rc -i
diff --git a/pim/rc/today b/pim/rc/today
new file mode 100755
index 0000000..6f458c7
--- /dev/null
+++ b/pim/rc/today
@@ -0,0 +1,4 @@
+#!/bin/rc
+# pim/today -- just today.
+rfork e
+exec pim/agenda -n 1 $*
diff --git a/pim/rc/week b/pim/rc/week
new file mode 100755
index 0000000..1d85529
--- /dev/null
+++ b/pim/rc/week
@@ -0,0 +1,4 @@
+#!/bin/rc
+# pim/week -- the next seven days.
+rfork e
+exec pim/agenda -n 7 $*
diff --git a/pim/rc/who b/pim/rc/who
new file mode 100755
index 0000000..353ca4f
--- /dev/null
+++ b/pim/rc/who
@@ -0,0 +1,87 @@
+#!/bin/rc
+# pim/who -- who you met with, over a range of days.
+#
+# pim/who the last seven days
+# pim/who -n 30 the last thirty
+# pim/who -a only those who accepted
+# pim/who -x calvinm leave yourself out
+rfork e
+
+mtpt=/mnt/pim
+days=7
+only=()
+me=()
+acc=()
+
+while(~ $1 -*){
+ switch($1){
+ case -m
+ mtpt=$2; shift
+ case -n
+ days=$2; shift
+ case -x
+ me=$2; shift
+ case -a
+ acc=1
+ case -c
+ only=($only $2); shift
+ case *
+ echo 'usage: who [-m mtpt] [-n days] [-x me] [-a]' >[1=2]
+ exit usage
+ }
+ shift
+}
+
+now=`{date -n}
+from=`{date -i `{echo $now - $days '*' 86400 | bc}}
+to=`{date -i `{echo $now + 86400 | bc}}
+
+cals=$only
+if(~ $#cals 0)
+ cals=`{pim/calendars -m $mtpt}
+
+# Who counts as you comes from the calendar's me= in the config, which
+# the server reports in ctl. -x adds to it.
+ex=/tmp/who.ex.$pid
+fn sigexit { rm -f $ex $tmp }
+>$ex
+if(! ~ $#me 0)
+ echo $me >>$ex
+for(c in $cals)
+ sed -n 's/^cal .* me //p' $mtpt/calendars/$c/ctl >[2]/dev/null |
+ tr , ' ' | tr ' ' '
+' >>$ex
+grep -v '^$' $ex >$ex^.t; mv $ex^.t $ex
+
+hits=()
+for(c in $cals){
+ echo 'from='^$"from^' to='^$"to >$mtpt/calendars/$c/query
+ for(r in `{cat $mtpt/calendars/$c/query})
+ hits=($hits $mtpt/calendars/$c/$r)
+}
+if(~ $#hits 0){
+ echo 'nobody, in the last '^$"days^' days'
+ exit 0
+}
+
+fn people {
+ for(h in $hits){
+ d=`{basename -d $h}
+ ev=`{sed -n 's/^event: //p' $h}
+ if(! ~ $#ev 0){
+ f=$d/$"ev/attendees
+ if(test -f $f){
+ if(~ $#acc 0)
+ awk -F'\t' 'NF>2{print $3}' $f
+ if(! ~ $#acc 0)
+ awk -F'\t' 'NF>2 && $1 ~ /ACCEPTED/{print $3}' $f
+ }
+ }
+ }
+}
+
+if(test -s $ex)
+ people | grep -v -f $ex | sort | uniq -c | sort -nr
+if(! test -s $ex)
+ people | sort | uniq -c | sort -nr
+exit 0
diff --git a/pim/test/cal/work.ics b/pim/test/cal/work.ics
new file mode 100644
index 0000000..ee7b152
--- /dev/null
+++ b/pim/test/cal/work.ics
@@ -0,0 +1,40 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ical/fs test//EN
+BEGIN:VEVENT
+UID:standup@test
+DTSTAMP:20260101T000000Z
+DTSTART:20260819T130000Z
+DTEND:20260819T131500Z
+SUMMARY:Daily standup
+LOCATION:the wire
+RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=20
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-PT5M
+DESCRIPTION:standup
+END:VALARM
+END:VEVENT
+BEGIN:VEVENT
+UID:retro@test
+DTSTAMP:20260101T000000Z
+DTSTART:20260821T150000Z
+DTEND:20260821T160000Z
+SUMMARY:Retro\, with snacks
+RRULE:FREQ=MONTHLY;BYDAY=-1FR;COUNT=6
+END:VEVENT
+BEGIN:VEVENT
+UID:holiday@test
+DTSTAMP:20260101T000000Z
+DTSTART;VALUE=DATE:20260824
+SUMMARY:Out of office
+END:VEVENT
+BEGIN:VEVENT
+UID:oneoff@test
+DTSTAMP:20260101T000000Z
+DTSTART:20260820T183000Z
+DTEND:20260820T193000Z
+SUMMARY:Dinner
+LOCATION:somewhere with a semicolon\; here
+END:VEVENT
+END:VCALENDAR