summaryrefslogtreecommitdiff
path: root/pim/lib
diff options
context:
space:
mode:
Diffstat (limited to 'pim/lib')
-rw-r--r--pim/lib/cal/backend.go17
-rw-r--r--pim/lib/cal/compose.go112
-rw-r--r--pim/lib/cal/server.go86
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
+}