calendar: date an ambient event by its day word, and refuse stale ones
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.
This commit is contained in:
@@ -36,13 +36,38 @@ type Notification struct {
|
||||
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. Everything else is defaulted — the date is Posted's day (a
|
||||
// meeting notification is about today or it would not be firing now), and a
|
||||
// bare start time gets DefaultReminderDuration.
|
||||
// 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
|
||||
@@ -57,9 +82,14 @@ func EventFromNotification(n Notification) (Event, bool) {
|
||||
return Event{}, false
|
||||
}
|
||||
|
||||
y, m, d := n.Posted.Date()
|
||||
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)
|
||||
@@ -73,12 +103,38 @@ func EventFromNotification(n Notification) (Event, bool) {
|
||||
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(stripClock(cand))
|
||||
s := strings.TrimSpace(stripDayWords(stripClock(cand)))
|
||||
s = strings.Trim(s, " \t-–—,;:@|·")
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if hasLetters(s) {
|
||||
|
||||
@@ -110,6 +110,97 @@ func TestEventFromNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A notification is not always about today. A 21:00 reminder reading
|
||||
// "Tomorrow at 09:00" used to be dated to the notification's own day, which put
|
||||
// the meeting twelve hours in the past and filed it under today in FactKey. A
|
||||
// wrong meeting stored is worse than nothing stored.
|
||||
func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
loc := time.FixedZone("+04", 4*3600)
|
||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title, text string
|
||||
posted time.Time
|
||||
wantOK bool
|
||||
wantDay int // day of month
|
||||
wantSummary string
|
||||
}{
|
||||
{
|
||||
name: "tomorrow in english", title: "Standup", text: "Tomorrow at 09:00",
|
||||
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Standup",
|
||||
},
|
||||
{
|
||||
name: "завтра in russian", title: "Планёрка", text: "завтра в 09:00",
|
||||
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
|
||||
},
|
||||
{
|
||||
name: "завтра in the title, summary in the body", title: "Завтра в 09:00", text: "Планёрка",
|
||||
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
|
||||
},
|
||||
{
|
||||
name: "послезавтра is two days, not one", title: "Ретро", text: "послезавтра 11:00",
|
||||
posted: evening, wantOK: true, wantDay: 5, wantSummary: "Ретро",
|
||||
},
|
||||
{
|
||||
name: "сегодня stays on the posted day", title: "Созвон", text: "сегодня 21:30",
|
||||
posted: evening, wantOK: true, wantDay: 3, wantSummary: "Созвон",
|
||||
},
|
||||
// No day word: the 09:00 is twelve hours behind the notification, so the
|
||||
// inferred day is wrong and there is nothing honest to store.
|
||||
{
|
||||
name: "stale morning time with no day word", title: "Standup", text: "at 09:00",
|
||||
posted: evening, wantOK: false,
|
||||
},
|
||||
// Inside the grace: a phone reposting the notification for a meeting
|
||||
// already under way must still store it.
|
||||
{
|
||||
name: "meeting already running", title: "Планёрка", text: "20:30-22:00",
|
||||
posted: evening, wantOK: true, wantDay: 3, wantSummary: "Планёрка",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Package: "com.google.android.calendar",
|
||||
Title: tt.title, Text: tt.text, Posted: tt.posted,
|
||||
})
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if got := ev.Start.Day(); got != tt.wantDay {
|
||||
t.Errorf("start day = %d, want %d (start %v)", got, tt.wantDay, ev.Start)
|
||||
}
|
||||
if ev.Summary != tt.wantSummary {
|
||||
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
|
||||
}
|
||||
if ev.Start.Before(tt.posted.Add(-ambientPastGrace)) {
|
||||
t.Errorf("start %v is stale against posted %v", ev.Start, tt.posted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The day word named the date, which now lives in Start. Leaving it in the
|
||||
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
|
||||
// that into the key.
|
||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Title: "Завтра Планёрка 09:00",
|
||||
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
if ev.Summary != "Планёрка" {
|
||||
t.Fatalf("summary = %q, want %q", ev.Summary, "Планёрка")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||
t.Error("a notification with no posted_at has no date to sit on")
|
||||
|
||||
Reference in New Issue
Block a user