summaryrefslogtreecommitdiff
path: root/pim/lib/cal/server.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/server.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/server.go')
-rw-r--r--pim/lib/cal/server.go325
1 files changed, 325 insertions, 0 deletions
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
+}