summaryrefslogtreecommitdiff
path: root/pim/cmd/caldavfs/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'pim/cmd/caldavfs/main.go')
-rw-r--r--pim/cmd/caldavfs/main.go239
1 files changed, 239 insertions, 0 deletions
diff --git a/pim/cmd/caldavfs/main.go b/pim/cmd/caldavfs/main.go
new file mode 100644
index 0000000..c3306de
--- /dev/null
+++ b/pim/cmd/caldavfs/main.go
@@ -0,0 +1,239 @@
+// caldav/fs serves a CalDAV calendar as a 9p file system.
+//
+// It is a backend behind pim/lib/cal, which owns the tree, so everything
+// above it -- events/date, events/uuid, ctl, query, alarm, changed -- is
+// the same as any other calendar. Only fetching differs, and unlike a
+// published .ics this one is a conversation: discovery, then a report
+// for the window we care about.
+//
+// A CalDAV server that advertises calendar-auto-schedule (RFC 6638)
+// sends the iMIP for you: writing your PARTSTAT back both updates your
+// copy and tells the organiser. That is the whole reason this backend
+// can honestly offer rsvp where ical/fs cannot.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/emersion/go-ical"
+ "github.com/emersion/go-webdav/caldav"
+
+ "pim/lib/cal"
+)
+
+func fatal(format string, a ...interface{}) {
+ cal.Warnf(format, a...)
+ os.Exit(1)
+}
+
+type backend struct {
+ name string
+ client *caldav.Client
+ path string // the calendar collection
+ window time.Duration
+ refresh time.Duration
+ sched bool // server does the scheduling for us
+
+ mu sync.Mutex
+ evs []*cal.Event
+ sig string
+ last time.Time
+ err string
+}
+
+func (b *backend) Name() string { return b.name }
+
+// autoschedule is reported so a dry run shows it; it decides whether
+// writing a PARTSTAT is enough or whether we must send the iMIP too.
+func (b *backend) Sched() bool { return b.sched }
+
+func (b *backend) Caps() string {
+ // Writing is not implemented yet, so say so rather than promise it.
+ // When it is: "read write rsvp" plus "schedule" when the server
+ // advertises calendar-auto-schedule.
+ return "read"
+}
+
+func (b *backend) Refresh() time.Duration { return b.refresh }
+
+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("collection %s\n", b.path)
+ s += fmt.Sprintf("autoschedule %v\n", b.sched)
+ return s
+}
+
+// Sync asks for every event in the window and reports whether the set
+// changed. ETags make that cheap to decide: if every object still has
+// the etag we saw last time, nothing has moved.
+func (b *backend) Sync() (bool, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ now := time.Now()
+ q := &caldav.CalendarQuery{
+ CompRequest: caldav.CalendarCompRequest{
+ Name: "VCALENDAR",
+ Props: []string{"VERSION"},
+ Comps: []caldav.CalendarCompRequest{{
+ Name: "VEVENT",
+ Props: []string{
+ "SUMMARY", "UID", "DTSTART", "DTEND", "DURATION",
+ "RRULE", "RDATE", "EXDATE", "RECURRENCE-ID",
+ "LOCATION", "DESCRIPTION", "STATUS", "SEQUENCE",
+ "ORGANIZER", "ATTENDEE",
+ },
+ }},
+ },
+ CompFilter: caldav.CompFilter{
+ Name: "VCALENDAR",
+ Comps: []caldav.CompFilter{{
+ Name: "VEVENT",
+ Start: now.Add(-b.window),
+ End: now.Add(b.window),
+ }},
+ },
+ }
+ objs, err := b.client.QueryCalendar(ctx, b.path, q)
+ b.note(time.Now(), err)
+ if err != nil {
+ return false, err
+ }
+
+ sig := etagsig(objs)
+ b.mu.Lock()
+ same := sig == b.sig && b.evs != nil
+ b.mu.Unlock()
+ if same {
+ return false, nil
+ }
+
+ cals := make([]*ical.Calendar, 0, len(objs))
+ for _, o := range objs {
+ if o.Data != nil {
+ cals = append(cals, o.Data)
+ }
+ }
+ evs := cal.FromCalendars(cals)
+
+ b.mu.Lock()
+ b.evs, b.sig = evs, sig
+ b.mu.Unlock()
+ cal.Warnf("%s: %d objects", b.name, len(objs))
+ return true, nil
+}
+
+func (b *backend) Events() ([]*cal.Event, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.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 = ""
+ }
+}
+
+// etagsig summarises the collection: path and etag per object. If the
+// server changes nothing, this does not change either.
+func etagsig(objs []caldav.CalendarObject) string {
+ s := make([]string, 0, len(objs))
+ for _, o := range objs {
+ s = append(s, o.Path+" "+o.ETag)
+ }
+ // QueryCalendar makes no promise about order
+ sortStrings(s)
+ return strings.Join(s, "\n")
+}
+
+func sortStrings(s []string) {
+ for i := 1; i < len(s); i++ {
+ for j := i; j > 0 && s[j] < s[j-1]; j-- {
+ s[j], s[j-1] = s[j-1], s[j]
+ }
+ }
+}
+
+func main() {
+ cal.Argv0 = "caldav/fs"
+ var (
+ endpoint = flag.String("e", "", "caldav endpoint, e.g. https://caldav.fastmail.com/dav/")
+ user = flag.String("u", "", "account name")
+ pwfile = flag.String("p", "", "file holding the password (keeps it out of ps)")
+ which = flag.String("C", "", "which calendar to serve, by display name")
+ name = flag.String("N", "", "name to report in ctl (default: the display name)")
+ srv = flag.String("s", "", "service name to post in /srv (default caldav.$user.$pid)")
+ days = flag.Int("w", 400, "expansion window in days")
+ poll = flag.Duration("r", 15*time.Minute, "how often to re-query")
+ dry = flag.Bool("n", false, "load and report, do not serve")
+ )
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr,
+ "usage: caldav/fs -e endpoint -u user -p pwfile [-C calendar] [-N name] [-s srv] [-w days] [-r poll] [-n]\n")
+ os.Exit(2)
+ }
+ flag.Parse()
+
+ if *endpoint == "" || *user == "" || *pwfile == "" {
+ flag.Usage()
+ }
+ pass, err := readSecret(*pwfile)
+ if err != nil {
+ fatal("%v", err)
+ }
+
+ client, coll, sched, err := dial(*endpoint, *user, pass, *which)
+ if err != nil {
+ fatal("%v", err)
+ }
+
+ who := os.Getenv("user")
+ if who == "" {
+ who = "glenda"
+ }
+ if *srv == "" {
+ // as rio(1) and plumb(1) name theirs, so several may run at once
+ *srv = fmt.Sprintf("caldav.%s.%d", who, os.Getpid())
+ }
+ label := *name
+ if label == "" {
+ label = coll.Name
+ }
+
+ be := &backend{
+ name: label, client: client, path: coll.Path, sched: sched,
+ window: time.Duration(*days) * 24 * time.Hour,
+ refresh: *poll,
+ }
+
+ s := cal.New(be, cal.Config{
+ User: who, Srv: *srv, Conf: *pwfile,
+ 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)
+ }
+}