package cal import ( "fmt" "sort" "strings" "time" "github.com/knusbaum/go9p/fs" "github.com/knusbaum/go9p/proto" ) // slug makes a string safe to use as one path element. func slug(s string) string { s = strings.TrimSpace(s) var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) case r == '-', r == '.', r == '_': b.WriteRune(r) case r == ' ': b.WriteRune('-') default: b.WriteRune('_') } } out := b.String() if out == "" { out = "unnamed" } if len(out) > 64 { out = out[:64] } return out } // uniqueName returns name, or name-2, name-3 ... if it is already taken. // Real calendars do collide: two events at the same minute with the same // summary, or several orphaned overrides sharing one UID. func uniqueName(parent *fs.StaticDir, name string) string { kids := parent.Children() if _, taken := kids[name]; !taken { return name } for i := 2; ; i++ { try := fmt.Sprintf("%s-%d", name, i) if _, taken := kids[try]; !taken { return try } } } func (s *Server) file(dir *fs.StaticDir, name, content string) { st := s.fsys.NewStat(name, s.user, s.user, 0444) dir.AddChild(fs.NewStaticFile(st, []byte(content))) } func (s *Server) subdir(parent *fs.StaticDir, name string) *fs.StaticDir { if c, ok := parent.Children()[name]; ok { if d, ok := c.(*fs.StaticDir); ok { return d } } st := s.fsys.NewStat(name, s.user, s.user, 0555|proto.DMDIR) d := fs.NewStaticDir(st) parent.AddChild(d) return d } func tfmt(t time.Time, allDay bool) string { if allDay { return t.Format("2006-01-02") } return t.Format(time.RFC3339) } // buildEvents populates events/uuid// with one directory per event. func (s *Server) buildEvents(root *fs.StaticDir, evs []*Event) { d := s.subdir(s.subdir(root, "events"), "uuid") for _, e := range evs { name := slug(e.UID) if !e.RecurID.IsZero() { // an override with no series of its own to hang under name += "-" + e.RecurID.Format("20060102T150405") } name = uniqueName(d, name) if s.evdir == nil { s.evdir = make(map[*Event]string) } s.evdir[e] = name ed := s.subdir(d, name) s.file(ed, "summary", e.Summary+"\n") s.file(ed, "start", tfmt(e.Start, e.AllDay)+"\n") if !e.End.IsZero() { s.file(ed, "end", tfmt(e.End, e.AllDay)+"\n") } if e.Location != "" { s.file(ed, "location", e.Location+"\n") } if e.Description != "" { s.file(ed, "description", e.Description+"\n") } if e.RRule != "" { s.file(ed, "rrule", e.RRule+"\n") } if e.Organizer != "" { s.file(ed, "organizer", e.Organizer+"\n") } if len(e.Attendees) > 0 { s.file(ed, "attendees", attendeeText(e.Attendees)) } s.file(ed, "uid", e.UID+"\n") s.addPartstat(ed, e) s.file(ed, "raw", e.Raw) } } // buildWhen populates events/date/YYYY/MM/DD/ with one file per occurrence. func (s *Server) buildWhen(root *fs.StaticDir, insts []Instance) { w := s.subdir(s.subdir(root, "events"), "date") for _, in := range insts { // File by local wall-clock time. Events arrive in a mix of // zones -- TZID=America/New_York here, UTC there -- and if the // path keeps each event's own zone then a day's files neither // sort by time nor land on the right day. All-day events are // floating and must not be shifted. st := in.Start if !in.Ev.AllDay { st = st.Local() } y := s.subdir(w, st.Format("2006")) m := s.subdir(y, st.Format("01")) d := s.subdir(m, st.Format("02")) // The first field is always four digits so that shell tools can // compare it numerically; all-day events sort to the top of the // day and are still marked as such. name := st.Format("1504") + "-" + slug(in.Ev.Summary) if in.Ev.AllDay { name = "0000-allday-" + slug(in.Ev.Summary) } // An overridden occurrence belongs to its series' directory. ev := in.Ev if ev.master != nil { ev = ev.master } fname := uniqueName(d, name) s.file(d, fname, instText(in, s.evdir[ev])) s.index = append(s.index, index{ // relative to the server's root: it cannot know where it // has been mounted, and under /mnt/pim/calendars/ // it would guess wrong path: fmt.Sprintf("events/date/%s/%s", st.Format("2006/01/02"), fname), in: in, }) } } func instText(in Instance, evdir string) string { var b strings.Builder fmt.Fprintf(&b, "summary: %s\n", in.Ev.Summary) fmt.Fprintf(&b, "start: %s\n", tfmt(in.Start, in.Ev.AllDay)) fmt.Fprintf(&b, "end: %s\n", tfmt(in.End, in.Ev.AllDay)) if in.Ev.Location != "" { fmt.Fprintf(&b, "location: %s\n", in.Ev.Location) } // Epoch seconds as well as RFC3339: rc has no way to parse the // latter, but date(1) formats the former. fmt.Fprintf(&b, "epoch: %d\n", in.Start.Unix()) fmt.Fprintf(&b, "epochend: %d\n", in.End.Unix()) fmt.Fprintf(&b, "uid: %s\n", in.Ev.UID) // 9P2000 has no symlinks, so publish the path instead. A tool must // never have to reproduce the server's slug rules to find this. if evdir != "" { fmt.Fprintf(&b, "event: ../../../../uuid/%s\n", evdir) } if in.Ev.RRule != "" { fmt.Fprintf(&b, "rrule: %s\n", in.Ev.RRule) } if in.Ev.Description != "" { fmt.Fprintf(&b, "\n%s\n", strings.TrimRight(in.Ev.Description, "\n")) } return b.String() } // expand returns every instance in [t0,t1), sorted by start time. func expand(evs []*Event, t0, t1 time.Time) []Instance { var out []Instance for _, e := range evs { out = append(out, e.Instances(t0, t1)...) } sort.Slice(out, func(i, j int) bool { if out[i].Start.Equal(out[j].Start) { return out[i].Ev.Summary < out[j].Ev.Summary } return out[i].Start.Before(out[j].Start) }) return out } // attendeeText is one attendee per line: status, name, address. func attendeeText(as []Attendee) string { var b strings.Builder for _, a := range as { st := a.Partstat if st == "" { st = "UNKNOWN" } name := a.Name if name == "" { name = a.Email } fmt.Fprintf(&b, "%-12s\t%s\t%s\n", st, name, a.Email) } return b.String() } // addPartstat publishes your own reply status for an event. // // Reading it says where you stand. Writing it answers the invitation -- // but only if the backend can actually do both halves of that: update // the calendar and tell the organiser. A published .ics can do neither, // so there the file is read-only and says why. func (s *Server) addPartstat(dir *fs.StaticDir, e *Event) { var me []string if id, ok := s.be.(Identity); ok { me = id.Me() } cur := "" // no identity, or not an attendee for _, a := range e.Attendees { for _, m := range me { if strings.EqualFold(a.Email, m) || strings.EqualFold(a.Name, m) { cur = a.Partstat } } } if cur == "" && len(me) == 0 { cur = "unknown" } else if cur == "" { cur = "not-an-attendee" } rsvp, canWrite := s.be.(RSVPer) mode := uint32(0444) if canWrite { mode = 0666 } st := s.fsys.NewStat("partstat", s.user, s.user, mode) base := fs.NewStaticFile(st, []byte(cur+"\n")) if !canWrite { dir.AddChild(base) return } uid := e.UID dir.AddChild(&fs.WrappedFile{ File: base, WriteF: func(fid uint64, off uint64, data []byte) (uint32, error) { want := strings.ToUpper(strings.TrimSpace(string(data))) switch want { case "ACCEPTED", "DECLINED", "TENTATIVE": default: return 0, fmt.Errorf("partstat: want ACCEPTED, DECLINED or TENTATIVE") } if len(me) == 0 { return 0, fmt.Errorf("partstat: no me= for this calendar") } if err := rsvp.RSVP(uid, time.Time{}, want); err != nil { return 0, err } return uint32(len(data)), nil }, }) }