package cal import ( "fmt" "os" "path/filepath" "sort" "strings" "time" _ "time/tzdata" // TZID= resolution; 9front has no zoneinfo "github.com/emersion/go-ical" "github.com/teambition/rrule-go" ) // Event is one VEVENT, with its recurrence set resolved but not expanded. type Event struct { UID string Summary string Location string Description string Start time.Time End time.Time AllDay bool RRule string Alarms []time.Duration // relative to start; negative means before Raw string RecurID time.Time // set when this event overrides one instance Cancelled bool Organizer string Attendees []Attendee master *Event // set on an override: the series it belongs to set *rrule.Set // nil when the event does not recur overrides map[int64]*Event // by RECURRENCE-ID, unix seconds } // Attendee is one ATTENDEE line, reduced to what a person wants to see. type Attendee struct { Name string // CN Email string // the mailto: value, stripped Partstat string // NEEDS-ACTION, ACCEPTED, DECLINED, TENTATIVE Role string } // Instance is one occurrence of an Event at a concrete time. type Instance struct { Ev *Event Start time.Time End time.Time } // Duration of a single occurrence. func (e *Event) dur() time.Duration { if e.End.IsZero() || !e.End.After(e.Start) { return time.Hour } return e.End.Sub(e.Start) } // Instances returns every occurrence starting within [t0, t1). // // An overridden occurrence is emitted from the override, not from the // recurrence rule, because the override may have moved it into or out of // the window, or cancelled it outright. func (e *Event) Instances(t0, t1 time.Time) []Instance { var out []Instance in := func(t time.Time) bool { return !t.Before(t0) && t.Before(t1) } if e.set == nil { if in(e.Start) { out = append(out, Instance{e, e.Start, e.Start.Add(e.dur())}) } } else { for _, t := range e.set.Between(t0, t1, true) { if _, ok := e.overrides[t.Unix()]; ok { continue // the override speaks for this occurrence } out = append(out, Instance{e, t, t.Add(e.dur())}) } } for _, ov := range e.overrides { if ov.Cancelled || !in(ov.Start) { continue } out = append(out, Instance{ov, ov.Start, ov.Start.Add(ov.dur())}) } return out } // loadDir reads every .ics file in dir and returns the events it contains. // LoadDir reads every .ics file in dir. func LoadDir(dir string) ([]*Event, error) { names, err := filepath.Glob(filepath.Join(dir, "*.ics")) if err != nil { return nil, err } sort.Strings(names) var evs []*Event for _, name := range names { f, err := os.Open(name) if err != nil { Warnf("%s: %v", name, err) continue } cal, err := ical.NewDecoder(f).Decode() f.Close() if err != nil { Warnf("%s: %v", name, err) continue } for _, c := range cal.Events() { ev, err := newEvent(&c) if err != nil { Warnf("%s: %v", name, err) continue } evs = append(evs, ev) } } return link(evs), nil } // link attaches RECURRENCE-ID events to the series they override. // An override with no matching series is kept as an event of its own. func link(evs []*Event) []*Event { masters := make(map[string]*Event, len(evs)) for _, e := range evs { if e.RecurID.IsZero() { masters[e.UID] = e } } out := make([]*Event, 0, len(masters)) for _, e := range evs { if e.RecurID.IsZero() { out = append(out, e) continue } m, ok := masters[e.UID] if !ok { out = append(out, e) // orphan; stands alone continue } e.master = m if m.overrides == nil { m.overrides = make(map[int64]*Event) } m.overrides[e.RecurID.Unix()] = e } return out } func newEvent(ev *ical.Event) (*Event, error) { e := &Event{} e.UID, _ = ev.Props.Text(ical.PropUID) if e.UID == "" { return nil, fmt.Errorf("event with no UID") } e.Summary, _ = ev.Props.Text(ical.PropSummary) e.Location, _ = ev.Props.Text(ical.PropLocation) e.Description, _ = ev.Props.Text(ical.PropDescription) start, err := ev.DateTimeStart(time.Local) if err != nil { return nil, fmt.Errorf("%s: bad DTSTART: %v", e.UID, err) } e.Start = start if end, err := ev.DateTimeEnd(time.Local); err == nil { e.End = end } if p := ev.Props.Get(ical.PropDateTimeStart); p != nil { e.AllDay = p.ValueType() == ical.ValueDate } if p := ev.Props.Get(ical.PropRecurrenceRule); p != nil { e.RRule = p.Value } if p := ev.Props.Get(ical.PropRecurrenceID); p != nil { if t, err := parseICSTime(p.Value, time.Local); err == nil { e.RecurID = t } else if t, err := ev.Props.DateTime(ical.PropRecurrenceID, time.Local); err == nil { e.RecurID = t } } if st, err := ev.Props.Text(ical.PropStatus); err == nil { e.Cancelled = strings.EqualFold(st, "CANCELLED") } if p := ev.Props.Get(ical.PropOrganizer); p != nil { e.Organizer = person(p) } for _, p := range ev.Props.Values(ical.PropAttendee) { e.Attendees = append(e.Attendees, Attendee{ Name: p.Params.Get(ical.ParamCommonName), Email: strings.TrimPrefix(p.Value, "mailto:"), Partstat: p.Params.Get(ical.ParamParticipationStatus), Role: p.Params.Get(ical.ParamRole), }) } e.Raw = rawOf(ev.Component) e.Alarms = alarmsOf(ev.Component) if err := e.buildSet(ev); err != nil { Warnf("%s: %v", e.UID, err) } return e, nil } // buildSet assembles the recurrence set from RRULE, EXDATE and RDATE. func (e *Event) buildSet(ev *ical.Event) error { opt, err := ev.Props.RecurrenceRule() if err != nil { return fmt.Errorf("bad RRULE: %v", err) } exd := ev.Props.Values(ical.PropExceptionDates) rdt := ev.Props.Values(ical.PropRecurrenceDates) if opt == nil && len(exd) == 0 && len(rdt) == 0 { return nil } set := &rrule.Set{} set.DTStart(e.Start) if opt != nil { opt.Dtstart = e.Start r, err := rrule.NewRRule(*opt) if err != nil { return fmt.Errorf("bad RRULE: %v", err) } set.RRule(r) } for _, p := range exd { for _, t := range dateList(p, e.Start.Location()) { set.ExDate(t) } } for _, p := range rdt { for _, t := range dateList(p, e.Start.Location()) { set.RDate(t) } } e.set = set return nil } // dateList parses the comma-separated date list in EXDATE/RDATE. func dateList(p ical.Prop, loc *time.Location) []time.Time { var out []time.Time for _, s := range strings.Split(p.Value, ",") { s = strings.TrimSpace(s) if s == "" { continue } t, err := parseICSTime(s, loc) if err != nil { Warnf("bad %s %q: %v", p.Name, s, err) continue } out = append(out, t) } return out } func parseICSTime(s string, loc *time.Location) (time.Time, error) { for _, f := range []string{"20060102T150405Z", "20060102T150405", "20060102"} { l := loc if strings.HasSuffix(f, "Z") { l = time.UTC } if t, err := time.ParseInLocation(f, s, l); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("unrecognised time") } // alarmsOf returns the relative triggers of every VALARM child. // Absolute triggers are ignored for now; see doc/design.md. func alarmsOf(c *ical.Component) []time.Duration { var out []time.Duration for _, child := range c.Children { if child.Name != ical.CompAlarm { continue } p := child.Props.Get(ical.PropTrigger) if p == nil { continue } d, err := parseDuration(p.Value) if err != nil { continue } out = append(out, d) } return out } func rawOf(c *ical.Component) string { var b strings.Builder fmt.Fprintf(&b, "BEGIN:%s\n", c.Name) var names []string for name := range c.Props { names = append(names, name) } sort.Strings(names) for _, name := range names { for _, p := range c.Props[name] { fmt.Fprintf(&b, "%s:%s\n", p.Name, p.Value) } } fmt.Fprintf(&b, "END:%s\n", c.Name) return b.String() } // person renders ORGANIZER as a name, falling back to the address. func person(p *ical.Prop) string { addr := strings.TrimPrefix(p.Value, "mailto:") if cn := p.Params.Get(ical.ParamCommonName); cn != "" && cn != addr { return cn + " <" + addr + ">" } return addr }