diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-08-22 13:16:49 -0400 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-08-22 13:16:49 -0400 |
| commit | 8a3d2c99bff60cb775ebc42516c0c3912d54ba3d (patch) | |
| tree | fd136a3b7927146763cdc11e6be07c71f92bb92c /pim/lib/cal/ical.go | |
| parent | 9aacce8b3b54060d0037eca897856d7273e6e5e8 (diff) | |
pim: personal information management, starting with a calendar
A calendar as a file tree, and tools that only know the tree:
events/date/yyyy/mm/dd/hhmm-summary as lived
events/uuid/<uid>/ as stored
ctl query alarm changed
lib/cal owns all of that. A backend supplies events and, where its
protocol allows, takes changes back -- six methods. cmd/icalfs is the
first: it reads .ics files from a directory and nothing else, because
fetching is rc/fetch's job and hget already exists. That keeps
net/http out of the binary and makes a subscribed calendar and a local
one the same thing.
The tools are rc on purpose. If the tree needs a compiled program to be
useful, the tree is the wrong shape. Three things were added to the
tree because the rc port needed them: a path from an occurrence to its
event, epoch seconds beside RFC3339, and a numeric slot for all-day
events so test(1) can compare it.
ctl reports caps, so a tool can say "read only" instead of trying and
failing. A published .ics is read only: nowhere to PUT, and no
METHOD:REQUEST to reply to. CalDAV would be read write rsvp schedule,
and that is the next backend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'pim/lib/cal/ical.go')
| -rw-r--r-- | pim/lib/cal/ical.go | 317 |
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 +} |
