summaryrefslogtreecommitdiff
path: root/pim/lib
diff options
context:
space:
mode:
Diffstat (limited to 'pim/lib')
-rw-r--r--pim/lib/cal/backend.go41
-rw-r--r--pim/lib/cal/duration.go79
-rw-r--r--pim/lib/cal/ical.go317
-rw-r--r--pim/lib/cal/query.go175
-rw-r--r--pim/lib/cal/server.go325
-rw-r--r--pim/lib/cal/tree.go217
6 files changed, 1154 insertions, 0 deletions
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()
+}