summaryrefslogtreecommitdiff
path: root/pim/lib/cal/ical.go
diff options
context:
space:
mode:
Diffstat (limited to 'pim/lib/cal/ical.go')
-rw-r--r--pim/lib/cal/ical.go317
1 files changed, 317 insertions, 0 deletions
diff --git a/pim/lib/cal/ical.go b/pim/lib/cal/ical.go
new file mode 100644
index 0000000..f52b333
--- /dev/null
+++ b/pim/lib/cal/ical.go
@@ -0,0 +1,317 @@
+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
+}