Merge master into the line B review stack (V-405)

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>
This commit is contained in:
2026-08-04 20:46:53 +04:00
160 changed files with 9759 additions and 700 deletions
+19 -17
View File
@@ -4,6 +4,8 @@ import (
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/lexicon"
)
// Ambient events — the work calendar read (Vikunja #126).
@@ -48,18 +50,6 @@ type Notification struct {
// 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.
//
@@ -69,8 +59,15 @@ var dayWords = map[string]int{
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
//
// The clock reading is a wall clock in the daemon's local zone. See
// EventFromNotificationIn.
// 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)
}
@@ -126,11 +123,16 @@ func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
}
// dayOffset reports how many days off Posted's day the notification puts the
// event. Words are matched whole, so "послезавтра" is not read as "завтра".
// 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 := dayWords[f]; ok {
if off, ok := lexicon.DayOffset(f); ok {
return off
}
}
@@ -143,7 +145,7 @@ func dayOffset(line string) int {
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 {
if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok {
continue
}
out = append(out, f)