summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pim/cmd/caldavfs/discover.go130
-rw-r--r--pim/cmd/caldavfs/http.go14
-rw-r--r--pim/cmd/caldavfs/main.go239
-rw-r--r--pim/cmd/caldavfs/principal.go66
-rw-r--r--pim/cmd/icalfs/main.go10
-rw-r--r--pim/go.mod1
-rw-r--r--pim/go.sum4
-rw-r--r--pim/lib/cal/backend.go23
-rw-r--r--pim/lib/cal/ical.go26
-rw-r--r--pim/lib/cal/tree.go58
-rwxr-xr-xpim/rc/pimup71
-rwxr-xr-xpim/rc/riostart20
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 }
diff --git a/pim/go.mod b/pim/go.mod
index 242ba7b..7a24a64 100644
--- a/pim/go.mod
+++ b/pim/go.mod
@@ -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
diff --git a/pim/go.sum b/pim/go.sum
index 0154ae4..35ddfe6 100644
--- a/pim/go.sum
+++ b/pim/go.sum
@@ -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