7dba1b7935
FactSpan built both instants by adding a duration to local midnight, so on the two DST changeover days every span was an hour off. A day is 23 or 25 hours wide there, and the busy gate then read a 14:00 meeting as 13:00 or 15:00. Both readings are time.Date now, and the midnight crossing is AddDate rather than a 24-hour add. The iCal parse did not unfold content lines. A server folds a property at 75 octets and a Russian summary is two bytes a letter, so the tail of an ordinary weekly standup was read as an unknown property and dropped, and the event was filed under a truncated name. RFC 5545 TEXT escapes are also reversed now, which RenderICal has always written and the parse never undid. Two regression tests: a folded and escaped summary, and a span across the start of DST in Europe/Berlin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
9.0 KiB
Go
249 lines
9.0 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"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
)
|
||
|
||
// 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]
|
||
}
|
||
|
||
// EventKeyPrefix — every calendar event fact starts with this. The loop scans
|
||
// the family to work out whether a meeting covers right now (Vikunja #513).
|
||
const EventKeyPrefix = "calendar_event_"
|
||
|
||
// FactSpan reads an event fact back into the instants it covers, against loc.
|
||
// The day comes from the key and the two clock readings from the value's
|
||
// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote.
|
||
//
|
||
// ok is false for anything that does not parse. A fact whose span cannot be
|
||
// read tells you nothing about now, and guessing a span is how a signal that
|
||
// was meant to suppress one nudge starts suppressing all of them.
|
||
//
|
||
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
|
||
// meeting covers the quarter hour it actually covers.
|
||
//
|
||
// Both readings are built with time.Date rather than added to midnight as a
|
||
// duration. A day is 23 or 25 hours wide on the two DST changeovers, so
|
||
// midnight plus fourteen hours is 13:00 or 15:00 on those days, and the busy
|
||
// gate would then read a 14:00 meeting an hour off. The same goes for the
|
||
// midnight crossing, which is AddDate and not a 24-hour add.
|
||
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
|
||
if !strings.HasPrefix(key, EventKeyPrefix) {
|
||
return time.Time{}, time.Time{}, false
|
||
}
|
||
rest := key[len(EventKeyPrefix):]
|
||
if len(rest) < 8 {
|
||
return time.Time{}, time.Time{}, false
|
||
}
|
||
day, err := time.ParseInLocation("20060102", rest[:8], loc)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, false
|
||
}
|
||
// SetValue stores a string fact JSON-encoded, so the value comes back
|
||
// quoted. Reading the tail off the quote is how this returned false for
|
||
// every real event the first time it ran.
|
||
if unq, err := strconv.Unquote(value); err == nil {
|
||
value = unq
|
||
}
|
||
i := strings.LastIndex(value, " @ ")
|
||
if i < 0 {
|
||
return time.Time{}, time.Time{}, false
|
||
}
|
||
tail := value[i+len(" @ "):]
|
||
from, to, found := strings.Cut(tail, "-")
|
||
if !found {
|
||
return time.Time{}, time.Time{}, false
|
||
}
|
||
sh, sm, ok1 := parseHM(strings.TrimSpace(from))
|
||
eh, em, ok2 := parseHM(strings.TrimSpace(to))
|
||
if !ok1 || !ok2 {
|
||
return time.Time{}, time.Time{}, false
|
||
}
|
||
y, mo, d := day.Date()
|
||
start = time.Date(y, mo, d, sh, sm, 0, 0, loc)
|
||
end = time.Date(y, mo, d, eh, em, 0, 0, loc)
|
||
if !end.After(start) {
|
||
end = end.AddDate(0, 0, 1)
|
||
}
|
||
return start, end, true
|
||
}
|
||
|
||
// parseHM reads "15:04" and nothing else.
|
||
func parseHM(s string) (h, m int, ok bool) {
|
||
t, err := time.Parse("15:04", s)
|
||
if err != nil {
|
||
return 0, 0, false
|
||
}
|
||
return t.Hour(), t.Minute(), true
|
||
}
|
||
|
||
// 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: letters and digits in
|
||
// any script, plus dashes, with space and underscore folded to a dash.
|
||
//
|
||
// It kept ASCII only until 04-08-2026, and dropped everything else. His
|
||
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
|
||
// "--" and produced the same key on the same day — the second event of the day
|
||
// silently overwrote the first (Vikunja #443). Letting the letters through is
|
||
// what makes the key identify the event. Migration #18 drops the keys written
|
||
// under the old rule; they are re-derived on the next poll.
|
||
func safeKey(s string) string {
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
switch {
|
||
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
|
||
b.WriteRune(r)
|
||
case r == ' ' || r == '_':
|
||
b.WriteRune('-')
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|