package main import ( "bytes" "errors" "fmt" "log" "math" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/emersion/go-ical" "github.com/teambition/rrule-go" ) // calendarsDir is the absolute path of the calendars folder (-calendars). // Empty disables the diary lines, the maintenance widget and the sync. Every // calendar is one .ics file in this folder; the folder's .page-settings // assigns calendars to roles and holds the CalDAV sync credentials. var calendarsDir string // === Settings === type calendarSettings struct { // Roles: calendar names (file basenames without .ics). Diary []string Weight []string Maintenance []string CaldavURL string CaldavUser string CaldavPass string SyncInterval time.Duration // 0 = startup and manual only } // readCalendarSettings reads the calendar keys from dir's .page-settings. // Read on every use, so edits apply without a restart. func readCalendarSettings(dir string) calendarSettings { kv := readSettingsFile(dir) s := calendarSettings{ Diary: splitCalendarList(kv["diary"]), Weight: splitCalendarList(kv["weight"]), Maintenance: splitCalendarList(kv["maintenance"]), CaldavURL: kv["caldav-url"], CaldavUser: kv["caldav-user"], CaldavPass: kv["caldav-pass"], SyncInterval: 15 * time.Minute, } if v, ok := kv["sync-interval"]; ok { if d, err := time.ParseDuration(v); err == nil && d >= 0 { s.SyncInterval = d } } return s } func splitCalendarList(v string) []string { var out []string for _, name := range strings.Split(v, ",") { if name = strings.TrimSpace(name); name != "" { out = append(out, name) } } return out } // calendarPath returns the file of calendar name in dir, or "" when the name // would escape the folder. func calendarPath(dir, name string) string { if name == "" || strings.ContainsAny(name, `/\`) || strings.HasPrefix(name, ".") { return "" } return filepath.Join(dir, name+".ics") } // === Model === // calEvent is one expanded occurrence. type calEvent struct { Calendar string UID string Summary string Start time.Time // local time; all-day events start at local midnight End time.Time // exclusive AllDay bool Stamp time.Time // LAST-MODIFIED, else DTSTAMP Series *calSeries // nil for single events } // calSeries describes a recurring event as a whole. type calSeries struct { Calendar string UID string Summary string Rule string // human-readable, e.g. "every 2 weeks (Sa)" } // calItem is a parsed VEVENT: a single event, a recurring master, or a // RECURRENCE-ID override attached to its master. type calItem struct { uid string summary string start time.Time end time.Time allDay bool days int // all-day length in days; keeps occurrences DST-safe stamp time.Time cancelled bool set *rrule.Set // nil = single event series *calSeries // set together with set overrides map[int64]*calItem // RECURRENCE-ID (unix) → replacement } // calFile is one parsed calendar file. type calFile struct { modTime time.Time size int64 items []*calItem events int // VEVENT components in the file } // === Store === // calStore caches parsed calendar files by path. An entry is reused while the // file's modtime and size are unchanged, so a request costs one stat per // calendar. Parsed files are never mutated, so callers may keep using a // snapshot after the lock is released. var calStore = struct { sync.Mutex files map[string]*calFile }{files: map[string]*calFile{}} // loadCalendar returns the parsed calendar at path; nil when it does not exist // or cannot be parsed (logged). func loadCalendar(path string) *calFile { info, err := os.Stat(path) if err != nil { return nil } calStore.Lock() defer calStore.Unlock() if f := calStore.files[path]; f != nil && f.modTime.Equal(info.ModTime()) && f.size == info.Size() { return f } data, err := os.ReadFile(path) if err != nil { return nil } f, err := parseCalendar(filepath.Base(path), data) if err != nil { log.Printf("calendar %s: %v", path, err) delete(calStore.files, path) return nil } f.modTime = info.ModTime() f.size = info.Size() calStore.files[path] = f return f } // calendarOccurrences expands every event of the named calendars in dir that // overlaps [from, to). The result is sorted by start, then summary. func calendarOccurrences(dir string, names []string, from, to time.Time) []calEvent { var out []calEvent for _, name := range names { p := calendarPath(dir, name) if p == "" { continue } f := loadCalendar(p) if f == nil { continue } for _, it := range f.items { out = it.expand(name, from, to, out) } } sort.SliceStable(out, func(i, j int) bool { if !out[i].Start.Equal(out[j].Start) { return out[i].Start.Before(out[j].Start) } return out[i].Summary < out[j].Summary }) return out } func (it *calItem) occurrence(calendar string, start, end time.Time) calEvent { return calEvent{ Calendar: calendar, UID: it.uid, Summary: it.summary, Start: start.In(time.Local), End: end.In(time.Local), AllDay: it.allDay, Stamp: it.stamp, } } // overlaps reports whether [start, end) intersects [from, to). Zero-length // events count when they start inside the window. func overlaps(start, end, from, to time.Time) bool { if !start.Before(to) { return false } return end.After(from) || !start.Before(from) } // expand appends the item's occurrences in [from, to) to out. func (it *calItem) expand(calendar string, from, to time.Time, out []calEvent) []calEvent { if it.cancelled { return out } if it.set == nil { if overlaps(it.start, it.end, from, to) { out = append(out, it.occurrence(calendar, it.start, it.end)) } return out } // Start the search early enough to catch occurrences that began before // the window but still run into it. after := from.Add(-it.end.Sub(it.start)).Add(-48 * time.Hour) for _, s := range it.set.Between(after, to, true) { if _, overridden := it.overrides[s.Unix()]; overridden { continue } e := it.occurrenceEnd(s) if overlaps(s, e, from, to) { ev := it.occurrence(calendar, s, e) ev.Series = it.series out = append(out, ev) } } // Overrides are placed on their own dates: a moved occurrence can enter // or leave the window independently of its original slot. for _, o := range it.overrides { if o.cancelled || !overlaps(o.start, o.end, from, to) { continue } ev := o.occurrence(calendar, o.start, o.end) ev.Series = it.series out = append(out, ev) } return out } // occurrenceEnd returns the end of the occurrence starting at s. func (it *calItem) occurrenceEnd(s time.Time) time.Time { if it.allDay { return s.AddDate(0, 0, it.days) } return s.Add(it.end.Sub(it.start)) } // === Parsing === // calParser holds per-file parse state: the file name for log messages, the // TZIDs already warned about, and resolved locations. type calParser struct { file string zones map[string]*time.Location warned map[string]bool } func parseCalendar(file string, data []byte) (*calFile, error) { data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM cal, err := ical.NewDecoder(bytes.NewReader(data)).Decode() if err != nil { return nil, err } p := &calParser{file: file, zones: map[string]*time.Location{}, warned: map[string]bool{}} f := &calFile{} masters := map[string]*calItem{} type override struct { item *calItem recurID time.Time } var overrides []override for _, comp := range cal.Children { if comp.Name != ical.CompEvent { continue } f.events++ it, recurID, err := p.item(comp) if err != nil { continue } if !recurID.IsZero() { overrides = append(overrides, override{it, recurID}) continue } f.items = append(f.items, it) if it.uid != "" { masters[it.uid] = it } } // Overrides can come before their master in the file, so attach them in // a second pass. One without a recurring master is shown as a single event. for _, o := range overrides { m := masters[o.item.uid] if m == nil || m.set == nil { f.items = append(f.items, o.item) continue } if o.item.summary == "" { o.item.summary = m.summary } m.overrides[o.recurID.Unix()] = o.item } return f, nil } // item parses one VEVENT. recurID is non-zero for RECURRENCE-ID overrides. func (p *calParser) item(comp *ical.Component) (it *calItem, recurID time.Time, err error) { startProp := comp.Props.Get(ical.PropDateTimeStart) if startProp == nil { return nil, time.Time{}, errors.New("no DTSTART") } starts, allDay, err := p.times(startProp) if err != nil || len(starts) == 0 { return nil, time.Time{}, fmt.Errorf("DTSTART: %v", err) } it = &calItem{start: starts[0], allDay: allDay} it.uid = calText(comp.Props.Get(ical.PropUID)) it.summary = strings.TrimSpace(calText(comp.Props.Get(ical.PropSummary))) it.end = it.start if prop := comp.Props.Get(ical.PropDateTimeEnd); prop != nil { if ends, _, err := p.times(prop); err == nil && len(ends) > 0 { it.end = ends[0] } } else if prop := comp.Props.Get(ical.PropDuration); prop != nil { if d, err := prop.Duration(); err == nil { if allDay { it.end = it.start.AddDate(0, 0, int(math.Round(d.Hours()/24))) } else { it.end = it.start.Add(d) } } } else if allDay { it.end = it.start.AddDate(0, 0, 1) } if it.end.Before(it.start) { it.end = it.start } if allDay { it.days = calendarDaysBetween(it.start, it.end) if it.days < 1 { it.days = 1 } it.end = it.start.AddDate(0, 0, it.days) } if strings.EqualFold(calText(comp.Props.Get(ical.PropStatus)), "CANCELLED") { it.cancelled = true } for _, name := range []string{ical.PropLastModified, ical.PropDateTimeStamp} { if prop := comp.Props.Get(name); prop != nil { if ts, _, err := p.times(prop); err == nil && len(ts) > 0 { it.stamp = ts[0] break } } } if prop := comp.Props.Get(ical.PropRecurrenceID); prop != nil { ids, _, err := p.times(prop) if err != nil || len(ids) == 0 { return nil, time.Time{}, fmt.Errorf("RECURRENCE-ID: %v", err) } return it, ids[0], nil } rule := comp.Props.Get(ical.PropRecurrenceRule) rdates := comp.Props.Values(ical.PropRecurrenceDates) if rule == nil && len(rdates) == 0 { return it, time.Time{}, nil } set := &rrule.Set{} ruleText := "" if rule != nil { opt, err := rrule.StrToROptionInLocation(rule.Value, it.start.Location()) if err != nil { p.warn("rrule:"+rule.Value, "cannot parse RRULE %q of %q: %v", rule.Value, it.summary, err) return it, time.Time{}, nil } opt.Dtstart = it.start r, err := rrule.NewRRule(*opt) if err != nil { p.warn("rrule:"+rule.Value, "cannot build RRULE %q of %q: %v", rule.Value, it.summary, err) return it, time.Time{}, nil } set.RRule(r) ruleText = describeRRule(opt) } else { // Without an RRULE the set only holds RDATEs; DTSTART is always the // first occurrence. set.RDate(it.start) ruleText = "on selected dates" } for i := range rdates { if ts, _, err := p.times(&rdates[i]); err == nil { for _, t := range ts { set.RDate(t) } } } for _, prop := range comp.Props.Values(ical.PropExceptionDates) { if ts, _, err := p.times(&prop); err == nil { for _, t := range ts { set.ExDate(t) } } } it.set = set it.overrides = map[int64]*calItem{} it.series = &calSeries{ Calendar: strings.TrimSuffix(p.file, ".ics"), UID: it.uid, Summary: it.summary, Rule: ruleText, } return it, time.Time{}, nil } // calText unescapes a TEXT value. Unlike go-ical's Text it keeps unescaped // commas: clients write titles like "95,6kg" without the required backslash, // and go-ical would cut them at the comma. Unknown escapes are kept as is. func calText(prop *ical.Prop) string { if prop == nil { return "" } v := prop.Value if !strings.Contains(v, `\`) { return v } var sb strings.Builder for i := 0; i < len(v); i++ { c := v[i] if c != '\\' || i+1 == len(v) { sb.WriteByte(c) continue } i++ switch n := v[i]; n { case 'n', 'N': sb.WriteByte('\n') case '\\', ';', ',': sb.WriteByte(n) default: sb.WriteByte('\\') sb.WriteByte(n) } } return sb.String() } // times parses a DATE or DATE-TIME property. Comma-separated lists (EXDATE, // RDATE) yield several values. Dates and floating times use the local zone; an // unknown TZID falls back to it with a warning. func (p *calParser) times(prop *ical.Prop) ([]time.Time, bool, error) { loc := time.Local if tzid := prop.Params.Get(ical.PropTimezoneID); tzid != "" { loc = p.zone(tzid) } var out []time.Time allDay := false for _, v := range strings.Split(prop.Value, ",") { v = strings.TrimSpace(v) var t time.Time var err error switch { case len(v) == 8: t, err = time.ParseInLocation("20060102", v, time.Local) allDay = true case len(v) == 16 && strings.HasSuffix(v, "Z"): t, err = time.Parse("20060102T150405Z", v) case len(v) == 15: t, err = time.ParseInLocation("20060102T150405", v, loc) default: err = fmt.Errorf("unsupported value %q", v) } if err != nil { return nil, false, err } out = append(out, t) } return out, allDay, nil } func (p *calParser) zone(tzid string) *time.Location { if loc, ok := p.zones[tzid]; ok { return loc } loc, err := time.LoadLocation(tzid) if err != nil { // Some clients prefix the IANA name, e.g. // "/freeassociation.sourceforge.net/Tzfile/Europe/Berlin". parts := strings.Split(strings.Trim(tzid, "/"), "/") if len(parts) >= 2 { loc, err = time.LoadLocation(strings.Join(parts[len(parts)-2:], "/")) } } if err != nil { p.warn("tz:"+tzid, "unknown TZID %q, using local time", tzid) loc = time.Local } p.zones[tzid] = loc return loc } func (p *calParser) warn(key, format string, args ...any) { if p.warned[key] { return } p.warned[key] = true log.Printf("calendar %s: "+format, append([]any{p.file}, args...)...) } // calendarDaysBetween counts calendar days from a to b (both local midnights), // independent of DST shifts. func calendarDaysBetween(a, b time.Time) int { ay, am, ad := a.Date() by, bm, bd := b.Date() ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC) ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC) return int(ub.Sub(ua).Hours() / 24) } // === Formatting === var germanWeekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"} // calDayLabel formats a date as "Sa 27.09.". func calDayLabel(t time.Time) string { return fmt.Sprintf("%s %02d.%02d.", germanWeekdays[t.Weekday()], t.Day(), int(t.Month())) } // describeRRule turns common rules into short text ("weekly (Mo)", // "every 2 weeks (Sa)", "monthly (day 1)"). Anything with parts it does not // know falls back to the raw rule. func describeRRule(opt *rrule.ROption) string { if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 { return opt.RRuleString() } units := map[rrule.Frequency][2]string{ rrule.DAILY: {"daily", "days"}, rrule.WEEKLY: {"weekly", "weeks"}, rrule.MONTHLY: {"monthly", "months"}, rrule.YEARLY: {"yearly", "years"}, } u, ok := units[opt.Freq] if !ok { return opt.RRuleString() } text := u[0] if opt.Interval > 1 { text = fmt.Sprintf("every %d %s", opt.Interval, u[1]) } var details []string for _, wd := range opt.Byweekday { name := germanWeekdays[(wd.Day()+1)%7] if n := wd.N(); n != 0 { name = strconv.Itoa(n) + ". " + name if n == -1 { name = "last " + germanWeekdays[(wd.Day()+1)%7] } } details = append(details, name) } for _, d := range opt.Bymonthday { details = append(details, fmt.Sprintf("day %d", d)) } for _, m := range opt.Bymonth { if m >= 1 && m <= 12 { details = append(details, germanMonths[time.Month(m)]) } } if len(details) > 0 { text += " (" + strings.Join(details, ", ") + ")" } return text } // === Diary lines === // calDayLines is the calendar content shown under a diary day. type calDayLines struct { Events string // "Feiertag, 10:00 Brunch, 19:30 Kino" Weight string // "95,6 kg" } var weightTitle = regexp.MustCompile(`(?i)^\s*(\d+(?:[.,]\d+)?)\s*kg\s*$`) // calendarDayLines builds the diary lines for every day in [from, to), keyed // by "YYYY-MM-DD". Events come from the diary role, weights from the weight // role. Days without either are absent. func calendarDayLines(from, to time.Time) map[string]calDayLines { out := map[string]calDayLines{} if calendarsDir == "" { return out } s := readCalendarSettings(calendarsDir) type dayEvents struct{ allDay, timed []calEvent } byDay := map[string]*dayEvents{} for _, ev := range calendarOccurrences(calendarsDir, s.Diary, from, to) { if ev.AllDay { // Multi-day all-day events show on every day they cover. for d := ev.Start; d.Before(ev.End); d = d.AddDate(0, 0, 1) { key := d.Format("2006-01-02") if byDay[key] == nil { byDay[key] = &dayEvents{} } byDay[key].allDay = append(byDay[key].allDay, ev) } continue } key := ev.Start.Format("2006-01-02") if byDay[key] == nil { byDay[key] = &dayEvents{} } byDay[key].timed = append(byDay[key].timed, ev) } for key, d := range byDay { sort.SliceStable(d.allDay, func(i, j int) bool { return d.allDay[i].Summary < d.allDay[j].Summary }) var parts []string for _, ev := range d.allDay { if ev.Summary != "" { parts = append(parts, ev.Summary) } } // Timed events are already sorted by start, then summary. for _, ev := range d.timed { parts = append(parts, strings.TrimSpace(ev.Start.Format("15:04")+" "+ev.Summary)) } if len(parts) > 0 { out[key] = calDayLines{Events: strings.Join(parts, ", ")} } } stamps := map[string]time.Time{} for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) { m := weightTitle.FindStringSubmatch(ev.Summary) if m == nil { continue } key := ev.Start.Format("2006-01-02") // Several entries on one day: the last edited one wins. if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) { continue } stamps[key] = ev.Stamp lines := out[key] lines.Weight = strings.ReplaceAll(m[1], ".", ",") + " kg" out[key] = lines } return out }