diff options
| -rw-r--r-- | pim/cmd/caldavfs/main.go | 5 | ||||
| -rw-r--r-- | pim/cmd/caldavfs/rsvp.go | 44 | ||||
| -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 | ||||
| -rwxr-xr-x | pim/rc/agenda | 15 | ||||
| -rwxr-xr-x | pim/rc/invite | 137 | ||||
| -rwxr-xr-x | pim/rc/invites | 87 | ||||
| -rwxr-xr-x | pim/rc/rsvp | 111 | ||||
| -rwxr-xr-x | pim/rc/show | 16 |
10 files changed, 623 insertions, 7 deletions
diff --git a/pim/cmd/caldavfs/main.go b/pim/cmd/caldavfs/main.go index 7a0a4d3..de37566 100644 --- a/pim/cmd/caldavfs/main.go +++ b/pim/cmd/caldavfs/main.go @@ -67,7 +67,7 @@ func (b *backend) Name() string { return b.name } func (b *backend) Sched() bool { return b.sched } func (b *backend) Caps() string { - c := "read rsvp" + c := "read write rsvp" if b.sched { // the server sends the iMIP for us; one PUT does both halves c += " schedule" @@ -90,6 +90,9 @@ func (b *backend) Status() (time.Time, string) { func (b *backend) Describe() string { s := fmt.Sprintf("collection %s\n", b.path) s += fmt.Sprintf("autoschedule %v\n", b.sched) + if len(b.me) > 0 { + s += "me " + strings.Join(b.me, ",") + "\n" + } return s } diff --git a/pim/cmd/caldavfs/rsvp.go b/pim/cmd/caldavfs/rsvp.go index 47d7425..25015a7 100644 --- a/pim/cmd/caldavfs/rsvp.go +++ b/pim/cmd/caldavfs/rsvp.go @@ -149,3 +149,47 @@ func quoteETag(s string) string { } return `"` + s + `"` } + +// Create adds an event to the collection. +// +// The path is ours to choose, so it is derived from the UID: a server +// that already has that object will replace it, which is what a second +// PUT of the same event should do. With calendar-auto-schedule the +// server mails every ATTENDEE for us, so creating an event with +// attendees is the whole of sending an invitation. +func (b *backend) Create(c *ical.Calendar) error { + var uid string + for _, e := range c.Events() { + if u, err := e.Props.Text(ical.PropUID); err == nil && u != "" { + uid = u + break + } + } + if uid == "" { + return fmt.Errorf("create: the event has no UID") + } + + name := slugUID(uid) + ".ics" + path := strings.TrimSuffix(b.path, "/") + "/" + name + // no etag: this is a create, and If-Match on nothing is meaningless + return b.put(objref{path: path}, c) +} + +// slugUID makes a UID safe as one path element. +func slugUID(s string) string { + var out []rune + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + out = append(out, r) + case r == '-', r == '.', r == '_': + out = append(out, r) + default: + out = append(out, '-') + } + } + if len(out) > 100 { + out = out[:100] + } + return string(out) +} 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 +} diff --git a/pim/rc/agenda b/pim/rc/agenda index ec38ad2..563758f 100755 --- a/pim/rc/agenda +++ b/pim/rc/agenda @@ -6,6 +6,7 @@ mtpt=/mnt/pim days=7 off=0 only=() +start=() while(~ $1 -*){ switch($1){ @@ -15,10 +16,18 @@ while(~ $1 -*){ days=$2; shift case -o off=$2; shift + case -d + # seconds(1) parses a human date; date(1) only formats one + start=`{seconds $2} + if(~ $#start 0){ + echo 'pim/agenda: cannot read the date '^$2 >[1=2] + exit baddate + } + shift case -c only=($only $2); shift case * - echo 'usage: agenda [-m mtpt] [-n days] [-o dayoffset] [-c cal]' >[1=2] + echo 'usage: agenda [-m mtpt] [-n days] [-d date] [-o dayoffset] [-c cal]' >[1=2] exit usage } shift @@ -39,7 +48,9 @@ if(! ~ $#cals 1) tmp=/tmp/agenda.$pid fn sigexit { rm -f $tmp } -now=`{date -n} +now=$start +if(~ $#now 0) + now=`{date -n} i=$off last=`{echo $off + $days | bc} n=0 diff --git a/pim/rc/invite b/pim/rc/invite new file mode 100755 index 0000000..cf2e69c --- /dev/null +++ b/pim/rc/invite @@ -0,0 +1,137 @@ +#!/bin/rc +# pim/invite -- make an event, and invite people to it. +# +# pim/invite -c fastmail 'catch up' '2026-08-28 15:00' 30 +# pim/invite -c fastmail -a bob@example.com -l 'the wire' \ +# 'design review' 'tomorrow 14:00' 60 +# +# What this really does is write an icalendar object. Where it goes is +# somebody else's business: +# +# -p writes it to standard output, so you can mail it yourself +# (upas/marshal -t 'text/calendar; method=REQUEST'), keep it, +# or drop it in a directory some ical/fs is serving +# otherwise it goes to the calendar's new file, and a backend with +# calendar-auto-schedule mails every attendee for you +# +# So there is no separate verb for "invite": to a calendar there is no +# separate thing, and to everything else it is just a file. +rfork e + +mtpt=/mnt/pim +print=() +cal=() +att=() +loc=() +desc=() + +while(~ $1 -*){ + switch($1){ + case -m + mtpt=$2; shift + case -c + cal=$2; shift + case -a + att=($att $2); shift + case -l + loc=$2; shift + case -D + desc=$2; shift + case -p + print=1 + case * + echo 'usage: invite [-m mtpt] [-c cal] [-a attendee]... [-l where] [-D text] [-p] summary start [minutes]' >[1=2] + exit usage + } + shift +} +if(test $#* -lt 2){ + echo 'usage: invite [-m mtpt] [-c cal] [-a attendee]... [-l where] [-D text] summary start [minutes]' >[1=2] + exit usage +} +summary=$1 +when=$2 +mins=30 +if(! ~ $#* 2) + mins=$3 + +# with -p the object goes to standard output and no calendar is needed +if(! ~ $#print 0){ + if(~ $#cal 0) + cal=`{pim/calendars -m $mtpt | sed 1q} +} +if(~ $#print 0 && ~ $#cal 0){ + for(c in `{pim/calendars -m $mtpt}){ + caps=`{sed -n 's/^caps //p' $mtpt/calendars/$c/ctl >[2]/dev/null} + for(x in $caps) + if(~ $x write) + cal=($cal $c) + } + if(~ $#cal 0){ + echo 'pim/invite: no calendar here can create events' >[1=2] + exit nowhere + } + if(! ~ $#cal 1){ + echo 'pim/invite: which calendar? -c one of: '^$"cal >[1=2] + exit ambiguous + } +} +d=$mtpt/calendars/$"cal +if(~ $#print 0) + if(! test -f $d/new){ + echo 'pim/invite: '^$"cal^' cannot create events' >[1=2] + exit readonly + } + +start=`{seconds $"when} +if(~ $#start 0){ + echo 'pim/invite: cannot read the time '^$"when >[1=2] + exit baddate +} +# organiser is whoever this calendar says we are +me=`{sed -n 's/^me //p' $d/ctl >[2]/dev/null | sed 's/,.*//'} +if(~ $#att 0) + me=() +if(! ~ $#att 0) + if(~ $#me 0){ + echo 'pim/invite: '^$"cal^' has no me=; cannot say who is organising' >[1=2] + exit nome + } + +# The event is written as key: value, the same form the tree emits and +# calfs composes from. A script spelling SUMMARY: by hand has to know +# that a comma means something there, and will eventually forget; this +# way go-ical does the spelling and the escaping. +end=`{echo $"start + $"mins '*' 60 | bc} + +tmp=/tmp/invite.$pid +fn sigexit { rm -f $tmp } +{ + echo 'summary: '^$"summary + echo 'start: '^$"start + echo 'end: '^$"end + if(! ~ $#loc 0) + echo 'location: '^$"loc + if(! ~ $#desc 0) + echo 'description: '^$"desc + if(! ~ $#att 0){ + echo 'organizer: '^$"me + for(a in $att) + echo 'attendee: '^$a + } +} >$tmp + +if(! ~ $#print 0){ + cat $tmp + exit 0 +} +cat $tmp >$d/new +if(! ~ $status ''){ + echo 'pim/invite: '^$"status >[1=2] + exit failed +} +w=`{date -f 'WWW DD MMM hh:mm' $"start} +echo $"summary^': '^$"w^' on '^$"cal +if(! ~ $#att 0) + echo 'invited: '^$"att +exit 0 diff --git a/pim/rc/invites b/pim/rc/invites new file mode 100755 index 0000000..de193ed --- /dev/null +++ b/pim/rc/invites @@ -0,0 +1,87 @@ +#!/bin/rc +# pim/invites -- what is waiting for your answer. +# +# pim/invites the next thirty days +# pim/invites -n 7 just this week +# pim/invites -a everything, answered or not +# +# Only upcoming events are looked at: an invitation you never answered +# for a meeting last March is not a thing you need to see. Walking the +# date tree rather than every event also keeps this to a few dozen reads +# instead of a few thousand. +rfork e + +mtpt=/mnt/pim +days=30 +only=() +all=() + +while(~ $1 -*){ + switch($1){ + case -m + mtpt=$2; shift + case -n + days=$2; shift + case -c + only=($only $2); shift + case -a + all=1 + case * + echo 'usage: invites [-m mtpt] [-n days] [-c cal] [-a]' >[1=2] + exit usage + } + shift +} + +cals=$only +if(~ $#cals 0) + cals=`{pim/calendars -m $mtpt} + +seen=/tmp/invites.seen.$pid +out=/tmp/invites.out.$pid +fn sigexit { rm -f $seen $out } +>$seen +>$out + +now=`{date -n} +i=0 +while(test $i -lt $days){ + sec=`{echo $now + $i '*' 86400 | bc} + day=`{date -f YYYY/MM/DD $sec} + for(c in $cals){ + d=$mtpt/calendars/$c/events/date/$day + if(test -d $d) + for(f in $d/*){ + ev=`{sed -n 's/^event: //p' $f} + if(! ~ $#ev 0){ + e=`{cleanname $d/$"ev} + # one line per event, not per occurrence + if(! grep -s '^'^$"e^'$' $seen){ + echo $"e >>$seen + if(test -f $e/partstat){ + st=`{cat $e/partstat} + show=() + if(~ $"st NEEDS-ACTION) + show=1 + if(! ~ $#all 0) + show=1 + if(! ~ $#show 0){ + ep=`{sed -n 's/^epoch: //p' $f} + when=`{date -f 'WWW DD MMM hh:mm' $"ep} + sum=`{sed -n 's/^summary: //p' $f} + echo $"ep^' '^$"when^' '^$"st^' '^$"sum^' '^$c >>$out + } + } + } + } + } + } + i=`{echo $i + 1 | bc} +} + +if(! test -s $out){ + echo 'nothing waiting in the next '^$"days^' days' + exit 0 +} +sort -n $out | awk -F' ' '{printf "%-22s %-13s %-9s %s\n", $2, $3, $5, $4}' +exit 0 diff --git a/pim/rc/rsvp b/pim/rc/rsvp new file mode 100755 index 0000000..0e21f04 --- /dev/null +++ b/pim/rc/rsvp @@ -0,0 +1,111 @@ +#!/bin/rc +# pim/rsvp -- answer an invitation. +# +# pim/rsvp accepted 'meet with ben' +# pim/rsvp declined /mnt/pim/calendars/work/events/date/2026/08/24/1000-API-WG +# +# Takes an occurrence path, as pim/agenda -p prints and cal9 plumbs, or a +# pattern to search for. The answer goes to the event's partstat, and +# what happens next is the backend's business: a caldav server with +# calendar-auto-schedule updates your copy and mails the organiser, and +# a published .ics refuses outright, which is the honest answer there. +rfork e + +mtpt=/mnt/pim +days=90 +only=() + +while(~ $1 -*){ + switch($1){ + case -m + mtpt=$2; shift + case -c + only=($only $2); shift + case -n + days=$2; shift + case * + echo 'usage: rsvp [-m mtpt] [-c cal] [-n days] accepted|declined|tentative path|pattern' >[1=2] + exit usage + } + shift +} +if(test $#* -lt 2){ + echo 'usage: rsvp [-m mtpt] [-c cal] [-n days] accepted|declined|tentative path|pattern' >[1=2] + exit usage +} + +want=`{echo $1 | tr a-z A-Z} +shift +switch($want){ +case ACCEPTED DECLINED TENTATIVE + ; +case * + echo 'pim/rsvp: want accepted, declined or tentative' >[1=2] + exit usage +} +pat=$"* + +# an occurrence path names one outright; anything else is a search +occ=() +if(test -f $"pat) + occ=$"pat +if(~ $#occ 0){ + hits=`{pim/show -m $mtpt -n $days $only -a $"pat >[2]/dev/null | sed -n 's/^at *//p'} + if(~ $#hits 0){ + echo 'pim/rsvp: nothing matching '^$"pat >[1=2] + exit notfound + } + # A recurring event matches once per occurrence, and the answer + # applies to the series, so count distinct events rather than hits. + evs=() + for(h in $hits){ + hd=`{basename -d $h} + he=`{sed -n 's/^event: //p' $h} + if(! ~ $#he 0) + evs=($evs `{cleanname $hd/$"he}) + } + evs=`{echo $evs | tr ' ' '\n' | sort -u} + if(! ~ $#evs 1){ + echo 'pim/rsvp: '^$#evs^' events match; be more specific:' >[1=2] + for(x in $evs) + echo ' '^$x >[1=2] + exit ambiguous + } + occ=$hits(1) +} + +# the occurrence points at its event; 9P has no symlinks, so it is a path +d=`{basename -d $"occ} +ev=`{sed -n 's/^event: //p' $"occ} +if(~ $#ev 0){ + echo 'pim/rsvp: '^$"occ^' has no event: line' >[1=2] + exit noevent +} +e=`{cleanname $d/$"ev} + +if(! test -f $e/partstat){ + echo 'pim/rsvp: '^$"e^' has no partstat' >[1=2] + exit nopartstat +} + +# Say why before the write fails. A published .ics has nowhere to put a +# reply and no invitation to reply to, and "permission denied" does not +# explain that. +c=`{echo $"e | sed 's|(/.*/calendars/[^/]+)/.*|\1|'} +caps=`{sed -n 's/^caps //p' $"c/ctl >[2]/dev/null} +# ~ takes its first argument as the subject, so `~ $caps rsvp` asks +# whether "read" is "rsvp". Walk the list instead. +can=() +for(x in $caps) + if(~ $x rsvp) + can=1 +if(! ~ $#caps 0) + if(~ $#can 0){ + echo 'pim/rsvp: '^`{basename $"c}^' is '^$"caps^', it cannot answer invitations' >[1=2] + exit readonly + } + +echo $want >$e/partstat +sum=`{sed -n 's/^summary: //p' $"occ} +echo $"sum^': '^$want +exit 0 diff --git a/pim/rc/show b/pim/rc/show index da23973..b22ca82 100755 --- a/pim/rc/show +++ b/pim/rc/show @@ -9,6 +9,7 @@ mtpt=/mnt/pim days=90 all=() only=() +start=() while(~ $1 -*){ switch($1){ @@ -20,14 +21,21 @@ while(~ $1 -*){ all=1 case -c only=($only $2); shift + case -d + start=`{seconds $2} + if(~ $#start 0){ + echo 'pim/show: cannot read the date '^$2 >[1=2] + exit baddate + } + shift case * - echo 'usage: show [-m mtpt] [-n days] [-c cal] [-a] path|pattern' >[1=2] + echo 'usage: show [-m mtpt] [-n days] [-d date] [-c cal] [-a] path|pattern' >[1=2] exit usage } shift } if(~ $#* 0){ - echo 'usage: show [-m mtpt] [-n days] [-c cal] [-a] path|pattern' >[1=2] + echo 'usage: show [-m mtpt] [-n days] [-d date] [-c cal] [-a] path|pattern' >[1=2] exit usage } pat=$"* @@ -76,7 +84,9 @@ fn search { cals=$only if(~ $#cals 0) cals=`{pim/calendars -m $mtpt} - now=`{date -n} + now=$start + if(~ $#now 0) + now=`{date -n} i=0 while(test $i -lt $days){ sec=`{echo $now + $i '*' 86400 | bc} |
