// 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=)` 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. func FactKey(e Event) string { return fmt.Sprintf("calendar_event_%s_%s", e.Start.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. func FactValue(e Event) string { return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04")) } // 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() }