summaryrefslogtreecommitdiff
path: root/pim/lib/cal/tree.go
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:49 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:49 -0400
commit8a3d2c99bff60cb775ebc42516c0c3912d54ba3d (patch)
treefd136a3b7927146763cdc11e6be07c71f92bb92c /pim/lib/cal/tree.go
parent9aacce8b3b54060d0037eca897856d7273e6e5e8 (diff)
pim: personal information management, starting with a calendar
A calendar as a file tree, and tools that only know the tree: events/date/yyyy/mm/dd/hhmm-summary as lived events/uuid/<uid>/ as stored ctl query alarm changed lib/cal owns all of that. A backend supplies events and, where its protocol allows, takes changes back -- six methods. cmd/icalfs is the first: it reads .ics files from a directory and nothing else, because fetching is rc/fetch's job and hget already exists. That keeps net/http out of the binary and makes a subscribed calendar and a local one the same thing. The tools are rc on purpose. If the tree needs a compiled program to be useful, the tree is the wrong shape. Three things were added to the tree because the rc port needed them: a path from an occurrence to its event, epoch seconds beside RFC3339, and a numeric slot for all-day events so test(1) can compare it. ctl reports caps, so a tool can say "read only" instead of trying and failing. A published .ics is read only: nowhere to PUT, and no METHOD:REQUEST to reply to. CalDAV would be read write rsvp schedule, and that is the next backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'pim/lib/cal/tree.go')
-rw-r--r--pim/lib/cal/tree.go217
1 files changed, 217 insertions, 0 deletions
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()
+}