Add Calendar integration (read-only)
This commit is contained in:
+666
@@ -0,0 +1,666 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user