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