4e4c9170e3
EventFromNotification took the date from the notification's own day, on the grounds that a meeting notification is about today or it would not be firing. Calendar apps break that. A 21:00 reminder reading "Tomorrow at 09:00" became an event at 09:00 today, twelve hours in the past, and FactKey filed that wrong meeting under today's date. Storing a wrong meeting is the one outcome this parse works to avoid. An explicit day word now moves the date: завтра, tomorrow, послезавтра, сегодня, today, tonight. Matched whole, so послезавтра is not read as завтра, and stripped from the summary so the meeting is not named after the day. Anything still landing more than two hours before the notification is refused, which covers the cases with no day word at all. The grace keeps a repost for a meeting already under way. Also matches the bearer scheme with EqualFold. A phone sending "bearer <tok>" fell through to the X-Maven-Token branch and got a 401 that looked like a wrong token. A bare token with no scheme in Authorization is now rejected rather than silently accepted. The route table in mavweb gains its /api/ambient row, and the missing calendar_busy write is recorded as a known gap. Found in review of #57.
278 lines
8.9 KiB
Go
278 lines
8.9 KiB
Go
package calendar
|
||
|
||
import (
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
)
|
||
|
||
// 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
|
||
|
||
// dayWords maps the words that move a notification off Posted's day. Only
|
||
// explicit ones: an offset is a claim about which day, and guessing which day
|
||
// is exactly the guess this parse refuses to make.
|
||
var dayWords = map[string]int{
|
||
"завтра": 1,
|
||
"tomorrow": 1,
|
||
"сегодня": 0,
|
||
"today": 0,
|
||
"tonight": 0,
|
||
"послезавтра": 2,
|
||
}
|
||
|
||
// 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.
|
||
func EventFromNotification(n Notification) (Event, bool) {
|
||
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
|
||
}
|
||
|
||
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
|
||
loc := n.Posted.Location()
|
||
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. Words are 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 := dayWords[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 := dayWords[strings.Trim(strings.ToLower(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
|
||
}
|