diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-08-22 16:48:07 -0400 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-08-22 16:48:07 -0400 |
| commit | a071bf9a56241aa743e1be21bccbb4cb72b189f2 (patch) | |
| tree | abb150135648d1cdc220ea21e8bc6ceb90dd0668 /pim/lib | |
| parent | ebfe721eb065be1d33a13e03e40426c4b3adfad0 (diff) | |
pim: invite, and a way to say what you mean
pim/invite makes an event and, when it names attendees, sends the
invitation -- which on a caldav server with auto-schedule is the same
act. -p writes the object to standard output instead, so it can go to
upas/marshal as iMIP, into a directory an ical/fs is serving, or
nowhere in particular. There is no separate verb for inviting because
to a calendar there is no separate thing.
It writes the tree's own key: value form rather than icalendar. The
first version spelled SUMMARY: by hand and got "lunch, then a walk"
wrong: a comma is a separator there and wants escaping. Parsing with
go-ical and generating by hand is the wrong asymmetry, so calfs grew
Compose, and now the same shape the tree emits is the shape it accepts.
The one UID we ever mint -- as organiser we own the event -- is minted
there too.
pim/invites lists what is waiting for an answer, which is what makes
pim/rsvp usable: it walks the date tree rather than every event, since
an invitation you never answered last March is not news.
agenda and show take -d date now, via seconds(1), which parses a human
date where date(1) only formats one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'pim/lib')
| -rw-r--r-- | pim/lib/cal/backend.go | 17 | ||||
| -rw-r--r-- | pim/lib/cal/compose.go | 112 | ||||
| -rw-r--r-- | pim/lib/cal/server.go | 86 |
3 files changed, 214 insertions, 1 deletions
diff --git a/pim/lib/cal/backend.go b/pim/lib/cal/backend.go index 6ed488c..91f793a 100644 --- a/pim/lib/cal/backend.go +++ b/pim/lib/cal/backend.go @@ -1,6 +1,10 @@ package cal -import "time" +import ( + "time" + + "github.com/emersion/go-ical" +) // A Backend supplies a calendar's events and, where the protocol allows // it, accepts changes back. @@ -49,6 +53,17 @@ type Identity interface { Me() []string } +// Creator is implemented by a backend that can add an event. +// +// It takes a whole object rather than fields, because that is what the +// protocols underneath take: CalDAV PUTs an entire icalendar object and +// JMAP patches a whole JSCalendar one. Building an event field by field +// and hoping the server knows when you have finished is a shape that +// fits neither. +type Creator interface { + Create(c *ical.Calendar) error +} + // RSVPer is implemented by a backend that can answer an invitation. // // Accepting is two writes: update your own copy, and tell the organiser. diff --git a/pim/lib/cal/compose.go b/pim/lib/cal/compose.go new file mode 100644 index 0000000..1960696 --- /dev/null +++ b/pim/lib/cal/compose.go @@ -0,0 +1,112 @@ +package cal + +import ( + "bufio" + "fmt" + "strconv" + "strings" + "time" + + "github.com/emersion/go-ical" +) + +// Compose builds an event from the same key: value form the tree emits. +// +// summary: lunch, then a walk; maybe +// start: 1787500800 +// end: 1787504400 +// location: the wire +// attendee: bob@example.com +// attendee: carol@example.com +// organizer: you@example.com +// description: anything +// +// Accepting this rather than raw icalendar keeps the escaping where the +// parser already lives. A shell script writing SUMMARY: by hand has to +// know that a comma means something, and will eventually forget; here +// it writes what it means and go-ical spells it. +// +// Times are epoch seconds, because that is what date(1) hands a script. +func Compose(r *bufio.Reader, uid string) (*ical.Calendar, error) { + f := map[string][]string{} + for { + line, err := r.ReadString('\n') + if line = strings.TrimRight(line, "\r\n"); line != "" { + k, v, ok := strings.Cut(line, ":") + if !ok { + return nil, fmt.Errorf("compose: %q is not key: value", line) + } + k = strings.ToLower(strings.TrimSpace(k)) + f[k] = append(f[k], strings.TrimSpace(v)) + } + if err != nil { + break + } + } + + one := func(k string) string { + if v := f[k]; len(v) > 0 { + return v[0] + } + return "" + } + at := func(k string) (time.Time, error) { + v := one(k) + if v == "" { + return time.Time{}, fmt.Errorf("compose: no %s", k) + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("compose: %s: want epoch seconds, got %q", k, v) + } + return time.Unix(n, 0).UTC(), nil + } + + if one("summary") == "" { + return nil, fmt.Errorf("compose: no summary") + } + start, err := at("start") + if err != nil { + return nil, err + } + end, err := at("end") + if err != nil { + return nil, err + } + + ev := ical.NewEvent() + ev.Props.SetText(ical.PropUID, uid) + ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) + ev.Props.SetDateTime(ical.PropDateTimeStart, start) + ev.Props.SetDateTime(ical.PropDateTimeEnd, end) + ev.Props.SetText(ical.PropSummary, one("summary")) + if v := one("location"); v != "" { + ev.Props.SetText(ical.PropLocation, v) + } + if v := one("description"); v != "" { + ev.Props.SetText(ical.PropDescription, v) + } + if v := one("organizer"); v != "" { + p := ical.NewProp(ical.PropOrganizer) + p.Value = "mailto:" + v + ev.Props.Set(p) + // the organiser is attending their own meeting + a := ical.NewProp(ical.PropAttendee) + a.Value = "mailto:" + v + a.Params.Set(ical.ParamParticipationStatus, "ACCEPTED") + ev.Props.Add(a) + } + for _, who := range f["attendee"] { + a := ical.NewProp(ical.PropAttendee) + a.Value = "mailto:" + who + a.Params.Set(ical.ParamParticipationStatus, "NEEDS-ACTION") + a.Params.Set("RSVP", "TRUE") + ev.Props.Add(a) + } + + c := ical.NewCalendar() + c.Props.SetText(ical.PropProductID, "-//9front//pim//EN") + c.Props.SetText(ical.PropVersion, "2.0") + c.Children = append(c.Children, ev.Component) + return c, nil +} diff --git a/pim/lib/cal/server.go b/pim/lib/cal/server.go index f9da81e..4b588c5 100644 --- a/pim/lib/cal/server.go +++ b/pim/lib/cal/server.go @@ -14,6 +14,10 @@ package cal import ( + "bufio" + "bytes" + "crypto/rand" + "encoding/hex" "fmt" "os" "sort" @@ -22,6 +26,7 @@ import ( "sync" "time" + "github.com/emersion/go-ical" "github.com/knusbaum/go9p" "github.com/knusbaum/go9p/fs" ) @@ -81,6 +86,7 @@ func New(be Backend, c Config) *Server { s.addAlarm() s.addQuery() s.addChanged() + s.addNew() return s } @@ -323,3 +329,83 @@ func orNone(s string) string { } return s } + +// addNew publishes the file you write an event to. +// +// cat invite.ics >/mnt/pim/calendars/work/new +// +// Writes are buffered and the object is created on close, because that +// is when the thing you are writing is complete -- cp and cat both do +// create, write, clunk, so the commit point falls out of 9p rather than +// having to be invented. +func (s *Server) addNew() { + create, ok := s.be.(Creator) + if !ok { + return // a backend that cannot add events does not offer the file + } + var mu sync.Mutex + buf := map[uint64][]byte{} + + st := s.fsys.NewStat("new", s.user, s.user, 0222) + base := fs.NewStaticFile(st, []byte("")) + s.root.AddChild(&fs.WrappedFile{ + File: base, + WriteF: func(fid uint64, off uint64, data []byte) (uint32, error) { + mu.Lock() + buf[fid] = append(buf[fid], data...) + mu.Unlock() + return uint32(len(data)), nil + }, + CloseF: func(fid uint64) error { + mu.Lock() + b := buf[fid] + delete(buf, fid) + mu.Unlock() + if len(b) == 0 { + return nil + } + // Either a whole icalendar object, or the key: value form + // the tree itself emits. The second exists so a script + // never has to spell iCalendar, where a comma in a summary + // is a syntax error waiting to happen. + var c *ical.Calendar + var err error + if bytes.HasPrefix(bytes.TrimSpace(b), []byte("BEGIN:VCALENDAR")) { + c, err = ical.NewDecoder(bytes.NewReader(b)).Decode() + } else { + c, err = Compose(bufio.NewReader(bytes.NewReader(b)), newUID()) + } + if err != nil { + Warnf("new: %v", err) + return err + } + if len(c.Events()) == 0 { + Warnf("new: no VEVENT") + return fmt.Errorf("new: no VEVENT") + } + if err := create.Create(c); err != nil { + Warnf("new: %v", err) + return err + } + if _, err := s.be.Sync(); err == nil { + s.Reload() + } + return nil + }, + }) +} + +// newUID mints an identifier for an event we are creating. This is the +// one place a UID is generated rather than echoed back: as organiser we +// own the event, so we name it. +func newUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%d@pim", time.Now().UnixNano()) + } + host, _ := os.Hostname() + if host == "" { + host = "pim" + } + return hex.EncodeToString(b[:]) + "@" + host +} |
