diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-08-22 14:04:17 -0400 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-08-22 14:04:17 -0400 |
| commit | 08155ead7bf80308b4cb67063cc91e3e3679ce16 (patch) | |
| tree | b0c0673b3e7f20d318695ec1992ef1cdf47e284d /pim/cmd/caldavfs/main.go | |
| parent | 8a3d2c99bff60cb775ebc42516c0c3912d54ba3d (diff) | |
pim: a caldav backend, and a partstat that refuses to lie
caldav/fs is the second backend, and it cost ~330 lines: discovery,
a report for the window, ETags to decide whether anything moved. The
event model, recurrence expansion, the tree, ctl, query, alarm and
changed all came from lib/cal untouched, which is what the split was
for. go-webdav hands back *ical.Calendar from the same library lib/cal
parses with, so FromCalendars takes it straight in.
Two interfaces, both optional. Identity says whose calendar this is,
because in iTIP your identity is the mailto: in your own ATTENDEE line
and nothing else can tell which attendee is you. RSVPer says the
backend can answer an invitation -- meaning both halves of it, updating
your copy and telling the organiser. A backend that can only do one
should not implement it, so partstat is 0444 on a published .ics and
writing to it fails rather than half-working.
Two bugs worth naming. go-webdav resolves paths with path.Join, which
drops a trailing slash: pointed at /dav/ it asks about /dav, and Cyrus
answers 405 for that spelling and 207 for the other, so the first
PROPFIND is done by hand. And an absolute DAV href replaces the
endpoint's path rather than extending it; appending gave
/dav/dav/calendars/..., a 404, and a probe reading 404 as "no" hid
calendar-auto-schedule, which fastmail does in fact offer.
pimup brings mail and the calendars up and mounts them; riostart runs
it before opening any window, since a mount only exists in the
namespace that made it. rio does not run riostart by itself: it is the
argument to -i.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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) + } +} |
