// 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 — 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() }