4f012e350c
parseDT stamped a zoned or floating DTSTART as UTC while every window around it is built in local time, so the two sides of every comparison were in different frames. On a +03 box a 22:00 local event parsed as 22:00Z, past the end of the local day, and the whole evening dropped out of the busy gate and the day plan. A 13:00 Moscow meeting read on a +04 box was recited at 17:00 next to its own printed 13:00. DTSTART now resolves three ways: a Z suffix is UTC, a TZID is loaded from the zone database, and a floating value is read in the caller's location. tzdata is embedded because the deploy image carries none, and a silent fallback to the box offset is the bug being fixed. FactKey and FactValue stamp the owner's clock, so the key date the store range-scans is the same day the plan asks for. FactSummary drops the time tail for callers that print the hour themselves. Found in review of #56 and #58.
171 lines
6.1 KiB
Go
171 lines
6.1 KiB
Go
// Package calendar is the one calendar data model the rest of maven shares:
|
|
// an Event, the iCal text it is parsed from and rendered to, and the fact
|
|
// encoding that puts it in the store.
|
|
//
|
|
// It exists because three separate features read or write the same events and
|
|
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
|
|
// #126/#127), the write-only render target that publishes maven's own
|
|
// reminders as a calendar (#127), and the day plan that recites them (#128).
|
|
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
|
|
// format was a Sprintf in two places.
|
|
//
|
|
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
|
|
// impurity, the way internal/morning and internal/loop do.
|
|
package calendar
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Fact sources. A calendar event reaches the store as a
|
|
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
|
|
// the source is the whole provenance story:
|
|
//
|
|
// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
|
|
// state stays in sqlite; the calendar is a render target (#127).
|
|
// - SourceWork — a work calendar, read-only by definition (#126). Nothing in
|
|
// maven ever writes to it: no code path pairs this source with a PUT.
|
|
// - SourceAmbient — inferred from an Android notification-listener relay
|
|
// rather than read from a server (#126). Confidence is below 1.0 because a
|
|
// notification is a signal about an event, not the event.
|
|
const (
|
|
SourcePersonal = "poll:caldav"
|
|
SourceWork = "poll:caldav:work"
|
|
SourceAmbient = "ambient:notif"
|
|
)
|
|
|
|
// AmbientConfidence — the confidence a notification-derived event is stored
|
|
// with. A parsed notification line is evidence, not a reading of the calendar,
|
|
// so it must never be indistinguishable from one (#126).
|
|
const AmbientConfidence = 0.6
|
|
|
|
// Sources lists every source a calendar event may legitimately carry, for the
|
|
// store query that reads the calendar back out. Ordered from most to least
|
|
// trusted.
|
|
func Sources() []string {
|
|
return []string{SourcePersonal, SourceWork, SourceAmbient}
|
|
}
|
|
|
|
// ReadOnlySource reports whether events from this source may never be written
|
|
// back. The work calendar is read-only by definition — see #126: maven holding
|
|
// a credential that can write to an employer's calendar is the thing the task
|
|
// exists to avoid.
|
|
func ReadOnlySource(source string) bool {
|
|
return source == SourceWork || source == SourceAmbient
|
|
}
|
|
|
|
// Event — one calendar entry. UID is the iCal UID when the event was parsed
|
|
// from a server and the identity maven renders under when it publishes one;
|
|
// Start/End are instants. All-day events are not modelled: the busy gate and
|
|
// the day plan both need a time of day, and an all-day marker answers neither.
|
|
type Event struct {
|
|
UID string
|
|
Summary string
|
|
Start time.Time
|
|
End time.Time
|
|
}
|
|
|
|
// FactKey is the store key for an event: one key per day per summary, stable
|
|
// across polls so re-reading an unchanged calendar rewrites nothing.
|
|
//
|
|
// The date prefix is load-bearing — store.CalendarEvents selects a day range
|
|
// by key prefix, not by a timestamp column — and it is the OWNER's day, taken
|
|
// on the box clock. An event carries the zone its server stated it in, so
|
|
// keying off the event's own location would file a 21:00 Moscow meeting under
|
|
// a different date than the day plan asks for.
|
|
func FactKey(e Event) string { return FactKeyIn(e, time.Local) }
|
|
|
|
// FactKeyIn is FactKey against an explicit location.
|
|
func FactKeyIn(e Event, loc *time.Location) string {
|
|
return fmt.Sprintf("calendar_event_%s_%s", e.Start.In(loc).Format("20060102"), safeKey(e.Summary))
|
|
}
|
|
|
|
// FactValue is the human-readable rendering stored as the fact value, and the
|
|
// string the day plan and the query path read back. Times are the owner's wall
|
|
// clock, for the same reason the key date is.
|
|
func FactValue(e Event) string { return FactValueIn(e, time.Local) }
|
|
|
|
// FactValueIn is FactValue against an explicit location.
|
|
func FactValueIn(e Event, loc *time.Location) string {
|
|
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.In(loc).Format("15:04"), e.End.In(loc).Format("15:04"))
|
|
}
|
|
|
|
// FactSummary strips the "@ HH:MM-HH:MM" tail FactValue appends, for a caller
|
|
// that prints the time itself. The day plan does: without this it renders
|
|
// "14:00 — Standup @ 14:00-14:30" and says the hour twice.
|
|
func FactSummary(value string) string {
|
|
i := strings.LastIndex(value, " @ ")
|
|
if i < 0 {
|
|
return value
|
|
}
|
|
tail := value[i+len(" @ "):]
|
|
if len(tail) != len("15:04-15:04") {
|
|
return value
|
|
}
|
|
for j, r := range tail {
|
|
switch j {
|
|
case 2, 8:
|
|
if r != ':' {
|
|
return value
|
|
}
|
|
case 5:
|
|
if r != '-' {
|
|
return value
|
|
}
|
|
default:
|
|
if r < '0' || r > '9' {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return value[:i]
|
|
}
|
|
|
|
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
|
// range-scans between two of these.
|
|
func KeyPrefixForDay(day time.Time) string {
|
|
return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
|
|
}
|
|
|
|
// Busy reports whether any event covers the instant now — the read the loop
|
|
// gate uses to suppress nudges during a meeting.
|
|
func Busy(events []Event, now time.Time) bool {
|
|
for _, e := range events {
|
|
if !now.Before(e.Start) && now.Before(e.End) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Overlapping returns the events intersecting [from, to), sorted by start.
|
|
func Overlapping(events []Event, from, to time.Time) []Event {
|
|
var out []Event
|
|
for _, e := range events {
|
|
if e.End.After(from) && e.Start.Before(to) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
|
return out
|
|
}
|
|
|
|
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
|
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
|
// the day prefix carries the identity and this only disambiguates within a day.
|
|
func safeKey(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
|
b.WriteRune(r)
|
|
case r == ' ' || r == '_':
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|