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.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() }