667 lines
18 KiB
Go
667 lines
18 KiB
Go
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 <Name>.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
|
|
}
|