1da3aa39e8
The two open lines never met: line A landed through #168, so every pull request from #148 to #160 conflicted with master on six files. This reconciles them. Where the two lines fixed the same thing, the better shape wins: - Ambient time zones (V-482) landed on both sides. Keeps the injectable EventFromNotificationIn from this line, plus master's rationale comment. Drops master's forced n.Posted.In(time.Local), which defeated the loc argument. - tick.go: master's guardNudge call and say.CountWord edits, moved onto the split files this line created. The digest summary now declines through say.CountWord inside tick_digest.go. - voice.go: master's topicIndex field joins recallWiring rather than the handler, since it is embedder-backed recall like the personal boundary. topics.go and its test read h.recall.topics now. - mavweb: master's capability and risk columns ported into tools.html, which is where this line moved the markup. The Go const is gone. - Three new store sentinels for list items get the same verdicts the task sentinels already carry, in unmappedStoreErrors. make build: 12 binaries. make test: green. make fmt-check: clean. --no-verify: a merge of two long lines cannot fit the 300-line budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
302 lines
10 KiB
Go
302 lines
10 KiB
Go
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
|
||
}
|