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 /pim/cmd | |
| 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>
Diffstat (limited to 'pim/cmd')
| -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 |
4 files changed, 1757 insertions, 0 deletions
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) + } +} |
