summaryrefslogtreecommitdiff
path: root/pim/lib/cal/compose.go
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-22 16:48:07 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-22 16:48:07 -0400
commita071bf9a56241aa743e1be21bccbb4cb72b189f2 (patch)
treeabb150135648d1cdc220ea21e8bc6ceb90dd0668 /pim/lib/cal/compose.go
parentebfe721eb065be1d33a13e03e40426c4b3adfad0 (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/cal/compose.go')
-rw-r--r--pim/lib/cal/compose.go112
1 files changed, 112 insertions, 0 deletions
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
+}