package calendar import ( "strings" "time" "unicode" "github.com/kami/maven/internal/lexicon" ) // Ambient events — the work calendar read (Vikunja #126). // // The work calendar is not read by holding a work credential. A corp mail or // calendar session living on the homelab ties the box's blast radius to the // employer's data, which is the thing the task exists to refuse. What maven // reads instead is the SIGNAL: an Android notification-listener on the owner's // phone relays meeting notifications over wg/LAN, and maven turns the ones that // clearly describe a meeting into calendar events. // // That makes the provenance honest. A notification is evidence about an event, // not a reading of the calendar, so it is stored under SourceAmbient at // AmbientConfidence — never indistinguishable from a real CalDAV read, and the // query path hedges when it recites one. // // The parse is deliberately conservative. A notification with no recognisable // clock reading produces nothing at all: maven is not a guesser-of-truth, and a // mailbox full of noise turned into invented events is worse than a gap. Mail // as a notification signal, not a mailbox. // Notification — one relayed Android notification. Package is the posting app // (for the log and for the owner to see where a wrong event came from), Title // and Text are the notification's two text lines, Posted is when the phone // showed it. Nothing else off the notification is kept. type Notification struct { Package string `json:"package"` Title string `json:"title"` Text string `json:"text"` Posted time.Time `json:"posted_at"` } // ambientPastGrace — how far before the notification a derived start may sit // before the event is refused. // // The date is not in the clock reading, so it is inferred, and the inference is // only safe while the event is still roughly now. A 21:00 reminder reading // "Tomorrow at 09:00" would otherwise land at 09:00 TODAY, twelve hours in the // past, and FactKey would file that wrong meeting under today's date. Storing a // wrong meeting is the one outcome this file exists to avoid, so anything this // stale is dropped instead. The grace covers the ordinary case of a phone // reposting a notification for a meeting already under way. const ambientPastGrace = 2 * time.Hour // EventFromNotification turns a notification into the event it describes, or // reports false when it does not clearly describe one. // // It needs two things: a clock reading, and a summary that is not just that // clock reading. The date comes from Posted's day, shifted by an explicit day // word ("завтра", "tomorrow") when the notification carries one, and the result // is refused if it lands more than ambientPastGrace in the past. A bare start // time gets DefaultReminderDuration. // // The clock reading is read in the daemon's zone (Vikunja #482). Posted is an // instant and carries an offset; "созвон в 14:30" is a wall clock and carries // none, so the zone has to come from somewhere else. A relay that posts // "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30 // meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a // UTC box. The owner's phone and the box share a zone, so the box's zone is the // honest reading of a bare wall clock. // // See EventFromNotificationIn, which takes the zone explicitly. func EventFromNotification(n Notification) (Event, bool) { return EventFromNotificationIn(n, time.Local) } // EventFromNotificationIn is EventFromNotification against an explicit zone. // // "созвон в 14:30" carries no zone, and nobody writing a phone notification // means 14:30Z. The wall clock used to be resolved against Posted's own zone, // and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting // on this box landed four hours late. The size of the error is the deploy's UTC // offset, which is why it is invisible on a UTC box and wrong everywhere else. // // Posted stays an instant in its own zone: it says when the phone showed the // notification, and the past-grace check compares instants. Only the day and // the wall clock are read in loc. func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) { if loc == nil { loc = time.Local } if n.Posted.IsZero() { return Event{}, false } line := strings.TrimSpace(n.Title + " " + n.Text) start, end, ok := parseTimeRange(line) if !ok { return Event{}, false } summary := notificationSummary(n) if summary == "" { return Event{}, false } // The day is Posted's LOCAL day: a notification posted at 23:30Z saying // "завтра в 09:00" is already tomorrow where he is standing. y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date() s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc) // Too far in the past to be the meeting this notification is about. The day // was inferred, so the honest reading is that the inference was wrong. if s.Before(n.Posted.Add(-ambientPastGrace)) { return Event{}, false } var e time.Time if end != nil { e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc) // A range that ends before it starts crossed midnight. if !e.After(s) { e = e.AddDate(0, 0, 1) } } else { e = s.Add(DefaultReminderDuration) } return Event{Summary: summary, Start: s, End: e}, true } // dayOffset reports how many days off Posted's day the notification puts the // event. Only explicit words move it: an offset is a claim about which day, and // guessing which day is exactly the guess this parse refuses to make. // // The words are a closed class and live complete in internal/lexicon (Vikunja // #525), which is how "позавчера" resolves here now — it never did while the map // was inline. Matched whole, so "послезавтра" is not read as "завтра". func dayOffset(line string) int { for _, f := range strings.Fields(strings.ToLower(line)) { f = strings.Trim(f, ".,;:!?—–-()\"'«»") if off, ok := lexicon.DayOffset(f); ok { return off } } return 0 } // stripDayWords removes the day word from a summary candidate. It named the // date, which now lives in Start, and leaving it in makes "Завтра Планёрка" // the name of the meeting. func stripDayWords(s string) string { out := make([]string, 0, 8) for _, f := range strings.Fields(s) { if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok { continue } out = append(out, f) } return strings.Join(out, " ") } // notificationSummary picks the text that names the meeting: the title when it // carries words, otherwise the body. The clock reading is stripped out — it // already lives in the times, and FactValue renders it again. func notificationSummary(n Notification) string { for _, cand := range []string{n.Title, n.Text} { s := strings.TrimSpace(stripDayWords(stripClock(cand))) s = strings.Trim(s, " \t-–—,;:@|·") s = strings.Join(strings.Fields(s), " ") if hasLetters(s) { return s } } return "" } type clock struct{ hour, min int } // parseTimeRange finds the first clock reading in s, and a second one if the // text spells a range. Accepted separators between hours and minutes are ":" // and "."; between the two ends of a range, "-", "–", "—" or "до". // // Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far // more often a count, a date or an unread badge than a meeting time, and an // invented event is worse than no event. func parseTimeRange(s string) (start clock, end *clock, ok bool) { first, _, firstEnd, ok := nextClock(s, 0) if !ok { return clock{}, nil, false } sep := strings.TrimLeft(s[firstEnd:], " \t") for _, p := range []string{"-", "–", "—", "до "} { if !strings.HasPrefix(sep, p) { continue } if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 { return first, &second, true } break } return first, nil, true } // nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and // returns it with the byte range it occupied. Digits and separators are ASCII, // so byte offsets are safe over Cyrillic text. func nextClock(s string, from int) (c clock, start, end int, ok bool) { for i := from; i < len(s); i++ { if !isDigit(s[i]) { continue } j := i for j < len(s) && isDigit(s[j]) { j++ } // A run longer than two digits is a year, an id or an unread count. if j-i > 2 { i = j continue } if j >= len(s) || (s[j] != ':' && s[j] != '.') { i = j continue } k := j + 1 for k < len(s) && isDigit(s[k]) { k++ } if k-(j+1) != 2 { i = j continue } // Reject a group that is a link in a longer dotted or colon chain: // "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline // date invented as a meeting time is exactly the wrong kind of guess. // A trailing ":ss" is fine — that is a time with seconds. if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) { i = k continue } if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) { i = k continue } hour, min := atoi(s[i:j]), atoi(s[j+1:k]) if hour > 23 || min > 59 { i = k continue } return clock{hour, min}, i, k, true } return clock{}, 0, 0, false } func isDigit(b byte) bool { return b >= '0' && b <= '9' } func atoi(s string) int { n := 0 for i := 0; i < len(s); i++ { n = n*10 + int(s[i]-'0') } return n } // stripClock removes every clock reading from a summary candidate, along with // the preposition or separator that introduced it. func stripClock(s string) string { for { _, start, end, ok := nextClock(s, 0) if !ok { return s } head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t")) s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:])) } } // trimTrailingPreposition drops the word that introduced a clock reading, so // "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not // keep a dangling "с". It repeats, because a range has two of them. func trimTrailingPreposition(s string) string { preps := []string{"в", "с", "до", "от", "at", "from", "to"} for again := true; again; { again = false s = strings.TrimRight(s, " \t") for _, p := range preps { if s == p { return "" } if strings.HasSuffix(s, " "+p) { s = s[:len(s)-len(p)-1] again = true break } } } return s } func hasLetters(s string) bool { for _, r := range s { if unicode.IsLetter(r) { return true } } return false }