summaryrefslogtreecommitdiff
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
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>
-rw-r--r--pim/cmd/caldavfs/main.go5
-rw-r--r--pim/cmd/caldavfs/rsvp.go44
-rw-r--r--pim/lib/cal/backend.go17
-rw-r--r--pim/lib/cal/compose.go112
-rw-r--r--pim/lib/cal/server.go86
-rwxr-xr-xpim/rc/agenda15
-rwxr-xr-xpim/rc/invite137
-rwxr-xr-xpim/rc/invites87
-rwxr-xr-xpim/rc/rsvp111
-rwxr-xr-xpim/rc/show16
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}