diff options
Diffstat (limited to 'pim/cmd/caldavfs/main.go')
| -rw-r--r-- | pim/cmd/caldavfs/main.go | 239 |
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) + } +} |
