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 | |
| 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>
| -rw-r--r-- | pim/cmd/caldavfs/discover.go | 130 | ||||
| -rw-r--r-- | pim/cmd/caldavfs/http.go | 14 | ||||
| -rw-r--r-- | pim/cmd/caldavfs/main.go | 239 | ||||
| -rw-r--r-- | pim/cmd/caldavfs/principal.go | 66 | ||||
| -rw-r--r-- | pim/cmd/icalfs/main.go | 10 | ||||
| -rw-r--r-- | pim/go.mod | 1 | ||||
| -rw-r--r-- | pim/go.sum | 4 | ||||
| -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 | ||||
| -rwxr-xr-x | pim/rc/pimup | 71 | ||||
| -rwxr-xr-x | pim/rc/riostart | 20 |
12 files changed, 656 insertions, 6 deletions
diff --git a/pim/cmd/caldavfs/discover.go b/pim/cmd/caldavfs/discover.go new file mode 100644 index 0000000..2b5fb23 --- /dev/null +++ b/pim/cmd/caldavfs/discover.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/emersion/go-webdav" + + "github.com/emersion/go-webdav/caldav" + "pim/lib/cal" +) + +// dial builds an authenticated client and finds the calendar to serve. +// +// The walk is the one CalDAV prescribes: the endpoint tells you your +// principal, the principal tells you where your calendars live, and +// that collection lists them. Naming a calendar by its display name +// beats hardcoding a path, which servers are free to change. +func dial(endpoint, user, pass, want string) (*caldav.Client, caldav.Calendar, bool, error) { + var zero caldav.Calendar + + hc := webdav.HTTPClientWithBasicAuth(nil, user, pass) + c, err := caldav.NewClient(hc, endpoint) + if err != nil { + return nil, zero, false, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + principal, err := findPrincipal(endpoint, user, pass) + if err != nil { + return nil, zero, false, fmt.Errorf("principal: %v", err) + } + home, err := c.FindCalendarHomeSet(ctx, principal) + if err != nil { + return nil, zero, false, fmt.Errorf("home set: %v", err) + } + cals, err := c.FindCalendars(ctx, home) + if err != nil { + return nil, zero, false, fmt.Errorf("calendars: %v", err) + } + if len(cals) == 0 { + return nil, zero, false, fmt.Errorf("no calendars under %s", home) + } + + sched := autoSchedule(endpoint, user, pass, home) + cal.Warnf("home %s, auto-schedule %v", home, sched) + + if want == "" { + // No choice made: list them and refuse, rather than pick one + // and have it silently be the wrong calendar. + var names []string + for _, cl := range cals { + names = append(names, cl.Name) + } + return nil, zero, sched, fmt.Errorf("which calendar? -C one of: %s", + strings.Join(names, ", ")) + } + for _, cl := range cals { + if cl.Name == want || cl.Path == want { + return c, cl, sched, nil + } + } + return nil, zero, sched, fmt.Errorf("no calendar named %q", want) +} + +// autoSchedule asks whether the server sends iMIP on our behalf. A +// server that does means writing a PARTSTAT both updates our copy and +// tells the organiser; a server that does not means we must send the +// mail ourselves. +func autoSchedule(endpoint, user, pass, path string) bool { + req, err := newRequest("OPTIONS", endpoint, path) + if err != nil { + return false + } + req.SetBasicAuth(user, pass) + resp, err := httpDo(req) + if err != nil { + return false + } + defer resp.Body.Close() + for _, v := range resp.Header.Values("DAV") { + for _, f := range strings.Split(v, ",") { + if strings.TrimSpace(f) == "calendar-auto-schedule" { + return true + } + } + } + return false +} + +// urlJoin resolves a DAV href against the endpoint. +// +// An href from the server is absolute on that server: it replaces the +// endpoint's path rather than extending it. Appending it instead gives +// /dav/dav/calendars/... which answers 404, and a probe that reads a +// 404 as "no" reports a capability the server actually has. +func urlJoin(endpoint, href string) string { + base, err := url.Parse(endpoint) + if err != nil { + return href + } + ref, err := url.Parse(href) + if err != nil { + return href + } + return base.ResolveReference(ref).String() +} + +// readSecret reads a password from a file, so it stays out of argv where +// ps(1) would show it. +func readSecret(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + return strings.Trim(line, `"'`), nil + } + return "", fmt.Errorf("%s: empty", path) +} diff --git a/pim/cmd/caldavfs/http.go b/pim/cmd/caldavfs/http.go new file mode 100644 index 0000000..a49baf8 --- /dev/null +++ b/pim/cmd/caldavfs/http.go @@ -0,0 +1,14 @@ +package main + +import ( + "net/http" + "time" +) + +var client = &http.Client{Timeout: 2 * time.Minute} + +func newRequest(method, endpoint, path string) (*http.Request, error) { + return http.NewRequest(method, urlJoin(endpoint, path), nil) +} + +func httpDo(req *http.Request) (*http.Response, error) { return client.Do(req) } 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) + } +} diff --git a/pim/cmd/caldavfs/principal.go b/pim/cmd/caldavfs/principal.go new file mode 100644 index 0000000..b5d6558 --- /dev/null +++ b/pim/cmd/caldavfs/principal.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/xml" + "fmt" + "net/http" + "strings" +) + +// findPrincipal asks the endpoint who we are. +// +// go-webdav does this too, but it resolves the request path with +// path.Join, which drops a trailing slash: a client pointed at /dav/ +// ends up asking about /dav, and Cyrus answers 405 for that exact +// spelling while answering 207 for /dav/. Deeper paths tolerate either, +// so only this first request needs doing by hand. +func findPrincipal(endpoint, user, pass string) (string, error) { + body := `<?xml version="1.0" encoding="utf-8"?>` + + `<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>` + + req, err := http.NewRequest("PROPFIND", endpoint, strings.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Depth", "0") + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + req.SetBasicAuth(user, pass) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMultiStatus { + return "", fmt.Errorf("%s: %s", endpoint, resp.Status) + } + + // Spelled out rather than using encoding/xml's "a>b" path + // shorthand, which does not carry namespaces through. + type href struct { + Href string `xml:"DAV: href"` + } + type prop struct { + Principal href `xml:"DAV: current-user-principal"` + } + type propstat struct { + Prop prop `xml:"DAV: prop"` + } + type response struct { + Propstats []propstat `xml:"DAV: propstat"` + } + var ms struct { + Responses []response `xml:"DAV: response"` + } + if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { + return "", err + } + for _, r := range ms.Responses { + for _, ps := range r.Propstats { + if h := strings.TrimSpace(ps.Prop.Principal.Href); h != "" { + return h, nil + } + } + } + return "", fmt.Errorf("%s: no current-user-principal", endpoint) +} diff --git a/pim/cmd/icalfs/main.go b/pim/cmd/icalfs/main.go index c155252..774a9b1 100644 --- a/pim/cmd/icalfs/main.go +++ b/pim/cmd/icalfs/main.go @@ -52,6 +52,16 @@ func (b *backend) Name() string { // A published feed can only be read. See pim/doc/design.md. func (b *backend) Caps() string { return "read" } +// Me is whose calendar this is, from the config. It makes partstat +// readable -- you can see where you stand -- without making it +// writable, because nothing here can deliver a reply. +func (b *backend) Me() []string { + if b.c == nil { + return nil + } + return b.c.Me +} + // Refresh is how often to re-stat the directory. Whoever writes the // files should poke ctl instead; this only catches a hand edit. func (b *backend) Refresh() time.Duration { return b.poll } @@ -11,6 +11,7 @@ require ( github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d // indirect github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/emersion/go-webdav v0.7.0 // indirect github.com/fhs/mux9p v0.3.1 // indirect github.com/knusbaum/go9p v1.18.0 // indirect github.com/teambition/rrule-go v1.8.2 // indirect @@ -3,10 +3,14 @@ github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d h1:xH/U6K+HYxh1480TkQYRqRO8F2RJsg+R6wFiVJzdldg= github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d/go.mod h1:UKp8dv9aeaZoQFWin7eQXtz89iHly1YAFZNn3MCutmQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY= github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= +github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= github.com/fhs/mux9p v0.3.1 h1:x1UswUWZoA9vrA02jfisndCq3xQm+wrQUxUt5N99E08= github.com/fhs/mux9p v0.3.1/go.mod h1:F4hwdenmit0WDoNVT2VMWlLJrBVCp/8UhzJa7scfjEQ= github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok= 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 + }, + }) +} diff --git a/pim/rc/pimup b/pim/rc/pimup new file mode 100755 index 0000000..1d7b2de --- /dev/null +++ b/pim/rc/pimup @@ -0,0 +1,71 @@ +#!/bin/rc +# pimup -- bring up mail and the calendars, and mount them here. +# +# Safe to run twice: anything already posted in /srv is reused rather +# than started again. Run it from riostart so every window inherits the +# mounts, since a mount only exists in the namespace that made it. +rfork e + +cald=/usr/glenda/lib/cal +log=/tmp/pimup.log +>$log + +# Wait for a server to announce itself and echo the name it posted. +# rc has no return, so this loops on a condition rather than bailing out. +fn waitsrv { + s=() + i=0 + while(~ $#s 0 && test $i -lt 40){ + s=`{sed -n 's|.*serving /srv/||p' $1} + if(~ $#s 0){ + sleep 1 + i=`{echo $i + 1 | bc} + } + } + echo $"s +} + +# ---- mail +if(! test -e /srv/upasfs.$user) + upas/fs -s -f /imaps/imap.fastmail.com/calvin@pobox.com \ + </dev/null >/dev/null >>[2]$log & +sleep 2 +if(test -e /srv/upasfs.$user) + mount /srv/upasfs.$user /mail/fs >>[2]$log + +# ---- mount points for the calendars +if(! test -e /srv/pimroot) + mntgen -s pimroot /mnt/pim </dev/null >/dev/null >[2]/dev/null & +sleep 1 +mount /srv/pimroot /mnt/pim >[2]/dev/null +ls -d /mnt/pim/calendars >/dev/null >[2]/dev/null +if(! test -e /srv/pimcals) + mntgen -s pimcals /mnt/pim/calendars </dev/null >/dev/null >[2]/dev/null & +sleep 1 +mount /srv/pimcals /mnt/pim/calendars >[2]/dev/null + +# ---- calendars. Go's getpid() on plan9 is a thread id, so the name rc +# knows as $apid is not the one in /srv: read it out of the log. +fn cal { + name=$1 + l=$2 + shift + shift + if(! test -f /mnt/pim/calendars/$name/ctl){ + >$l + $* >[2]$l </dev/null >/dev/null & + s=`{waitsrv $l} + if(~ $#s 0) + echo 'pimup: '^$name^' did not start, see '^$l >>$log + if(! ~ $#s 0) + mount /srv/^$"s /mnt/pim/calendars/$name >>[2]$log + } +} + +cal work /tmp/work.log ical/fs -N work -d $cald +cal local /tmp/local.log ical/fs -N local -d /tmp/callocal +if(test -f $cald/fastmail.pw) + cal fastmail /tmp/dav.log caldav/fs -e https://caldav.fastmail.com/dav/ \ + -u calvin@pobox.com -p $cald/fastmail.pw -C Calendar -N fastmail + +exit 0 diff --git a/pim/rc/riostart b/pim/rc/riostart new file mode 100755 index 0000000..7384503 --- /dev/null +++ b/pim/rc/riostart @@ -0,0 +1,20 @@ +#!/bin/rc +# riostart -- what rio brings up. +# +# rio does not run this by itself: it is the argument to -i. +# rio -i riostart +rfork e + +# servers and mounts first, so every window below inherits them +pimup + +window 0,0,161,117 stats -lmisce +window bar + +# a shell on the serial console, when there is one +~ $#console 0 || window -scroll console + +window -r 170 0 900 640 acme +window -r 170 650 1000 1000 cal9 + +window -miny 130 |
