// 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 endpoint string client *caldav.Client path string // the calendar collection window time.Duration refresh time.Duration sched bool // server does the scheduling for us user string pass string me []string mu sync.Mutex evs []*cal.Event sig string obj map[string]objref // uid -> where it lives on the server last time.Time err string } // objref is what a write needs: the path to PUT back to, and the etag // that says nobody else has touched it since we looked. type objref struct { path string etag 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 { c := "read rsvp" if b.sched { // the server sends the iMIP for us; one PUT does both halves c += " schedule" } return c } // Me is whose calendar this is. Without it nothing can tell which of an // event's attendees to change. func (b *backend) Me() []string { return b.me } 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)) ref := make(map[string]objref, len(objs)) for _, o := range objs { if o.Data == nil { continue } cals = append(cals, o.Data) for _, e := range o.Data.Events() { if uid, err := e.Props.Text(ical.PropUID); err == nil && uid != "" { ref[uid] = objref{o.Path, o.ETag} } } } evs := cal.FromCalendars(cals) b.mu.Lock() b.evs, b.sig, b.obj = evs, sig, ref 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") me = flag.String("m", "", "your addresses on this calendar, comma separated (default: -u)") dry = flag.Bool("n", false, "load and report, do not serve") once = flag.String("R", "", "answer one invitation: uid:ACCEPTED|DECLINED|TENTATIVE, then exit") ) flag.Usage = func() { fmt.Fprintf(os.Stderr, "usage: caldav/fs -e endpoint -u user -p pwfile [-C calendar] [-N name] [-m me] [-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, endpoint: *endpoint, client: client, path: coll.Path, sched: sched, user: *user, pass: pass, me: mefrom(*me, *user), 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 *once != "" { uid, want, ok := strings.Cut(*once, ":") if !ok { fatal("-R wants uid:PARTSTAT") } if _, err := be.Sync(); err != nil { fatal("%v", err) } if err := be.RSVP(uid, time.Time{}, strings.ToUpper(want)); err != nil { fatal("%v", err) } cal.Warnf("%s: %s", uid, strings.ToUpper(want)) return } if *dry { if err := s.Report(); err != nil { fatal("%v", err) } return } if err := s.Serve(); err != nil { fatal("%v", err) } } // mefrom decides which addresses count as us. The account name is the // obvious default, but you may be invited at an alias and have to be // told about it. func mefrom(list, user string) []string { if list == "" { return []string{user} } var out []string for _, a := range strings.Split(list, ",") { if a = strings.TrimSpace(a); a != "" { out = append(out, a) } } return out }