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/lib/cal | |
| 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/lib/cal')
| -rw-r--r-- | pim/lib/cal/backend.go | 23 | ||||
| -rw-r--r-- | pim/lib/cal/ical.go | 26 | ||||
| -rw-r--r-- | pim/lib/cal/tree.go | 58 |
3 files changed, 101 insertions, 6 deletions
diff --git a/pim/lib/cal/backend.go b/pim/lib/cal/backend.go index 6dcacc2..6ed488c 100644 --- a/pim/lib/cal/backend.go +++ b/pim/lib/cal/backend.go @@ -39,3 +39,26 @@ type Backend interface { type Describer interface { Describe() string } + +// Identity is implemented by a backend that knows whose calendar this +// is. In iTIP your identity is the mailto: in your own ATTENDEE line -- +// there is no separate field for it -- so without this nothing can tell +// which of an event's attendees is you. Aliases are listed because you +// may be invited at one address and reply from another. +type Identity interface { + Me() []string +} + +// RSVPer is implemented by a backend that can answer an invitation. +// +// Accepting is two writes: update your own copy, and tell the organiser. +// A CalDAV server advertising calendar-auto-schedule does both from one +// PUT. A backend that cannot do both should not implement this at all, +// so that writing to partstat fails honestly rather than doing half of +// it and looking like it worked. +// +// recurID is the zero time to answer a whole series, or the start of +// one occurrence to answer only that. +type RSVPer interface { + RSVP(uid string, recurID time.Time, partstat string) error +} diff --git a/pim/lib/cal/ical.go b/pim/lib/cal/ical.go index f52b333..5477030 100644 --- a/pim/lib/cal/ical.go +++ b/pim/lib/cal/ical.go @@ -95,29 +95,43 @@ func LoadDir(dir string) ([]*Event, error) { return nil, err } sort.Strings(names) - var evs []*Event + var cals []*ical.Calendar for _, name := range names { f, err := os.Open(name) if err != nil { Warnf("%s: %v", name, err) continue } - cal, err := ical.NewDecoder(f).Decode() + c, 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) + cals = append(cals, c) + } + return FromCalendars(cals), nil +} + +// FromCalendars turns decoded iCalendar objects into events, with +// RECURRENCE-ID overrides attached to the series they belong to. +// +// A backend that already holds parsed calendars -- CalDAV hands them +// straight back from a REPORT -- comes through here rather than writing +// them to disk first. +func FromCalendars(cals []*ical.Calendar) []*Event { + var evs []*Event + for _, c := range cals { + for _, e := range c.Events() { + ev, err := newEvent(&e) if err != nil { - Warnf("%s: %v", name, err) + Warnf("%v", err) continue } evs = append(evs, ev) } } - return link(evs), nil + return link(evs) } // link attaches RECURRENCE-ID events to the series they override. diff --git a/pim/lib/cal/tree.go b/pim/lib/cal/tree.go index cd9e9d2..f78f2eb 100644 --- a/pim/lib/cal/tree.go +++ b/pim/lib/cal/tree.go @@ -112,6 +112,7 @@ func (s *Server) buildEvents(root *fs.StaticDir, evs []*Event) { s.file(ed, "attendees", attendeeText(e.Attendees)) } s.file(ed, "uid", e.UID+"\n") + s.addPartstat(ed, e) s.file(ed, "raw", e.Raw) } } @@ -215,3 +216,60 @@ func attendeeText(as []Attendee) string { } return b.String() } + +// addPartstat publishes your own reply status for an event. +// +// Reading it says where you stand. Writing it answers the invitation -- +// but only if the backend can actually do both halves of that: update +// the calendar and tell the organiser. A published .ics can do neither, +// so there the file is read-only and says why. +func (s *Server) addPartstat(dir *fs.StaticDir, e *Event) { + var me []string + if id, ok := s.be.(Identity); ok { + me = id.Me() + } + cur := "" // no identity, or not an attendee + for _, a := range e.Attendees { + for _, m := range me { + if strings.EqualFold(a.Email, m) || strings.EqualFold(a.Name, m) { + cur = a.Partstat + } + } + } + if cur == "" && len(me) == 0 { + cur = "unknown" + } else if cur == "" { + cur = "not-an-attendee" + } + + rsvp, canWrite := s.be.(RSVPer) + mode := uint32(0444) + if canWrite { + mode = 0666 + } + st := s.fsys.NewStat("partstat", s.user, s.user, mode) + base := fs.NewStaticFile(st, []byte(cur+"\n")) + if !canWrite { + dir.AddChild(base) + return + } + uid := e.UID + dir.AddChild(&fs.WrappedFile{ + File: base, + WriteF: func(fid uint64, off uint64, data []byte) (uint32, error) { + want := strings.ToUpper(strings.TrimSpace(string(data))) + switch want { + case "ACCEPTED", "DECLINED", "TENTATIVE": + default: + return 0, fmt.Errorf("partstat: want ACCEPTED, DECLINED or TENTATIVE") + } + if len(me) == 0 { + return 0, fmt.Errorf("partstat: no me= for this calendar") + } + if err := rsvp.RSVP(uid, time.Time{}, want); err != nil { + return 0, err + } + return uint32(len(data)), nil + }, + }) +} |
