diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-08-22 13:16:49 -0400 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-08-22 13:16:49 -0400 |
| commit | 8a3d2c99bff60cb775ebc42516c0c3912d54ba3d (patch) | |
| tree | fd136a3b7927146763cdc11e6be07c71f92bb92c | |
| parent | 9aacce8b3b54060d0037eca897856d7273e6e5e8 (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/.gitignore | 3 | ||||
| -rw-r--r-- | pim/cmd/cal9/main.go | 1131 | ||||
| -rw-r--r-- | pim/cmd/cal9/menu.go | 267 | ||||
| -rw-r--r-- | pim/cmd/icalfs/config.go | 137 | ||||
| -rw-r--r-- | pim/cmd/icalfs/main.go | 222 | ||||
| -rw-r--r-- | pim/doc/design.md | 143 | ||||
| -rw-r--r-- | pim/doc/gotchas.md | 62 | ||||
| -rw-r--r-- | pim/doc/todo.md | 44 | ||||
| -rw-r--r-- | pim/go.mod | 17 | ||||
| -rw-r--r-- | pim/go.sum | 25 | ||||
| -rw-r--r-- | pim/lib/cal/backend.go | 41 | ||||
| -rw-r--r-- | pim/lib/cal/duration.go | 79 | ||||
| -rw-r--r-- | pim/lib/cal/ical.go | 317 | ||||
| -rw-r--r-- | pim/lib/cal/query.go | 175 | ||||
| -rw-r--r-- | pim/lib/cal/server.go | 325 | ||||
| -rw-r--r-- | pim/lib/cal/tree.go | 217 | ||||
| -rw-r--r-- | pim/man/ical.4 | 269 | ||||
| -rwxr-xr-x | pim/mk.sh | 22 | ||||
| -rwxr-xr-x | pim/rc/agenda | 88 | ||||
| -rw-r--r-- | pim/rc/calendars | 38 | ||||
| -rwxr-xr-x | pim/rc/fetch | 56 | ||||
| -rwxr-xr-x | pim/rc/find | 77 | ||||
| -rwxr-xr-x | pim/rc/month | 4 | ||||
| -rwxr-xr-x | pim/rc/next | 81 | ||||
| -rwxr-xr-x | pim/rc/show | 115 | ||||
| -rwxr-xr-x | pim/rc/showwin | 10 | ||||
| -rwxr-xr-x | pim/rc/today | 4 | ||||
| -rwxr-xr-x | pim/rc/week | 4 | ||||
| -rwxr-xr-x | pim/rc/who | 87 | ||||
| -rw-r--r-- | pim/test/cal/work.ics | 40 |
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 |
