summaryrefslogtreecommitdiff
path: root/pim/cmd/cal9/invite.go
diff options
context:
space:
mode:
Diffstat (limited to 'pim/cmd/cal9/invite.go')
-rw-r--r--pim/cmd/cal9/invite.go242
1 files changed, 242 insertions, 0 deletions
diff --git a/pim/cmd/cal9/invite.go b/pim/cmd/cal9/invite.go
new file mode 100644
index 0000000..25dcb06
--- /dev/null
+++ b/pim/cmd/cal9/invite.go
@@ -0,0 +1,242 @@
+package main
+
+// The invite view: a window you fill in to make an event.
+//
+// cal9 -v invite [calendar]
+//
+// It writes the calendar's new file in the tree's own key: value form,
+// so nothing here knows what iCalendar looks like -- calfs composes it,
+// and a comma in a summary stays a comma. With attendees on a backend
+// that schedules, writing the file is also sending the invitation.
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path"
+ "strings"
+
+ "time"
+
+ "9front/gui/draw"
+ "9front/gui/ui"
+)
+
+type form struct {
+ cal string
+ fields []*ui.Field
+ focus int
+ note string // what happened, shown under the buttons
+}
+
+func newForm(cal string) *form {
+ return &form{
+ cal: cal,
+ fields: []*ui.Field{
+ {Label: "summary"},
+ {Label: "when"},
+ {Label: "minutes", Value: "30"},
+ {Label: "where"},
+ {Label: "attendees"},
+ },
+ }
+}
+
+func (s *state) drawInvite(r draw.Rectangle) {
+ s.Fill(r, "bg")
+ f := s.form
+ lh := int32(s.F.Height) + 8
+
+ y := r.Min.Y + pad
+ s.Text(draw.Point{X: r.Min.X + pad, Y: y}, "ink", "new event on "+f.cal)
+ y += lh
+
+ for i, fl := range f.fields {
+ w := r.Max.X - pad
+ // the when field gets a button that opens the picker
+ if fl.Label == "when" {
+ bw := s.F.Width("pick") + 20
+ w -= bw + 6
+ s.Button(draw.Rect(w+6, y, w+6+bw, y+lh-4), "pick", false, s.pickWhen)
+ }
+ s.Draw(fl, draw.Rect(r.Min.X+pad, y, w, y+lh-4), i == f.focus)
+ n := i
+ s.On(fl.R, func() { f.focus = n; s.redraw() })
+ y += lh
+ }
+
+ y += 6
+ x := r.Min.X + pad
+ for _, b := range []struct {
+ label string
+ do func()
+ }{
+ {"Create", func() { s.create() }},
+ {"Clear", func() {
+ for _, fl := range f.fields {
+ fl.Value = ""
+ }
+ f.fields[2].Value = "30"
+ f.note = ""
+ s.redraw()
+ }},
+ } {
+ do := b.do
+ bw := s.F.Width(b.label) + 20
+ br := draw.Rect(x, y, x+bw, y+lh)
+ s.Button(br, b.label, false, do)
+ x = br.Max.X + 6
+ }
+ y += lh + 6
+
+ if f.note != "" {
+ s.Text(draw.Point{X: r.Min.X + pad, Y: y}, "ink", s.Fit(f.note, r.Dx()-pad*2))
+ y += lh
+ }
+ s.Text(draw.Point{X: r.Min.X + pad, Y: r.Max.Y - int32(s.F.Height) - pad}, "rule",
+ "tab moves, ^U clears a field, when: 2026-08-28 15:00")
+ s.D.Flush()
+}
+
+// pickWhen opens datepick in a window of its own and waits for it.
+//
+// A window started through wctl has nowhere useful to print -- nothing
+// is reading its standard output -- so datepick is asked to leave the
+// answer in a file, and a goroutine watches for it. That keeps the form
+// answering the mouse while the picker is up.
+func (s *state) pickWhen() {
+ tmp := fmt.Sprintf("/tmp/cal9.pick.%d", os.Getpid())
+ os.Remove(tmp)
+ s.New(360, 320, "datepick", "-t", "-o", tmp)
+ go func() {
+ for i := 0; i < 600; i++ { // two minutes is long enough to choose
+ if b, err := os.ReadFile(tmp); err == nil && len(b) > 0 {
+ os.Remove(tmp)
+ s.picked <- strings.TrimSpace(string(b))
+ return
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ }()
+}
+
+// create writes the form to the calendar's new file. The fs composes
+// the icalendar; this only has to say what it means.
+func (s *state) create() {
+ f := s.form
+ get := func(i int) string { return strings.TrimSpace(f.fields[i].Value) }
+
+ if get(0) == "" || get(1) == "" {
+ f.note = "summary and when are needed"
+ s.redraw()
+ return
+ }
+ start, err := seconds(get(1))
+ if err != nil {
+ f.note = "cannot read the time: " + get(1)
+ s.redraw()
+ return
+ }
+ mins := int64(30)
+ fmt.Sscan(get(2), &mins)
+ if mins <= 0 {
+ mins = 30
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "summary: %s\n", get(0))
+ fmt.Fprintf(&b, "start: %d\n", start)
+ fmt.Fprintf(&b, "end: %d\n", start+mins*60)
+ if v := get(3); v != "" {
+ fmt.Fprintf(&b, "location: %s\n", v)
+ }
+ if v := get(4); v != "" {
+ me := calMe(path.Join(mtpt, "calendars", f.cal))
+ if me == "" {
+ f.note = f.cal + " has no me=; cannot say who is organising"
+ s.redraw()
+ return
+ }
+ fmt.Fprintf(&b, "organizer: %s\n", me)
+ for _, a := range strings.FieldsFunc(v, func(r rune) bool {
+ return r == ',' || r == ' '
+ }) {
+ fmt.Fprintf(&b, "attendee: %s\n", a)
+ }
+ }
+
+ nw := path.Join(mtpt, "calendars", f.cal, "new")
+ fd, err := os.OpenFile(nw, os.O_WRONLY, 0)
+ if err != nil {
+ f.note = err.Error()
+ s.redraw()
+ return
+ }
+ _, err = fd.WriteString(b.String())
+ if cerr := fd.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ f.note = err.Error()
+ } else {
+ f.note = "created: " + get(0)
+ for _, fl := range f.fields {
+ fl.Value = ""
+ }
+ f.fields[2].Value = "30"
+ }
+ s.redraw()
+}
+
+// calMe reads whose calendar this is, the same line pim/invite reads.
+func calMe(dir string) string {
+ b, err := os.ReadFile(path.Join(dir, "ctl"))
+ if err != nil {
+ return ""
+ }
+ for _, l := range strings.Split(string(b), "\n") {
+ if v, ok := strings.CutPrefix(l, "me "); ok {
+ if i := strings.IndexByte(v, ','); i >= 0 {
+ v = v[:i]
+ }
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+// writable names the calendars that can take a new event.
+func writable() []string {
+ var out []string
+ for _, c := range calendars() {
+ b, err := os.ReadFile(path.Join(mtpt, "calendars", c, "ctl"))
+ if err != nil {
+ continue
+ }
+ for _, l := range strings.Split(string(b), "\n") {
+ if v, ok := strings.CutPrefix(l, "caps "); ok {
+ for _, f := range strings.Fields(v) {
+ if f == "write" {
+ out = append(out, c)
+ }
+ }
+ }
+ }
+ }
+ return out
+}
+
+// seconds parses a human date the way seconds(1) does, by asking it.
+// Reimplementing tmparse here would be a second answer to a question
+// the system already answers.
+func seconds(when string) (int64, error) {
+ out, err := exec.Command("/bin/seconds", when).Output()
+ if err != nil {
+ return 0, err
+ }
+ var n int64
+ if _, err := fmt.Sscan(strings.TrimSpace(string(out)), &n); err != nil {
+ return 0, err
+ }
+ return n, nil
+}