Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4646155b4 | |||
| da647e87d0 | |||
| bf6ccf9aea | |||
| 7b2b96b957 | |||
| c8444813e2 | |||
| ed9bdd5e09 | |||
| 49f089d8a6 | |||
| 3af290152c |
@@ -34,6 +34,8 @@ deps
|
|||||||
deploy/db_key.env
|
deploy/db_key.env
|
||||||
# Deploy secret (telegram bot token + chat id) — never commit
|
# Deploy secret (telegram bot token + chat id) — never commit
|
||||||
deploy/telegram.env
|
deploy/telegram.env
|
||||||
|
# zenmoney API token, read by mavpoll (never in argv, never committed)
|
||||||
|
deploy/zenmoney.token
|
||||||
|
|
||||||
# Temp files
|
# Temp files
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|||||||
+75
-144
@@ -1,17 +1,24 @@
|
|||||||
// mavcaldav — the CalDAV poller module.
|
// mavcaldav — the CalDAV module: reads calendars into facts, and renders
|
||||||
|
// maven's own reminders back out to a calendar she owns.
|
||||||
//
|
//
|
||||||
// Polls a Radicale (or any CalDAV) server for today's events and writes
|
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
||||||
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
|
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
||||||
// Key-free, restart-free, fail-independent — crashes can't touch the
|
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
||||||
// store key, worst case a stale calendar_busy fact until the next poll.
|
// touch the store key, worst case a stale calendar_busy fact until the next
|
||||||
|
// poll. Two facts:
|
||||||
//
|
//
|
||||||
// Two facts written:
|
|
||||||
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
||||||
// nudges during meetings
|
// nudges during meetings
|
||||||
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
||||||
//
|
//
|
||||||
// Append-only discipline: a fact is written only when its value CHANGED
|
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
||||||
// vs the latest for that key+source.
|
// latest for that key+source.
|
||||||
|
//
|
||||||
|
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
|
||||||
|
// pending reminder as a single-event iCal resource in a collection maven owns.
|
||||||
|
// The calendar is a view, sqlite is the store — see render.go. The render URL
|
||||||
|
// must differ from the read URL, checked at startup, so the render target can
|
||||||
|
// never be a calendar maven is only supposed to read.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,6 +34,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +51,10 @@ func run(args []string) error {
|
|||||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||||
|
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
||||||
|
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
||||||
|
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
||||||
|
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -54,6 +66,9 @@ func run(args []string) error {
|
|||||||
if *url == "" || *user == "" || *pass == "" {
|
if *url == "" || *user == "" || *pass == "" {
|
||||||
return fmt.Errorf("-url, -user, -pass are required")
|
return fmt.Errorf("-url, -user, -pass are required")
|
||||||
}
|
}
|
||||||
|
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -64,16 +79,36 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
defer core.Close()
|
defer core.Close()
|
||||||
|
|
||||||
|
hc := &http.Client{Timeout: *timeout}
|
||||||
p := &poller{
|
p := &poller{
|
||||||
core: core,
|
core: core,
|
||||||
http: &http.Client{Timeout: *timeout},
|
http: hc,
|
||||||
url: strings.TrimRight(*url, "/"),
|
url: strings.TrimRight(*url, "/"),
|
||||||
user: *user,
|
user: *user,
|
||||||
pass: *pass,
|
pass: *pass,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var rend *renderer
|
||||||
|
if *renderURL != "" {
|
||||||
|
ru, rp := *renderUser, *renderPass
|
||||||
|
if ru == "" {
|
||||||
|
ru = *user
|
||||||
|
}
|
||||||
|
if rp == "" {
|
||||||
|
rp = *pass
|
||||||
|
}
|
||||||
|
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||||
|
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
||||||
p.pollOnce(ctx) // fire immediately
|
tick := func() {
|
||||||
|
p.pollOnce(ctx)
|
||||||
|
if rend != nil {
|
||||||
|
rend.renderOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick() // fire immediately
|
||||||
t := time.NewTicker(*interval)
|
t := time.NewTicker(*interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -82,11 +117,30 @@ func run(args []string) error {
|
|||||||
log.Printf("mavcaldav: bye")
|
log.Printf("mavcaldav: bye")
|
||||||
return nil
|
return nil
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
p.pollOnce(ctx)
|
tick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkRenderTarget refuses a render URL that is also a read URL. This is the
|
||||||
|
// structural half of #127's "cannot write to your work calendar": the write
|
||||||
|
// credential and the write URL are separate flags, and the one calendar maven
|
||||||
|
// is known to only read is rejected as a target at startup rather than trusted
|
||||||
|
// at runtime.
|
||||||
|
func checkRenderTarget(readURL, renderURL string) error {
|
||||||
|
if renderURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sameCollection(readURL, renderURL) {
|
||||||
|
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCollection(a, b string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
type poller struct {
|
type poller struct {
|
||||||
core ipc.CoreAPI
|
core ipc.CoreAPI
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -95,12 +149,6 @@ type poller struct {
|
|||||||
pass string
|
pass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type icalEvent struct {
|
|
||||||
start time.Time
|
|
||||||
end time.Time
|
|
||||||
summary string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *poller) pollOnce(ctx context.Context) {
|
func (p *poller) pollOnce(ctx context.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
events, err := p.fetchEvents(ctx, now)
|
events, err := p.fetchEvents(ctx, now)
|
||||||
@@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy := false
|
|
||||||
for _, e := range events {
|
|
||||||
if !now.Before(e.start) && now.Before(e.end) {
|
|
||||||
busy = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
busyVal := "false"
|
busyVal := "false"
|
||||||
if busy {
|
if calendar.Busy(events, now) {
|
||||||
busyVal = "true"
|
busyVal = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write calendar_busy on change.
|
// Write calendar_busy on change.
|
||||||
if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil {
|
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
||||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write per-event facts (one per event, keyed by event summary + start).
|
// Write per-event facts (one per event, keyed by day + event summary).
|
||||||
// This lets the note RAG path answer "what's on my calendar" without
|
// This lets the note RAG path answer "what's on my calendar" without
|
||||||
// reaching back to Radicale.
|
// reaching back to Radicale.
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
|
key := calendar.FactKey(e)
|
||||||
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
|
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||||
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
|
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||||
log.Printf("mavcaldav: write %s: %v", eventKey, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, error) {
|
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -162,120 +202,11 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, e
|
|||||||
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseICal(body, now), nil
|
return calendar.ParseICalDay(body, now), nil
|
||||||
}
|
|
||||||
|
|
||||||
// parseICal scans iCal text for VEVENT components. Returns events that overlap
|
|
||||||
// with today (UTC day boundaries) to keep the response manageable.
|
|
||||||
func parseICal(body []byte, now time.Time) []icalEvent {
|
|
||||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
todayEnd := todayStart.AddDate(0, 0, 1)
|
|
||||||
|
|
||||||
var events []icalEvent
|
|
||||||
text := string(body)
|
|
||||||
for {
|
|
||||||
veventStart := strings.Index(text, "BEGIN:VEVENT")
|
|
||||||
if veventStart < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
text = text[veventStart+len("BEGIN:VEVENT"):]
|
|
||||||
veventEnd := strings.Index(text, "END:VEVENT")
|
|
||||||
if veventEnd < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
block := text[:veventEnd]
|
|
||||||
text = text[veventEnd+len("END:VEVENT"):]
|
|
||||||
|
|
||||||
e := parseVEVENT(block)
|
|
||||||
if e == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Only keep events overlapping today.
|
|
||||||
if e.end.After(todayStart) && e.start.Before(todayEnd) {
|
|
||||||
events = append(events, *e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseVEVENT extracts start, end, summary from a VEVENT block.
|
|
||||||
// Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...)
|
|
||||||
// formats. Returns nil for all-day events (no DTSTART/DTEND time component) or
|
|
||||||
// parse failures.
|
|
||||||
func parseVEVENT(block string) *icalEvent {
|
|
||||||
var e icalEvent
|
|
||||||
lines := strings.Split(block, "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(line, "DTSTART"):
|
|
||||||
if t, ok := parseDT(line); ok {
|
|
||||||
e.start = t
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(line, "DTEND"):
|
|
||||||
if t, ok := parseDT(line); ok {
|
|
||||||
e.end = t
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(line, "SUMMARY"):
|
|
||||||
if idx := strings.Index(line, ":"); idx >= 0 {
|
|
||||||
e.summary = strings.TrimSpace(line[idx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if e.start.IsZero() || e.end.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &e
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDT parses a DTSTART/DTEND value. Supports:
|
|
||||||
// - UTC: DTEND:20260703T100000Z
|
|
||||||
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
|
||||||
// - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time)
|
|
||||||
func parseDT(line string) (time.Time, bool) {
|
|
||||||
if strings.Contains(line, "VALUE=DATE:") {
|
|
||||||
return time.Time{}, false // all-day, skip
|
|
||||||
}
|
|
||||||
idx := strings.LastIndex(line, ":")
|
|
||||||
if idx < 0 {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
val := line[idx+1:]
|
|
||||||
val = strings.TrimSuffix(val, "Z")
|
|
||||||
|
|
||||||
// Try UTC first (has Z suffix, or ended in Z before TrimSuffix).
|
|
||||||
if strings.HasSuffix(line, "Z") {
|
|
||||||
t, err := time.Parse("20060102T150405", val)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return t.UTC(), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local time — treat as UTC for simplicity (CalDAV server and poller
|
|
||||||
// run in the same timezone; the gate only needs busy/not-busy accuracy).
|
|
||||||
t, err := time.Parse("20060102T150405", val)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return t.UTC(), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeKey makes an event summary safe to use as a fact key (alphanumeric + dash).
|
|
||||||
func safeKey(s string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
} else if r == ' ' || r == '_' {
|
|
||||||
b.WriteRune('-')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
||||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && prev.Value == val:
|
case err == nil && prev.Value == val:
|
||||||
@@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
|||||||
Key: key,
|
Key: key,
|
||||||
Value: val,
|
Value: val,
|
||||||
Source: source,
|
Source: source,
|
||||||
Confidence: 1.0,
|
Confidence: confidence,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write %s: %w", key, err)
|
return fmt.Errorf("write %s: %w", key, err)
|
||||||
|
|||||||
+5
-164
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
|||||||
return int64(len(f.writeLog)), nil
|
return int64(len(f.writeLog)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Parsing tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestParseICal(t *testing.T) {
|
|
||||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
body := []byte(`BEGIN:VCALENDAR
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260703T090000Z
|
|
||||||
DTEND:20260703T100000Z
|
|
||||||
SUMMARY:Morning standup
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260703T140000Z
|
|
||||||
DTEND:20260703T150000Z
|
|
||||||
SUMMARY:Team sync
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260702T140000Z
|
|
||||||
DTEND:20260702T150000Z
|
|
||||||
SUMMARY:Yesterday retro
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260704T090000Z
|
|
||||||
DTEND:20260704T100000Z
|
|
||||||
SUMMARY:Tomorrow standup
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART;VALUE=DATE:20260704
|
|
||||||
DTEND;VALUE=DATE:20260705
|
|
||||||
SUMMARY:All-day event
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR`)
|
|
||||||
|
|
||||||
events := parseICal(body, now)
|
|
||||||
|
|
||||||
if len(events) != 2 {
|
|
||||||
t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Morning standup — overlaps today.
|
|
||||||
if events[0].summary != "Morning standup" {
|
|
||||||
t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup")
|
|
||||||
}
|
|
||||||
wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)
|
|
||||||
if !events[0].start.Equal(wantStart0) {
|
|
||||||
t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0)
|
|
||||||
}
|
|
||||||
wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
|
|
||||||
if !events[0].end.Equal(wantEnd0) {
|
|
||||||
t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Team sync — overlaps today.
|
|
||||||
if events[1].summary != "Team sync" {
|
|
||||||
t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync")
|
|
||||||
}
|
|
||||||
wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
|
||||||
if !events[1].start.Equal(wantStart1) {
|
|
||||||
t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1)
|
|
||||||
}
|
|
||||||
wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
|
|
||||||
if !events[1].end.Equal(wantEnd1) {
|
|
||||||
t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseVEVENT(t *testing.T) {
|
|
||||||
// Normal event with TZID in DTSTART and UTC DTEND.
|
|
||||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
|
||||||
e := parseVEVENT(block)
|
|
||||||
if e == nil {
|
|
||||||
t.Fatal("expected non-nil icalEvent")
|
|
||||||
}
|
|
||||||
wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
|
||||||
if !e.start.Equal(wantStart) {
|
|
||||||
t.Errorf("start = %v, want %v", e.start, wantStart)
|
|
||||||
}
|
|
||||||
wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
|
||||||
if !e.end.Equal(wantEnd) {
|
|
||||||
t.Errorf("end = %v, want %v", e.end, wantEnd)
|
|
||||||
}
|
|
||||||
if e.summary != "Stand up meeting" {
|
|
||||||
t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting")
|
|
||||||
}
|
|
||||||
|
|
||||||
// All-day event (VALUE=DATE) → nil.
|
|
||||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
|
||||||
if e2 := parseVEVENT(allDay); e2 != nil {
|
|
||||||
t.Error("expected nil for all-day event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDT(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
line string
|
|
||||||
want time.Time
|
|
||||||
wantOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "UTC",
|
|
||||||
line: "DTEND:20260703T100000Z",
|
|
||||||
want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC),
|
|
||||||
wantOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "local time",
|
|
||||||
line: "DTSTART;TZID=Europe/Moscow:20260703T130000",
|
|
||||||
want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC),
|
|
||||||
wantOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all-day",
|
|
||||||
line: "DTSTART;VALUE=DATE:20260703",
|
|
||||||
want: time.Time{},
|
|
||||||
wantOK: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid",
|
|
||||||
line: "DTSTART:garbage",
|
|
||||||
want: time.Time{},
|
|
||||||
wantOK: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, ok := parseDT(tt.line)
|
|
||||||
if ok != tt.wantOK {
|
|
||||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
|
||||||
}
|
|
||||||
if !got.Equal(tt.want) {
|
|
||||||
t.Errorf("got = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSafeKey(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"Stand up meeting", "Stand-up-meeting"},
|
|
||||||
{"Hello_World", "Hello-World"},
|
|
||||||
{"special@#$chars!!", "specialchars"},
|
|
||||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := safeKey(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core logic tests
|
// Core logic tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
t.Run("no previous fact writes", func(t *testing.T) {
|
t.Run("no previous fact writes", func(t *testing.T) {
|
||||||
fc := &fakeCore{}
|
fc := &fakeCore{}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -280,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
writeErr: fmt.Errorf("disk full"),
|
writeErr: fmt.Errorf("disk full"),
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
||||||
|
//
|
||||||
|
// It is a RENDER TARGET, not a store. sqlite stays canonical: every tick the
|
||||||
|
// renderer reads the pending reminders out of core and publishes each one as a
|
||||||
|
// single-event iCal resource in a CalDAV collection maven owns. Nothing is ever
|
||||||
|
// read back from that collection, and losing it costs nothing — the next tick
|
||||||
|
// rebuilds it.
|
||||||
|
//
|
||||||
|
// It structurally cannot write to a calendar maven only reads. The URL comes
|
||||||
|
// from its own flag, checked at startup against every read URL (see
|
||||||
|
// run in main.go), and the only paths it ever addresses carry
|
||||||
|
// calendar.ReminderUIDPrefix — so even pointed at the wrong collection it can
|
||||||
|
// only touch resources it created.
|
||||||
|
type renderer struct {
|
||||||
|
core ipc.CoreAPI
|
||||||
|
http *http.Client
|
||||||
|
url string
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
dur time.Duration
|
||||||
|
|
||||||
|
// published maps reminder id → the body last successfully PUT, so an
|
||||||
|
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
||||||
|
// re-publishes every reminder once, which is idempotent.
|
||||||
|
published map[int64]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
||||||
|
return &renderer{
|
||||||
|
core: core,
|
||||||
|
http: hc,
|
||||||
|
url: strings.TrimRight(url, "/"),
|
||||||
|
user: user,
|
||||||
|
pass: pass,
|
||||||
|
dur: dur,
|
||||||
|
published: make(map[int64]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOnce publishes every pending reminder and withdraws the ones that are
|
||||||
|
// no longer pending. Errors are logged and skipped: a calendar maven cannot
|
||||||
|
// reach must never break the reminder itself, which lives in sqlite.
|
||||||
|
func (r *renderer) renderOnce(ctx context.Context) {
|
||||||
|
reminders, err := r.core.ListReminders(ctx, renderMaxReminders)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mavcaldav: list reminders: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live := make(map[int64]bool, len(reminders))
|
||||||
|
for _, rem := range reminders {
|
||||||
|
if rem.Status != "pending" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
live[rem.ID] = true
|
||||||
|
e := calendar.ReminderEvent(rem.ID, fireTime(rem), rem.Payload, r.dur)
|
||||||
|
body := calendar.RenderICal([]calendar.Event{e})
|
||||||
|
if r.published[rem.ID] == body {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := r.put(ctx, calendar.ReminderPath(rem.ID), body); err != nil {
|
||||||
|
log.Printf("mavcaldav: render reminder %d: %v", rem.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.published[rem.ID] = body
|
||||||
|
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range r.published {
|
||||||
|
if live[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
||||||
|
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(r.published, id)
|
||||||
|
log.Printf("mavcaldav: withdrew reminder %d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMaxReminders bounds the read. Reminders past this count are older than
|
||||||
|
// anything a calendar view is useful for.
|
||||||
|
const renderMaxReminders = 200
|
||||||
|
|
||||||
|
// fireTime prefers NextFireTs — for a recurring reminder that is the occurrence
|
||||||
|
// worth showing; FireTs is the original statement.
|
||||||
|
func fireTime(rem ipc.Reminder) time.Time {
|
||||||
|
if !rem.NextFireTs.IsZero() {
|
||||||
|
return rem.NextFireTs
|
||||||
|
}
|
||||||
|
return rem.FireTs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *renderer) put(ctx context.Context, name, body string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, r.url+"/"+name, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(r.user, r.pass)
|
||||||
|
req.Header.Set("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
return r.do(req, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *renderer) delete(ctx context.Context, name string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, r.url+"/"+name, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(r.user, r.pass)
|
||||||
|
return r.do(req, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do runs the request and treats any 2xx, plus 404 on a DELETE, as success —
|
||||||
|
// a resource that is already gone is the state the caller wanted.
|
||||||
|
func (r *renderer) do(req *http.Request, name string) error {
|
||||||
|
resp, err := r.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||||
|
return nil
|
||||||
|
case req.Method == http.MethodDelete && resp.StatusCode == http.StatusNotFound:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s: %s", req.Method, name, resp.Status)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reminderCore is a fakeCore that also answers ListReminders.
|
||||||
|
type reminderCore struct {
|
||||||
|
fakeCore
|
||||||
|
reminders []ipc.Reminder
|
||||||
|
listErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) {
|
||||||
|
if c.listErr != nil {
|
||||||
|
return nil, c.listErr
|
||||||
|
}
|
||||||
|
return c.reminders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calSrv records what a CalDAV collection received.
|
||||||
|
type calSrv struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
puts map[string]string
|
||||||
|
dels []string
|
||||||
|
status int
|
||||||
|
*httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCalSrv() *calSrv {
|
||||||
|
s := &calSrv{puts: map[string]string{}, status: http.StatusCreated}
|
||||||
|
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPut:
|
||||||
|
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
||||||
|
case http.MethodDelete:
|
||||||
|
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(s.status)
|
||||||
|
}))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *calSrv) putCount() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.puts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOncePublishesPendingReminders(t *testing.T) {
|
||||||
|
fire := time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC)
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 7, FireTs: fire, Payload: "позвонить маме", Status: "pending"},
|
||||||
|
{ID: 8, FireTs: fire, Payload: "уже сделано", Status: "fired"},
|
||||||
|
{ID: 9, FireTs: fire, Payload: "отменено", Status: "cancelled"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal/", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
srv.mu.Lock()
|
||||||
|
body, ok := srv.puts["maven-reminder-7.ics"]
|
||||||
|
n := len(srv.puts)
|
||||||
|
srv.mu.Unlock()
|
||||||
|
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("expected exactly the pending reminder to be published, got %d PUTs", n)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("pending reminder 7 was not published")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "SUMMARY:позвонить маме") {
|
||||||
|
t.Errorf("payload missing from rendered body:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "UID:maven-reminder-7") {
|
||||||
|
t.Errorf("UID missing from rendered body:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOnceSkipsUnchanged(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
if got := srv.putCount(); got != 1 {
|
||||||
|
t.Fatalf("an unchanged reminder was re-published: %d distinct PUTs", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOnceWithdrawsResolvedReminders(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 5, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "встреча", Status: "pending"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
core.reminders[0].Status = "fired"
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
srv.mu.Lock()
|
||||||
|
dels := append([]string(nil), srv.dels...)
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||||
|
t.Fatalf("resolved reminder was not withdrawn: %v", dels)
|
||||||
|
}
|
||||||
|
if len(r.published) != 0 {
|
||||||
|
t.Errorf("published map still holds %v", r.published)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A calendar maven cannot reach must never break anything: sqlite is canonical.
|
||||||
|
func TestRenderOnceSurvivesServerErrors(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
srv.status = http.StatusInternalServerError
|
||||||
|
defer srv.Close()
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "x", Status: "pending"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
if len(r.published) != 0 {
|
||||||
|
t.Error("a failed PUT must not be recorded as published, or it never retries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
next := time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC)
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{{
|
||||||
|
ID: 3,
|
||||||
|
FireTs: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
||||||
|
NextFireTs: next,
|
||||||
|
Payload: "зарядка",
|
||||||
|
Status: "pending",
|
||||||
|
Cron: "0 7 * * *",
|
||||||
|
}}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
srv.mu.Lock()
|
||||||
|
body := srv.puts["maven-reminder-3.ics"]
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if !strings.Contains(body, "DTSTART:20260802T070000Z") {
|
||||||
|
t.Errorf("recurring reminder should render its next occurrence:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
||||||
|
read := "http://localhost:5232/kami/personal"
|
||||||
|
if err := checkRenderTarget(read, ""); err != nil {
|
||||||
|
t.Fatalf("rendering off must be fine: %v", err)
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
||||||
|
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, read); err == nil {
|
||||||
|
t.Error("rendering into the read calendar must be refused")
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, read+"/"); err == nil {
|
||||||
|
t.Error("a trailing slash must not defeat the check")
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
||||||
|
t.Error("case must not defeat the check")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Money questions (Vikunja #125).
|
||||||
|
//
|
||||||
|
// This is the whole read side: mavpoll holds the zenmoney token and writes
|
||||||
|
// facts(kind=env, source=poll:zenmoney); core reads them back when he asks.
|
||||||
|
// Core never sees the token, never calls zenmoney, and has no rule on these
|
||||||
|
// keys — a total is never a reason for Maven to speak first. Maven is not a
|
||||||
|
// nag, least of all about his money.
|
||||||
|
//
|
||||||
|
// Nothing here can reach the external search capability: the figures are read
|
||||||
|
// from the store and rendered locally, and his financial data is never search
|
||||||
|
// input.
|
||||||
|
|
||||||
|
// queryMoney — "сколько я потратил сегодня?", "покажи мои траты".
|
||||||
|
//
|
||||||
|
// Answers only from the latest fact the poller wrote. Three honest outcomes and
|
||||||
|
// no fourth: the figure, "the fact is old and here is its date", or "money
|
||||||
|
// tracking is not connected". It never computes, estimates or rounds a total of
|
||||||
|
// its own — an invented number about his money is the worst thing this could do.
|
||||||
|
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||||
|
if window == router.MoneyToday {
|
||||||
|
key, phrase = zenmoney.KeySpentToday, "сегодня"
|
||||||
|
}
|
||||||
|
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
|
||||||
|
if err != nil {
|
||||||
|
// No fact at all is the normal state when the capability is off. Claim
|
||||||
|
// the turn anyway: falling through to recall would answer a question
|
||||||
|
// about money with whatever note happens to be nearest.
|
||||||
|
if !isNoFactErr(err) {
|
||||||
|
log.Printf("voice: money fact: %v", err)
|
||||||
|
}
|
||||||
|
return "я не отслеживаю траты — не подключено.", true
|
||||||
|
}
|
||||||
|
val, err := zenmoney.ParseFactValue(fact.Value)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: money fact: decode: %v", err)
|
||||||
|
return "не получилось прочитать траты.", true
|
||||||
|
}
|
||||||
|
reply := val.FormatRU(phrase)
|
||||||
|
if reply == "" {
|
||||||
|
return "по тратам пока нечего сказать.", true
|
||||||
|
}
|
||||||
|
// A stale fact is reported as stale rather than spoken as today's number.
|
||||||
|
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
|
||||||
|
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
|
||||||
|
func isNoFactErr(err error) bool {
|
||||||
|
for e := err; e != nil; {
|
||||||
|
if e == ipc.ErrNoFact {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, ok := e.(interface{ Unwrap() error })
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e = u.Unwrap()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
|
||||||
|
// which is the assertion that answering a money question costs no model call
|
||||||
|
// and reaches no network.
|
||||||
|
type moneyAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
fact ipc.Fact
|
||||||
|
err error
|
||||||
|
gotKey string
|
||||||
|
gotSrc string
|
||||||
|
callCnt int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||||
|
a.gotKey, a.gotSrc = key, source
|
||||||
|
a.callCnt++
|
||||||
|
return a.fact, a.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func moneyFact(ts time.Time, val string) ipc.Fact {
|
||||||
|
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
|
||||||
|
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the money source must claim a money question")
|
||||||
|
}
|
||||||
|
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
|
||||||
|
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "1749.5") {
|
||||||
|
t.Errorf("reply = %q, want the exact figure", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "в этом месяце") {
|
||||||
|
t.Errorf("reply = %q, want the window named", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
|
||||||
|
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
if _, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||||
|
}); !ok {
|
||||||
|
t.Fatal("expected the source to claim it")
|
||||||
|
}
|
||||||
|
if api.gotKey != zenmoney.KeySpentToday {
|
||||||
|
t.Errorf("key = %q, want today's", api.gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The capability is off unless configured, and then there is no fact. She says
|
||||||
|
// so instead of letting the recall pass answer a money question from a note.
|
||||||
|
func TestQueryMoneySaysNotConnected(t *testing.T) {
|
||||||
|
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
|
||||||
|
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the source to claim it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не подключено") {
|
||||||
|
t.Errorf("reply = %q, want an honest 'not connected'", reply)
|
||||||
|
}
|
||||||
|
// No number of any kind in that answer.
|
||||||
|
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
|
||||||
|
if strings.Contains(reply, d) {
|
||||||
|
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fact older than the staleness bound is dated rather than spoken as if it
|
||||||
|
// were current: the poller can be down, and last week's total presented as
|
||||||
|
// today's is a lie by omission.
|
||||||
|
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
|
||||||
|
old := moneyNow().Add(-72 * time.Hour)
|
||||||
|
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||||
|
})
|
||||||
|
if !strings.Contains(reply, "данные от") {
|
||||||
|
t.Errorf("reply = %q, want the stale fact dated", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
|
||||||
|
api := &moneyAPI{}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
|
||||||
|
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||||
|
t.Errorf("the money source claimed %q", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api.callCnt != 0 {
|
||||||
|
t.Error("a non-money question must not read the money facts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money must be answered before the recall sources, or a question about
|
||||||
|
// spending gets answered by the nearest note.
|
||||||
|
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
||||||
|
moneyAt, notesAt := -1, -1
|
||||||
|
for i, src := range querySources {
|
||||||
|
switch src.name {
|
||||||
|
case "money":
|
||||||
|
moneyAt = i
|
||||||
|
case "notes":
|
||||||
|
notesAt = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moneyAt < 0 || notesAt < 0 {
|
||||||
|
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
|
||||||
|
}
|
||||||
|
if moneyAt > notesAt {
|
||||||
|
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ import (
|
|||||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||||
// index it for recall.
|
// index it for recall.
|
||||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||||
|
// An utterance that explicitly files a task is work, not recall, and
|
||||||
|
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||||
|
// is paid for. Everything else is a note, exactly as before.
|
||||||
|
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
// embed the note text with the same model the classifier uses, persist
|
// embed the note text with the same model the classifier uses, persist
|
||||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||||
// facts — no predicate reads it (spec's two-memory split).
|
// facts — no predicate reads it (spec's two-memory split).
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/weather"
|
"github.com/kami/maven/internal/weather"
|
||||||
)
|
)
|
||||||
@@ -45,6 +47,26 @@ type querySource struct {
|
|||||||
// line here plus its method; where you put the line is the whole decision.
|
// line here plus its method; where you put the line is the whole decision.
|
||||||
var querySources = []querySource{
|
var querySources = []querySource{
|
||||||
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
||||||
|
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||||||
|
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||||
|
// listing would otherwise swallow it.
|
||||||
|
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||||||
|
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||||
|
// and the habit question is the more specific one. Its matcher requires a
|
||||||
|
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||||
|
// Wednesday still reaches the calendar.
|
||||||
|
{"habits", (*reactiveHandler).queryHabits},
|
||||||
|
// Before "calendar" and before the recall sources: "что мне нужно
|
||||||
|
// сделать?" is a question about the task list, and the notes pass would
|
||||||
|
// otherwise answer it with whatever note happens to be nearest. Its
|
||||||
|
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||||
|
// date-bearing question still reaches the calendar.
|
||||||
|
{"tasks", (*reactiveHandler).queryTasks},
|
||||||
|
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||||
|
// the money facts the poller wrote, and the notes pass would otherwise
|
||||||
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
|
{"money", (*reactiveHandler).queryMoney},
|
||||||
{"calendar", (*reactiveHandler).queryCalendar},
|
{"calendar", (*reactiveHandler).queryCalendar},
|
||||||
{"weather", (*reactiveHandler).queryWeather},
|
{"weather", (*reactiveHandler).queryWeather},
|
||||||
{"embed", (*reactiveHandler).queryEmbed},
|
{"embed", (*reactiveHandler).queryEmbed},
|
||||||
@@ -89,6 +111,75 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||||
|
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||||
|
// reminders, and any morning checklist still outstanding.
|
||||||
|
//
|
||||||
|
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||||
|
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||||
|
// day, so that phrasing trims what has already passed.
|
||||||
|
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
plan, err := h.api.DayPlan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: day plan: %v", err)
|
||||||
|
return "не получилось собрать план.", true
|
||||||
|
}
|
||||||
|
if !isRestOfDayQuery(t.dec.Utterance) {
|
||||||
|
return plan.Spoken, true
|
||||||
|
}
|
||||||
|
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||||
|
// rendered the whole day — one formatter, one persona.
|
||||||
|
p := morning.Plan{Date: plan.Date}
|
||||||
|
for _, it := range plan.Items {
|
||||||
|
p.Items = append(p.Items, morning.PlanEntry{
|
||||||
|
At: it.At,
|
||||||
|
Text: it.Text,
|
||||||
|
Kind: morning.PlanKind(it.Kind),
|
||||||
|
Uncertain: it.Uncertain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return p.After(h.now()).FormatRU(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||||
|
// that means "from now on" rather than "the whole day".
|
||||||
|
func isRestOfDayQuery(text string) bool {
|
||||||
|
s := strings.ToLower(text)
|
||||||
|
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
// habitFactWindow — how many recent facts the behaviour profile is counted
|
||||||
|
// over. Enough for a season of habits without scanning the whole store on every
|
||||||
|
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||||
|
const habitFactWindow = 2000
|
||||||
|
|
||||||
|
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||||
|
// answer out of the fact log rather than asking the model to summarise a life:
|
||||||
|
// see internal/memory/behavior.go for why nothing here is generated.
|
||||||
|
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: habits: recent facts: %v", err)
|
||||||
|
return "не получилось посмотреть записи.", true
|
||||||
|
}
|
||||||
|
obs := make([]memory.Observation, 0, len(facts))
|
||||||
|
for _, f := range facts {
|
||||||
|
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||||||
|
}
|
||||||
|
profile := memory.BuildProfile(obs, h.now())
|
||||||
|
if q.HasWeekday {
|
||||||
|
return profile.FormatWeekdayRU(q.Weekday), true
|
||||||
|
}
|
||||||
|
return profile.FormatOverallRU(), true
|
||||||
|
}
|
||||||
|
|
||||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||||
// source can be tested at a fixed time like the rest.
|
// source can be tested at a fixed time like the rest.
|
||||||
@@ -102,12 +193,15 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
return "не получилось проверить календарь.", true
|
return "не получилось проверить календарь.", true
|
||||||
}
|
}
|
||||||
values := make([]string, len(events))
|
// Provenance travels with each event. A work meeting relayed off a phone
|
||||||
|
// notification (source ambient:notif, #126) is stored below full confidence
|
||||||
|
// and gets hedged; a CalDAV read is recited plainly.
|
||||||
|
entries := make([]router.CalendarEntry, len(events))
|
||||||
for i, e := range events {
|
for i, e := range events {
|
||||||
values[i] = e.Value
|
entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0}
|
||||||
}
|
}
|
||||||
var f router.CalendarEventFormatter
|
var f router.CalendarEventFormatter
|
||||||
return f.Format(values, date), true
|
return f.FormatEntries(entries, date), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task capture on the voice/chat path (Vikunja #130).
|
||||||
|
//
|
||||||
|
// Two halves, both deliberately small:
|
||||||
|
//
|
||||||
|
// - captureTaskFromNote runs at the top of actionNote. An utterance that
|
||||||
|
// explicitly files a task ("добавь в задачи купить молоко") goes to the task
|
||||||
|
// store instead of the note store. Anything without an explicit marker is
|
||||||
|
// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
|
||||||
|
// not become a task.
|
||||||
|
// - queryTasks is a query source that reads the list back.
|
||||||
|
//
|
||||||
|
// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
|
||||||
|
// rule reads the table.
|
||||||
|
|
||||||
|
// captureTaskFromNote claims the turn when the utterance explicitly files a
|
||||||
|
// task, returning the reply. ("", false) hands the turn back to the note path.
|
||||||
|
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
|
cap, ok := router.ParseTaskCapture(dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||||
|
Text: cap.Text,
|
||||||
|
Source: "tap:voice",
|
||||||
|
Status: store.TaskOpen, // he stated it himself — not a candidate
|
||||||
|
Weight: cap.Weight, // 0 unless he said "срочно" / "важно"
|
||||||
|
Ts: h.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: capture task: %v", err)
|
||||||
|
return "не получилось записать задачу.", true
|
||||||
|
}
|
||||||
|
if !resp.Created {
|
||||||
|
return "это уже в списке.", true
|
||||||
|
}
|
||||||
|
return "записала: " + cap.Text, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||||
|
//
|
||||||
|
// Reads the live set and recites it in priority order (Vikunja #129). The order
|
||||||
|
// is computed by internal/tasks from what he told her — deadlines, the urgency
|
||||||
|
// he stated, how long a task has been sitting — never asked of the model. The
|
||||||
|
// rendering is the package's too, so the spoken list and the /tasks page can
|
||||||
|
// never disagree about what comes first.
|
||||||
|
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !router.IsTaskListQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
live, err := h.api.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list tasks: %v", err)
|
||||||
|
return "не получилось посмотреть задачи.", true
|
||||||
|
}
|
||||||
|
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||||
|
// internal/tasks so the ranker stays a pure package with no ipc (and therefore
|
||||||
|
// no store, and therefore no cgo) dependency — the same posture as
|
||||||
|
// internal/morning and internal/memory.
|
||||||
|
func taskItems(ts []ipc.Task) []tasks.Item {
|
||||||
|
out := make([]tasks.Item, len(ts))
|
||||||
|
for i, t := range ts {
|
||||||
|
out[i] = tasks.Item{
|
||||||
|
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||||
|
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskAPI answers only the three task methods; every other call is
|
||||||
|
// unimplemented, which is the assertion that capture needs nothing else — in
|
||||||
|
// particular no embedder, so a filed task costs no model call.
|
||||||
|
type taskAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
captured []ipc.CaptureTaskReq
|
||||||
|
created bool
|
||||||
|
capErr error
|
||||||
|
|
||||||
|
tasks []ipc.Task
|
||||||
|
listArg string
|
||||||
|
listErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
|
a.captured = append(a.captured, req)
|
||||||
|
if a.capErr != nil {
|
||||||
|
return ipc.CaptureTaskResp{}, a.capErr
|
||||||
|
}
|
||||||
|
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||||
|
a.listArg = status
|
||||||
|
return a.tasks, a.listErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||||
|
return &reactiveHandler{api: api, now: taskNow}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
||||||
|
api := &taskAPI{created: true}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||||
|
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("an explicit capture must claim the turn")
|
||||||
|
}
|
||||||
|
if len(api.captured) != 1 {
|
||||||
|
t.Fatalf("captured %d, want 1", len(api.captured))
|
||||||
|
}
|
||||||
|
got := api.captured[0]
|
||||||
|
if got.Text != "купить молоко" {
|
||||||
|
t.Errorf("text = %q, want the marker stripped", got.Text)
|
||||||
|
}
|
||||||
|
if got.Source != "tap:voice" {
|
||||||
|
t.Errorf("source = %q, want tap:voice", got.Source)
|
||||||
|
}
|
||||||
|
if got.Status != "open" {
|
||||||
|
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
||||||
|
}
|
||||||
|
if !got.Ts.Equal(taskNow()) {
|
||||||
|
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "купить молоко") {
|
||||||
|
t.Errorf("reply = %q, want it to read the task back", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note is still a note: capture only fires on an explicit marker, so
|
||||||
|
// ordinary recall is untouched.
|
||||||
|
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
||||||
|
api := &taskAPI{}
|
||||||
|
h := taskHandler(api)
|
||||||
|
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
||||||
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
||||||
|
t.Errorf("%q was captured as a task", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(api.captured) != 0 {
|
||||||
|
t.Errorf("captured %d requests, want none", len(api.captured))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
||||||
|
h := taskHandler(&taskAPI{created: false})
|
||||||
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the capture path to claim it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "уже") {
|
||||||
|
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||||
|
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
||||||
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не получилось") {
|
||||||
|
t.Errorf("reply = %q, want an honest failure", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||||
|
api := &taskAPI{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Status: "open"},
|
||||||
|
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
||||||
|
}}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the task source must claim a task-list question")
|
||||||
|
}
|
||||||
|
if api.listArg != "live" {
|
||||||
|
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
||||||
|
t.Errorf("reply = %q, want both tasks", reply)
|
||||||
|
}
|
||||||
|
// The candidate must be named as unconfirmed, not recited as his work.
|
||||||
|
openIdx := strings.Index(reply, "купить молоко")
|
||||||
|
candIdx := strings.Index(reply, "продлить страховку")
|
||||||
|
if !(openIdx < candIdx) {
|
||||||
|
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не подтвердил") {
|
||||||
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stated urgency rides through capture as a weight, so the ranker can use
|
||||||
|
// it later (Vikunja #129). "срочно" is not part of the task text.
|
||||||
|
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
||||||
|
api := &taskAPI{created: true}
|
||||||
|
h := taskHandler(api)
|
||||||
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||||
|
Utterance: "добавь в задачи срочно оплатить интернет",
|
||||||
|
}); !ok {
|
||||||
|
t.Fatal("expected a capture")
|
||||||
|
}
|
||||||
|
got := api.captured[0]
|
||||||
|
if got.Text != "оплатить интернет" {
|
||||||
|
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
||||||
|
}
|
||||||
|
if got.Weight == 0 {
|
||||||
|
t.Error("weight = 0 — he said срочно and it was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
||||||
|
// comes before undated work.
|
||||||
|
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
||||||
|
due := taskNow()
|
||||||
|
api := &taskAPI{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
||||||
|
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
||||||
|
}}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
||||||
|
})
|
||||||
|
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
||||||
|
t.Errorf("reply = %q, want the dated task first", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "сегодня") {
|
||||||
|
t.Errorf("reply = %q, want the reason named", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryTasksEmptyList(t *testing.T) {
|
||||||
|
h := taskHandler(&taskAPI{})
|
||||||
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the task source to claim it")
|
||||||
|
}
|
||||||
|
if reply != "задач нет." {
|
||||||
|
t.Errorf("reply = %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
||||||
|
api := &taskAPI{}
|
||||||
|
h := taskHandler(api)
|
||||||
|
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
||||||
|
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||||
|
t.Errorf("the task source claimed %q", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api.listArg != "" {
|
||||||
|
t.Error("a non-task question must not read the task list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chain must reach the task source before the recall sources, or "что мне
|
||||||
|
// нужно сделать?" gets answered by whatever note is nearest.
|
||||||
|
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
||||||
|
var tasksAt, notesAt = -1, -1
|
||||||
|
for i, src := range querySources {
|
||||||
|
switch src.name {
|
||||||
|
case "tasks":
|
||||||
|
tasksAt = i
|
||||||
|
case "notes":
|
||||||
|
notesAt = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tasksAt < 0 || notesAt < 0 {
|
||||||
|
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
||||||
|
}
|
||||||
|
if tasksAt > notesAt {
|
||||||
|
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||||
|
// exactly the assertion that the plan source needs nothing else.
|
||||||
|
type planAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
plan ipc.DayPlan
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *planAPI) DayPlan(context.Context) (ipc.DayPlan, error) {
|
||||||
|
a.calls++
|
||||||
|
if a.err != nil {
|
||||||
|
return ipc.DayPlan{}, a.err
|
||||||
|
}
|
||||||
|
return a.plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func planDay() time.Time { return time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func samplePlan() ipc.DayPlan {
|
||||||
|
day := planDay()
|
||||||
|
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
return ipc.DayPlan{
|
||||||
|
Date: mid,
|
||||||
|
Items: []ipc.DayPlanItem{
|
||||||
|
{At: day.Add(-2 * time.Hour), Text: "Standup @ 10:00-10:30", Kind: "event"},
|
||||||
|
{At: day.Add(2 * time.Hour), Text: "Планёрка @ 14:00-14:30", Kind: "event", Uncertain: true},
|
||||||
|
{At: day.Add(6 * time.Hour), Text: "позвонить маме", Kind: "reminder"},
|
||||||
|
},
|
||||||
|
Spoken: "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||||
|
"похоже, 14:00 — Планёрка @ 14:00-14:30; 18:00 — позвонить маме.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||||
|
return &reactiveHandler{api: api, now: planDay}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryDayPlanRecitesTheDay(t *testing.T) {
|
||||||
|
api := &planAPI{plan: samplePlan()}
|
||||||
|
h := planHandler(api)
|
||||||
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие планы на сегодня?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the plan source must claim a plan question")
|
||||||
|
}
|
||||||
|
if reply != api.plan.Spoken {
|
||||||
|
t.Errorf("reply = %q, want the core's spoken plan %q", reply, api.plan.Spoken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "что дальше?" is the rest of the day, not the whole day: what has already
|
||||||
|
// happened is not a plan.
|
||||||
|
func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||||
|
h := planHandler(&planAPI{plan: samplePlan()})
|
||||||
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the plan source to claim it")
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "Standup") {
|
||||||
|
t.Errorf("a passed item must not be read back: %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Планёрка") || !strings.Contains(reply, "позвонить маме") {
|
||||||
|
t.Errorf("the rest of the day is missing: %q", reply)
|
||||||
|
}
|
||||||
|
// Provenance survives the trim.
|
||||||
|
if !strings.Contains(reply, "похоже,") {
|
||||||
|
t.Errorf("a relayed event must stay hedged: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A question that is not about the plan must fall through, or the plan buries
|
||||||
|
// the calendar listing and the weather behind it.
|
||||||
|
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||||
|
for _, q := range []string{
|
||||||
|
"что у меня сегодня?",
|
||||||
|
"какие планы на завтра?",
|
||||||
|
"когда планёрка?",
|
||||||
|
"какая погода?",
|
||||||
|
"",
|
||||||
|
} {
|
||||||
|
api := &planAPI{plan: samplePlan()}
|
||||||
|
reply, ok := planHandler(api).queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||||
|
})
|
||||||
|
if ok {
|
||||||
|
t.Errorf("%q was claimed by the plan source (reply %q)", q, reply)
|
||||||
|
}
|
||||||
|
if api.calls != 0 {
|
||||||
|
t.Errorf("%q hit the core for a plan it does not want", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||||
|
h := planHandler(&planAPI{err: errors.New("socket closed")})
|
||||||
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "план на сегодня"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||||
|
}
|
||||||
|
if reply != "не получилось собрать план." {
|
||||||
|
t.Errorf("reply = %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The day plan must sit before the calendar listing: both match "…на сегодня",
|
||||||
|
// and the more specific matcher has to get first refusal (see #373 for what
|
||||||
|
// happens when the order is wrong).
|
||||||
|
func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||||
|
plan, cal := -1, -1
|
||||||
|
for i, s := range querySources {
|
||||||
|
switch s.name {
|
||||||
|
case "day-plan":
|
||||||
|
plan = i
|
||||||
|
case "calendar":
|
||||||
|
cal = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plan < 0 || cal < 0 {
|
||||||
|
t.Fatalf("sources missing: day-plan=%d calendar=%d", plan, cal)
|
||||||
|
}
|
||||||
|
if plan > cal {
|
||||||
|
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// habitAPI answers only RecentFacts — the whole input the behaviour profile
|
||||||
|
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
|
||||||
|
type habitAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
facts []ipc.Fact
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||||
|
a.calls++
|
||||||
|
return a.facts, a.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
|
||||||
|
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
|
||||||
|
d := now
|
||||||
|
for d.Weekday() != time.Tuesday {
|
||||||
|
d = d.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
var out []ipc.Fact
|
||||||
|
for i := 0; i < weeks; i++ {
|
||||||
|
day := d.AddDate(0, 0, -7*i)
|
||||||
|
out = append(out, ipc.Fact{
|
||||||
|
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||||
|
Kind: "self",
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
|
||||||
|
now := planDay() // a Monday
|
||||||
|
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||||
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the habit source must claim a habit question")
|
||||||
|
}
|
||||||
|
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||||
|
t.Errorf("reply = %q, want %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
|
||||||
|
now := planDay()
|
||||||
|
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
|
||||||
|
api := &habitAPI{}
|
||||||
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||||
|
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||||
|
}); ok {
|
||||||
|
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
|
||||||
|
}
|
||||||
|
if api.calls != 0 {
|
||||||
|
t.Errorf("%q scanned the fact log for a profile it does not want", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both specific sources must precede the calendar listing, which matches any
|
||||||
|
// utterance naming a day.
|
||||||
|
func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||||
|
habits, cal := -1, -1
|
||||||
|
for i, s := range querySources {
|
||||||
|
switch s.name {
|
||||||
|
case "habits":
|
||||||
|
habits = i
|
||||||
|
case "calendar":
|
||||||
|
cal = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if habits < 0 || cal < 0 {
|
||||||
|
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
|
||||||
|
}
|
||||||
|
if habits > cal {
|
||||||
|
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -270,6 +270,7 @@ func run(args []string) error {
|
|||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
api := coreAPI.(*daemonAPI)
|
api := coreAPI.(*daemonAPI)
|
||||||
@@ -449,6 +450,7 @@ func run(args []string) error {
|
|||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
|
|||||||
@@ -789,6 +789,77 @@ func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.Morni
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||||
|
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||||
|
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||||
|
//
|
||||||
|
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||||
|
// unprompted delivery in maven stays with the morning nudge and the
|
||||||
|
// dispatcher's policy.
|
||||||
|
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
var events []morning.PlanEntry
|
||||||
|
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: calendar events: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range facts {
|
||||||
|
events = append(events, morning.PlanEntry{
|
||||||
|
At: f.Ts,
|
||||||
|
Text: f.Value,
|
||||||
|
Kind: morning.PlanEvent,
|
||||||
|
// Provenance below a calendar read (an ambient relay, #126) is
|
||||||
|
// hedged rather than recited as fact.
|
||||||
|
Uncertain: f.Confidence < 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminders []morning.PlanEntry
|
||||||
|
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: list reminders: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range rems {
|
||||||
|
if r.Status != "pending" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fire := r.NextFireTs
|
||||||
|
if fire.IsZero() {
|
||||||
|
fire = r.FireTs
|
||||||
|
}
|
||||||
|
reminders = append(reminders, morning.PlanEntry{
|
||||||
|
At: fire,
|
||||||
|
Text: strings.TrimSpace(r.Payload),
|
||||||
|
Kind: morning.PlanReminder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var checklistFacts map[string]store.Fact
|
||||||
|
if len(t.morningRoutines) > 0 {
|
||||||
|
checklistFacts = t.gatherMorningFacts(ctx)
|
||||||
|
}
|
||||||
|
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||||
|
|
||||||
|
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||||
|
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||||
|
for i, it := range plan.Items {
|
||||||
|
out.Items[i] = ipc.DayPlanItem{
|
||||||
|
At: it.At,
|
||||||
|
Text: it.Text,
|
||||||
|
Kind: string(it.Kind),
|
||||||
|
Uncertain: it.Uncertain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||||
|
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||||
|
const dayPlanMaxReminders = 500
|
||||||
|
|
||||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||||
// rule:
|
// rule:
|
||||||
@@ -868,6 +939,7 @@ type daemonAPI struct {
|
|||||||
ipc.CoreAPI
|
ipc.CoreAPI
|
||||||
getTrace func() *loop.TickTrace
|
getTrace func() *loop.TickTrace
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
chatFn func(ctx context.Context, text string) string
|
chatFn func(ctx context.Context, text string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,6 +965,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
|||||||
return d.getMorningStatus(ctx), nil
|
return d.getMorningStatus(ctx), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||||
|
if d.getDayPlan == nil {
|
||||||
|
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||||
|
}
|
||||||
|
return d.getDayPlan(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||||
for i, r := range t.RuleTraces {
|
for i, r := range t.RuleTraces {
|
||||||
|
|||||||
+124
-3
@@ -9,6 +9,12 @@
|
|||||||
// Two sources, each its own provenance (the loop's rules trust source):
|
// Two sources, each its own provenance (the loop's rules trust source):
|
||||||
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
||||||
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
||||||
|
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
|
||||||
|
//
|
||||||
|
// The zenmoney source is why the token lives HERE and not in core: the poller
|
||||||
|
// already owns every other third-party credential, it holds no store key, and
|
||||||
|
// core never needs to know an account exists to answer a question about a fact
|
||||||
|
// the poller wrote. It is off unless -zenmoney-token-file is given.
|
||||||
//
|
//
|
||||||
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
|
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
|
||||||
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
|
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
|
||||||
@@ -37,6 +43,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -52,6 +59,9 @@ func run(args []string) error {
|
|||||||
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
|
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
|
||||||
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
|
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
|
||||||
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
|
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
|
||||||
|
zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)")
|
||||||
|
zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)")
|
||||||
|
zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)")
|
||||||
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
|
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
|
||||||
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
|
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
|
||||||
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
|
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
|
||||||
@@ -62,8 +72,24 @@ func run(args []string) error {
|
|||||||
if *socket == "" {
|
if *socket == "" {
|
||||||
return fmt.Errorf("-socket is required")
|
return fmt.Errorf("-socket is required")
|
||||||
}
|
}
|
||||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" {
|
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg")
|
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is read from a file, never taken as a flag value: an argv token
|
||||||
|
// is visible in `ps` to every user on the box and lands in the compose file
|
||||||
|
// and the shell history. Read once at start — a rotated token means a
|
||||||
|
// restart, which is cheaper than re-reading his credential every hour.
|
||||||
|
var zen *zenmoney.Client
|
||||||
|
if *zenTokenFile != "" {
|
||||||
|
raw, err := os.ReadFile(*zenTokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read zenmoney token: %w", err)
|
||||||
|
}
|
||||||
|
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -83,9 +109,13 @@ func run(args []string) error {
|
|||||||
kumaKey: *kumaKey,
|
kumaKey: *kumaKey,
|
||||||
wgIface: *wgIface,
|
wgIface: *wgIface,
|
||||||
wgCmd: *wgCmd,
|
wgCmd: *wgCmd,
|
||||||
|
zen: zen,
|
||||||
|
zenEvery: *zenInterval,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q)", *interval, *netdataURL, *kumaURL, *wgIface)
|
// The token is never logged, not even its length.
|
||||||
|
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
|
||||||
|
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
|
||||||
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
||||||
t := time.NewTicker(*interval)
|
t := time.NewTicker(*interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -108,6 +138,12 @@ type poller struct {
|
|||||||
kumaKey string
|
kumaKey string
|
||||||
wgIface string
|
wgIface string
|
||||||
wgCmd string
|
wgCmd string
|
||||||
|
|
||||||
|
// zen is nil unless a token file was configured — money tracking is a
|
||||||
|
// capability, off by default like weather and telegram.
|
||||||
|
zen *zenmoney.Client
|
||||||
|
zenEvery time.Duration
|
||||||
|
zenLast time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
||||||
@@ -129,6 +165,66 @@ func (p *poller) pollOnce(ctx context.Context) {
|
|||||||
log.Printf("mavpoll: wg: %v", err)
|
log.Printf("mavpoll: wg: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Money on its own, much slower cadence: a bank feed that updates hourly
|
||||||
|
// polled every minute is 60 pointless reads of his financial history.
|
||||||
|
if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery {
|
||||||
|
p.zenLast = now
|
||||||
|
if err := p.pollZenmoney(ctx, now); err != nil {
|
||||||
|
log.Printf("mavpoll: zenmoney: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- zenmoney: spending/income totals → money facts ------------------------
|
||||||
|
|
||||||
|
// pollZenmoney reads today's and this month's totals and writes them as
|
||||||
|
// facts(kind=env, source=poll:zenmoney) (Vikunja #125).
|
||||||
|
//
|
||||||
|
// Two properties this function exists to hold:
|
||||||
|
//
|
||||||
|
// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses
|
||||||
|
// to encode a summary built from zero transactions, so a poller that cannot
|
||||||
|
// reach the API leaves the last good fact in place rather than overwriting
|
||||||
|
// it with a zero Maven would then recite as fact.
|
||||||
|
// - Nothing about the money leaves the box except the diff request itself, to
|
||||||
|
// the service that already holds his bank sessions. The totals are written
|
||||||
|
// to the store and read back only when he asks; they are never search input
|
||||||
|
// and no tick rule fires on them.
|
||||||
|
//
|
||||||
|
// Both windows are read from one diff call each. Two calls an hour against an
|
||||||
|
// API whose whole job is this is not worth caching.
|
||||||
|
// moneyWindow — one fact key and the period it covers.
|
||||||
|
type moneyWindow struct {
|
||||||
|
key string
|
||||||
|
from, to time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||||
|
dFrom, dTo := zenmoney.DayWindow(now)
|
||||||
|
mFrom, mTo := zenmoney.MonthWindow(now)
|
||||||
|
windows := []moneyWindow{
|
||||||
|
{zenmoney.KeySpentToday, dFrom, dTo},
|
||||||
|
{zenmoney.KeySpentMonth, mFrom, mTo},
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
for _, w := range windows {
|
||||||
|
sum, err := p.zen.Since(ctx, w.from, w.to)
|
||||||
|
if err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, ok := sum.Value()
|
||||||
|
if !ok {
|
||||||
|
// Nothing read. Silence, not a zero.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- wireguard: latest handshake → presence signal -------------------------
|
// ---- wireguard: latest handshake → presence signal -------------------------
|
||||||
@@ -301,6 +397,31 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeIfChangedRaw is writeIfChanged for values that are already JSON (the
|
||||||
|
// money facts store an object, not a string). Kept separate rather than
|
||||||
|
// generalising writeIfChanged, because the string-valued env facts encoding
|
||||||
|
// their own value is the convention the rules rely on.
|
||||||
|
//
|
||||||
|
// The log line names the key and the source, never the figures: mavpoll's log
|
||||||
|
// is not the place his spending ends up.
|
||||||
|
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||||
|
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||||
|
switch {
|
||||||
|
case err == nil && prev.Value == jsonVal:
|
||||||
|
return nil
|
||||||
|
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||||
|
return fmt.Errorf("read %s: %w", key, err)
|
||||||
|
}
|
||||||
|
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||||
|
Source: source, Confidence: 1.0,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", key, err)
|
||||||
|
}
|
||||||
|
log.Printf("mavpoll: %s updated (%s)", key, source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||||
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
||||||
func isNoFact(err error) bool {
|
func isNoFact(err error) bool {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaxSeverity(t *testing.T) {
|
func TestMaxSeverity(t *testing.T) {
|
||||||
@@ -62,3 +71,120 @@ func TestParseMaxHandshake(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- zenmoney (Vikunja #125) ----------------------------------------------
|
||||||
|
|
||||||
|
// factCore records the facts the poller wrote and answers "no fact yet".
|
||||||
|
type factCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
written []ipc.WriteFactReq
|
||||||
|
prev map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||||
|
if v, ok := c.prev[key+"|"+source]; ok {
|
||||||
|
return ipc.Fact{Key: key, Source: source, Value: v}, nil
|
||||||
|
}
|
||||||
|
return ipc.Fact{}, ipc.ErrNoFact
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||||
|
c.written = append(c.written, req)
|
||||||
|
return int64(len(c.written)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if status != http.StatusOK {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(body)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
|
||||||
|
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := zenFixtureServer(t, body, http.StatusOK)
|
||||||
|
defer srv.Close()
|
||||||
|
zen, err := zenmoney.New("tok", srv.URL, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
core := &factCore{}
|
||||||
|
p := &poller{core: core, zen: zen}
|
||||||
|
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
|
||||||
|
if err := p.pollZenmoney(context.Background(), now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(core.written) != 2 {
|
||||||
|
t.Fatalf("wrote %d facts, want today + month", len(core.written))
|
||||||
|
}
|
||||||
|
for _, f := range core.written {
|
||||||
|
if f.Kind != "env" || f.Source != zenmoney.Source {
|
||||||
|
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
|
||||||
|
}
|
||||||
|
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
|
||||||
|
t.Errorf("fact value %q does not decode: %v", f.Value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read that returns nothing for the window writes NOTHING. Silence, not a
|
||||||
|
// zero: an invented 0 would be recited back to him as fact.
|
||||||
|
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
|
||||||
|
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
|
||||||
|
defer srv.Close()
|
||||||
|
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
|
||||||
|
core := &factCore{}
|
||||||
|
p := &poller{core: core, zen: zen}
|
||||||
|
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(core.written) != 0 {
|
||||||
|
t.Errorf("wrote %+v, want no fact at all", core.written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An API failure must not overwrite the last good total either.
|
||||||
|
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
|
||||||
|
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
|
||||||
|
defer srv.Close()
|
||||||
|
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
|
||||||
|
core := &factCore{}
|
||||||
|
p := &poller{core: core, zen: zen}
|
||||||
|
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
|
||||||
|
t.Error("want the 401 reported")
|
||||||
|
}
|
||||||
|
if len(core.written) != 0 {
|
||||||
|
t.Errorf("wrote %+v on a failed read", core.written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged totals do not churn the facts table.
|
||||||
|
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
|
||||||
|
core := &factCore{prev: map[string]string{
|
||||||
|
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
|
||||||
|
}}
|
||||||
|
p := &poller{core: core}
|
||||||
|
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(core.written) != 0 {
|
||||||
|
t.Errorf("wrote %+v for an unchanged value", core.written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money tracking is off unless configured: no token file, no zenmoney client,
|
||||||
|
// and the poller still refuses to start with nothing at all to poll.
|
||||||
|
func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||||
|
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
|
||||||
|
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/ambient — the work calendar read (Vikunja #126).
|
||||||
|
//
|
||||||
|
// Maven does not hold a work credential. A corp mail or calendar session on the
|
||||||
|
// homelab ties the box's blast radius to the employer's data, so the work
|
||||||
|
// calendar is read as a SIGNAL instead: an Android notification-listener on the
|
||||||
|
// owner's phone posts meeting notifications here over wg/LAN, and the ones that
|
||||||
|
// clearly describe a meeting become calendar events at source=ambient:notif,
|
||||||
|
// confidence below 1.0. Mail as a notification signal, not a mailbox.
|
||||||
|
//
|
||||||
|
// Off unless configured: no -ambient-token, no route. The token is a shared
|
||||||
|
// secret because the poster is a phone service, not a browser — WebAuthn has no
|
||||||
|
// answer for a background Android service. The endpoint is write-only and
|
||||||
|
// accepts exactly one shape of write; it cannot read anything back out.
|
||||||
|
//
|
||||||
|
// A notification with no recognisable clock reading stores NOTHING. Maven is
|
||||||
|
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||||
|
// is worse than a gap.
|
||||||
|
|
||||||
|
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||||
|
const ambientMaxBody = 8 << 10
|
||||||
|
|
||||||
|
type ambientResp struct {
|
||||||
|
Stored bool `json:"stored"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAmbient ingests one relayed notification. token is the configured
|
||||||
|
// shared secret; an empty token means the capability is off and the handler is
|
||||||
|
// never registered, so it is treated as a hard failure here too.
|
||||||
|
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ambientAuthorized(r, token) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var n calendar.Notification
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &n); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.Posted.IsZero() {
|
||||||
|
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ev, ok := calendar.EventFromNotification(n)
|
||||||
|
if !ok {
|
||||||
|
// Not an event. 202: the relay did its job, there is just nothing here
|
||||||
|
// worth remembering, and it must not retry.
|
||||||
|
writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := calendar.FactKey(ev)
|
||||||
|
val := calendar.FactValue(ev)
|
||||||
|
|
||||||
|
// Append-only discipline, same as cmd/mavcaldav: a phone reposts the same
|
||||||
|
// notification many times, and each repost is the same event.
|
||||||
|
if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val {
|
||||||
|
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
|
||||||
|
return
|
||||||
|
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
|
||||||
|
log.Printf("ambient: read %s: %v", key, err)
|
||||||
|
http.Error(w, "read failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// kind=env: an observation about the world, never a self-fact — a passive
|
||||||
|
// signal does not write truth about the owner. Confidence below 1.0 is the
|
||||||
|
// honest part: this is a notification about a meeting, not a reading of a
|
||||||
|
// calendar, and the query path hedges when it recites one.
|
||||||
|
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
||||||
|
Ts: ev.Start,
|
||||||
|
Kind: "env",
|
||||||
|
Key: key,
|
||||||
|
Value: val,
|
||||||
|
Source: calendar.SourceAmbient,
|
||||||
|
Confidence: calendar.AmbientConfidence,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("ambient: write %s: %v", key, err)
|
||||||
|
http.Error(w, "write failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
|
||||||
|
writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
|
||||||
|
// header, compared in constant time.
|
||||||
|
func ambientAuthorized(r *http.Request, token string) bool {
|
||||||
|
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
||||||
|
if got == "" {
|
||||||
|
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ambientTestToken = "s3cret"
|
||||||
|
|
||||||
|
// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path
|
||||||
|
// needs.
|
||||||
|
type ambientCore struct {
|
||||||
|
fakeCore
|
||||||
|
latest map[string]ipc.Fact // "key|source" → fact
|
||||||
|
readErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||||
|
if c.readErr != nil {
|
||||||
|
return ipc.Fact{}, c.readErr
|
||||||
|
}
|
||||||
|
f, ok := c.latest[key+"|"+source]
|
||||||
|
if !ok {
|
||||||
|
return ipc.Fact{}, ipc.ErrNoFact
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, core, token)
|
||||||
|
var resp ambientResp
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||||
|
return rr, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNotification() calendar.Notification {
|
||||||
|
return calendar.Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: "Планёрка",
|
||||||
|
Text: "10:00-10:30",
|
||||||
|
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientStoresMeeting(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if !resp.Stored {
|
||||||
|
t.Errorf("resp = %+v, want stored", resp)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
||||||
|
}
|
||||||
|
got := core.writeLog[0]
|
||||||
|
if got.Source != calendar.SourceAmbient {
|
||||||
|
t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient)
|
||||||
|
}
|
||||||
|
if got.Confidence >= 1.0 {
|
||||||
|
t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence)
|
||||||
|
}
|
||||||
|
if got.Confidence != calendar.AmbientConfidence {
|
||||||
|
t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence)
|
||||||
|
}
|
||||||
|
if got.Kind != "env" {
|
||||||
|
t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind)
|
||||||
|
}
|
||||||
|
if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) {
|
||||||
|
t.Errorf("key = %q, want prefix %q", got.Key, want)
|
||||||
|
}
|
||||||
|
if got.Value != "Планёрка @ 10:00-10:30" {
|
||||||
|
t.Errorf("value = %q", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phone reposts the same notification many times. Each repost is the same
|
||||||
|
// event, and the append-only log must not fill with duplicates.
|
||||||
|
func TestHandleAmbientDedupesReposts(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("first post did not write")
|
||||||
|
}
|
||||||
|
w := core.writeLog[0]
|
||||||
|
core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}}
|
||||||
|
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code)
|
||||||
|
}
|
||||||
|
if resp.Stored {
|
||||||
|
t.Error("a repost must not be stored again")
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Errorf("wrote %d facts, want 1", len(core.writeLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The conservative half: noise stores nothing at all.
|
||||||
|
func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: "3 новых письма",
|
||||||
|
Posted: time.Now(),
|
||||||
|
})
|
||||||
|
if rr.Code != http.StatusAccepted {
|
||||||
|
t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code)
|
||||||
|
}
|
||||||
|
if resp.Stored {
|
||||||
|
t.Error("a notification with no meeting time must store nothing")
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 0 {
|
||||||
|
t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientAuth(t *testing.T) {
|
||||||
|
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||||
|
|
||||||
|
newReq := func(hdr, val string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||||
|
if hdr != "" {
|
||||||
|
r.Header.Set(hdr, val)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no token rejected", func(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("", ""), core, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 0 {
|
||||||
|
t.Error("an unauthorized post must not write")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong token rejected", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("Authorization", "Bearer nope"), &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("X-Maven-Token accepted", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("capability off", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "")
|
||||||
|
if rr.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503 when no token is configured", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET rejected", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil)
|
||||||
|
handleAmbient(rr, r, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientBadInput(t *testing.T) {
|
||||||
|
t.Run("bad json", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope"))
|
||||||
|
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing posted_at", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`))
|
||||||
|
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("read error surfaces", func(t *testing.T) {
|
||||||
|
core := &ambientCore{readErr: fmt.Errorf("socket closed")}
|
||||||
|
rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want 502", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+230
-1
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,9 @@ var notificationsHTML string
|
|||||||
//go:embed reminders.html
|
//go:embed reminders.html
|
||||||
var remindersHTML string
|
var remindersHTML string
|
||||||
|
|
||||||
|
//go:embed tasks.html
|
||||||
|
var tasksHTML string
|
||||||
|
|
||||||
//go:embed voice.html
|
//go:embed voice.html
|
||||||
var voiceHTML string
|
var voiceHTML string
|
||||||
|
|
||||||
@@ -97,6 +101,7 @@ var sidebarSections = []struct {
|
|||||||
Pages: []struct{ Label, URL, Key string }{
|
Pages: []struct{ Label, URL, Key string }{
|
||||||
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
||||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||||
|
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
||||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||||
@@ -170,6 +175,8 @@ func pageIcon(key string) string {
|
|||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||||
|
case "tasks":
|
||||||
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||||
case "routines":
|
case "routines":
|
||||||
@@ -202,6 +209,8 @@ func pageTitle(key string) string {
|
|||||||
return "Rule Trace"
|
return "Rule Trace"
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return "Notifications"
|
return "Notifications"
|
||||||
|
case "tasks":
|
||||||
|
return "Tasks"
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return "Reminders"
|
return "Reminders"
|
||||||
case "routines":
|
case "routines":
|
||||||
@@ -334,6 +343,10 @@ func main() {
|
|||||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||||
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
||||||
|
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
||||||
|
// reads the work calendar as a signal instead of holding a work credential
|
||||||
|
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
||||||
|
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var core ipc.CoreAPI
|
var core ipc.CoreAPI
|
||||||
@@ -381,6 +394,14 @@ func main() {
|
|||||||
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleSignal(w, r, core)
|
handleSignal(w, r, core)
|
||||||
})
|
})
|
||||||
|
// Off unless configured: no token, no route — an unconfigured ingest is not
|
||||||
|
// a 503 waiting to be probed, it does not exist.
|
||||||
|
if *ambientToken != "" {
|
||||||
|
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleAmbient(w, r, core, *ambientToken)
|
||||||
|
})
|
||||||
|
log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient")
|
||||||
|
}
|
||||||
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleDash(w, r, core)
|
handleDash(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -396,6 +417,11 @@ func main() {
|
|||||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleReminders(w, r, core)
|
handleReminders(w, r, core)
|
||||||
})
|
})
|
||||||
|
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
|
||||||
|
// why a task write is not in the same class as /tools or /routines.
|
||||||
|
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleTasks(w, r, core)
|
||||||
|
})
|
||||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleMorning(w, r, core)
|
handleMorning(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -732,6 +758,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
|
|||||||
|
|
||||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
||||||
|
|
||||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
||||||
|
|
||||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||||
@@ -802,6 +830,187 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||||
|
// template holds no date logic.
|
||||||
|
type taskRow struct {
|
||||||
|
ID int64
|
||||||
|
Text string
|
||||||
|
Source string
|
||||||
|
Evidence string
|
||||||
|
Status string
|
||||||
|
Due string
|
||||||
|
Created string
|
||||||
|
Resolved string
|
||||||
|
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||||
|
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||||
|
// rendering: he never said this one mattered more.
|
||||||
|
Why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTasks serves the task review surface (GET) and the four writes it
|
||||||
|
// offers (POST): add, confirm, done, drop.
|
||||||
|
//
|
||||||
|
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||||
|
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||||
|
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
||||||
|
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
||||||
|
// weaker caller can do here is write a line onto a list he reads himself. It
|
||||||
|
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||||
|
// page.
|
||||||
|
//
|
||||||
|
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||||
|
// from something she read into work he owns. That review step is why derived
|
||||||
|
// tasks are captured as candidates in the first place.
|
||||||
|
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
var msg, errMsg string
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var err error
|
||||||
|
msg, err = applyTaskPost(ctx, core, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tasks: %v", err)
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := core.ListTasks(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tasks: list: %v", err)
|
||||||
|
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||||
|
// and the voice reply can never disagree about what comes first. Resolved
|
||||||
|
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||||
|
var live []tasks.Item
|
||||||
|
var resolved []taskRow
|
||||||
|
for _, t := range all {
|
||||||
|
switch t.Status {
|
||||||
|
case "candidate", "open":
|
||||||
|
live = append(live, tasks.Item{
|
||||||
|
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||||
|
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
resolved = append(resolved, taskRow{
|
||||||
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||||
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||||
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byID := make(map[int64]ipc.Task, len(all))
|
||||||
|
for _, t := range all {
|
||||||
|
byID[t.ID] = t
|
||||||
|
}
|
||||||
|
var cands, open []taskRow
|
||||||
|
for _, r := range tasks.Rank(live, time.Now()) {
|
||||||
|
t := byID[r.ID]
|
||||||
|
row := taskRow{
|
||||||
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||||
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||||
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||||
|
Why: r.Reason,
|
||||||
|
}
|
||||||
|
if t.Status == "candidate" {
|
||||||
|
// A candidate's due date is Maven's reading of a mail, so its
|
||||||
|
// ranking reason is not shown as if he had set a priority.
|
||||||
|
row.Why = ""
|
||||||
|
cands = append(cands, row)
|
||||||
|
} else {
|
||||||
|
open = append(open, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tasksTmpl.Execute(w, struct {
|
||||||
|
Msg, Err string
|
||||||
|
Candidates []taskRow
|
||||||
|
Open []taskRow
|
||||||
|
Resolved []taskRow
|
||||||
|
}{msg, errMsg, cands, open, resolved}); err != nil {
|
||||||
|
log.Printf("tasks render: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTaskPost performs one write and returns the message to show. A bad
|
||||||
|
// request returns an error, which the page renders inline rather than as a
|
||||||
|
// bare 400 — this is a form surface, not an API.
|
||||||
|
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||||
|
action := r.FormValue("action")
|
||||||
|
if action == "add" {
|
||||||
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
|
if text == "" {
|
||||||
|
return "", errors.New("empty task text")
|
||||||
|
}
|
||||||
|
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
|
||||||
|
// Importance is his, stated on the form. Out-of-range values are
|
||||||
|
// clamped rather than rejected — a bad select is not worth a 400.
|
||||||
|
if v := r.FormValue("weight"); v != "" {
|
||||||
|
var wgt int
|
||||||
|
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
|
||||||
|
return "", fmt.Errorf("bad weight %q", v)
|
||||||
|
}
|
||||||
|
if wgt > tasks.MaxWeight {
|
||||||
|
wgt = tasks.MaxWeight
|
||||||
|
}
|
||||||
|
req.Weight = wgt
|
||||||
|
}
|
||||||
|
if d := r.FormValue("due"); d != "" {
|
||||||
|
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("bad due date %q", d)
|
||||||
|
}
|
||||||
|
req.Due = &due
|
||||||
|
}
|
||||||
|
resp, err := core.CaptureTask(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !resp.Created {
|
||||||
|
return "already on the list", nil
|
||||||
|
}
|
||||||
|
return "added task", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
|
||||||
|
return "", errors.New("invalid id")
|
||||||
|
}
|
||||||
|
var status, msg string
|
||||||
|
switch action {
|
||||||
|
case "confirm":
|
||||||
|
status, msg = "open", "confirmed task"
|
||||||
|
case "done":
|
||||||
|
status, msg = "done", "task done"
|
||||||
|
case "drop":
|
||||||
|
status, msg = "dropped", "dropped task"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown action %q", action)
|
||||||
|
}
|
||||||
|
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTaskTime(t *time.Time) string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return t.Local().Format("02 Jan 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTaskDate(t *time.Time) string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return t.Local().Format("02 Jan")
|
||||||
|
}
|
||||||
|
|
||||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||||
// how long ago she noticed it.
|
// how long ago she noticed it.
|
||||||
type routineRow struct {
|
type routineRow struct {
|
||||||
@@ -947,12 +1156,32 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
view := morningView{Routines: status}
|
||||||
|
// The day plan (#128) shows on this page because it is the same question at
|
||||||
|
// a different scale. A plan read that fails must not take the checklist
|
||||||
|
// down with it — the page degrades to what it had before.
|
||||||
|
plan, err := core.DayPlan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("morning: day plan: %v", err)
|
||||||
|
view.PlanErr = err.Error()
|
||||||
|
} else {
|
||||||
|
view.Plan = &plan
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := morningTmpl.Execute(w, status); err != nil {
|
if err := morningTmpl.Execute(w, view); err != nil {
|
||||||
log.Printf("morning render: %v", err)
|
log.Printf("morning render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// morningView — what /morning renders: today's plan on top, the checklist
|
||||||
|
// state under it. PlanErr is set instead of Plan when the core could not build
|
||||||
|
// a plan, so the page says so rather than showing an empty day.
|
||||||
|
type morningView struct {
|
||||||
|
Plan *ipc.DayPlan
|
||||||
|
PlanErr string
|
||||||
|
Routines []ipc.MorningRoutineStatus
|
||||||
|
}
|
||||||
|
|
||||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||||
|
|||||||
+20
-2
@@ -1,9 +1,27 @@
|
|||||||
{{template "shellTop" "morning"}}
|
{{template "shellTop" "morning"}}
|
||||||
|
<h1>Today</h1>
|
||||||
|
{{with .Plan}}
|
||||||
|
<div class=hint>{{.Date.Format "02.01.2006"}}</div>
|
||||||
|
{{if not .Items}}
|
||||||
|
<div class=hint>nothing planned</div>
|
||||||
|
{{else}}
|
||||||
|
<div class=scroll><table class=mono>
|
||||||
|
<tr><th>at<th>kind<th>what</tr>
|
||||||
|
{{range .Items}}<tr>
|
||||||
|
<td>{{.At.Format "15:04"}}</td>
|
||||||
|
<td class=gray>{{.Kind}}</td>
|
||||||
|
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .PlanErr}}<div class=hint>plan unavailable: {{.PlanErr}}</div>{{end}}
|
||||||
|
|
||||||
<h1>Morning Routines</h1>
|
<h1>Morning Routines</h1>
|
||||||
{{if not .}}
|
{{if not .Routines}}
|
||||||
<div class=hint>no morning routines configured</div>
|
<div class=hint>no morning routines configured</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{range .}}
|
{{range .Routines}}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div><strong>{{.Name}}</strong>
|
<div><strong>{{.Name}}</strong>
|
||||||
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{{template "shellTop" "tasks"}}
|
||||||
|
<h1>Tasks</h1>
|
||||||
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
|
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||||
|
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>add</h2>
|
||||||
|
<form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=action value=add>
|
||||||
|
<input type=text name=text placeholder="что нужно сделать" size=44 required>
|
||||||
|
<input type=date name=due title="due date (optional)">
|
||||||
|
<select name=weight title="importance (optional)">
|
||||||
|
<option value=0>normal</option>
|
||||||
|
<option value=2>важно</option>
|
||||||
|
<option value=3>срочно</option>
|
||||||
|
</select>
|
||||||
|
<button class=btn>add</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .Candidates}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
|
||||||
|
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
|
||||||
|
<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||||
|
{{range .Candidates}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
|
||||||
|
<td>{{.Due}}</td>
|
||||||
|
<td class=muted>{{.Created}}</td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=confirm>
|
||||||
|
<button class=btn>confirm</button></form></td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=drop>
|
||||||
|
<button class="btn btn-muted">drop</button></form></td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||||
|
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
|
||||||
|
{{if .Open}}<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||||
|
{{range .Open}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td class=hint>{{.Why}}</td>
|
||||||
|
<td class=hint>{{.Source}}</td>
|
||||||
|
<td>{{.Due}}</td>
|
||||||
|
<td class=muted>{{.Created}}</td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=done>
|
||||||
|
<button class=btn>done</button></form></td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=drop>
|
||||||
|
<button class="btn btn-muted">drop</button></form></td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||||
|
<div>no open tasks</div>
|
||||||
|
<div class=hint>add one above, or tell maven "добавь в задачи …"</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .Resolved}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
|
||||||
|
<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>status</th><th>when</th></tr>
|
||||||
|
{{range .Resolved}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
|
<td class=muted>{{.Resolved}}</td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{template "shellBottom"}}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
|
||||||
|
// writes the page made.
|
||||||
|
type fakeTaskCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
tasks []ipc.Task
|
||||||
|
listErr error
|
||||||
|
|
||||||
|
captured []ipc.CaptureTaskReq
|
||||||
|
created bool
|
||||||
|
captureErr error
|
||||||
|
|
||||||
|
statusID int64
|
||||||
|
statusVal string
|
||||||
|
statusErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||||
|
if f.listErr != nil {
|
||||||
|
return nil, f.listErr
|
||||||
|
}
|
||||||
|
return f.tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
|
f.captured = append(f.captured, req)
|
||||||
|
if f.captureErr != nil {
|
||||||
|
return ipc.CaptureTaskResp{}, f.captureErr
|
||||||
|
}
|
||||||
|
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
|
||||||
|
f.statusID, f.statusVal = id, status
|
||||||
|
return f.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
resolved := now.Add(time.Hour)
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
|
||||||
|
{ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
|
||||||
|
{ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"купить молоко", "продлить страховку", "полить цветы",
|
||||||
|
"полис истекает", // the evidence trail is visible for review
|
||||||
|
"found, not confirmed", // candidates get their own section
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The candidate must offer confirm, and the open task must not.
|
||||||
|
if !strings.Contains(body, "value=confirm") {
|
||||||
|
t.Error("candidate row has no confirm action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksAddCaptures(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(core.captured) != 1 {
|
||||||
|
t.Fatalf("captured %d requests, want 1", len(core.captured))
|
||||||
|
}
|
||||||
|
got := core.captured[0]
|
||||||
|
if got.Text != "позвонить в банк" {
|
||||||
|
t.Errorf("text = %q, want trimmed", got.Text)
|
||||||
|
}
|
||||||
|
if got.Source != "tap:web" {
|
||||||
|
t.Errorf("source = %q, want tap:web", got.Source)
|
||||||
|
}
|
||||||
|
if got.Status != "open" {
|
||||||
|
t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
|
||||||
|
}
|
||||||
|
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||||
|
t.Errorf("due = %v", got.Due)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "added task") {
|
||||||
|
t.Error("no confirmation message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: false}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
if !strings.Contains(rec.Body.String(), "already on the list") {
|
||||||
|
t.Error("a deduped capture must not claim it saved something new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksStatusActions(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ action, want string }{
|
||||||
|
{"confirm", "open"},
|
||||||
|
{"done", "done"},
|
||||||
|
{"drop", "dropped"},
|
||||||
|
} {
|
||||||
|
core := &fakeTaskCore{}
|
||||||
|
form := url.Values{"action": {tc.action}, "id": {"42"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if core.statusID != 42 || core.statusVal != tc.want {
|
||||||
|
t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksRejectsBadPost(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{}
|
||||||
|
form := url.Values{"action": {"explode"}, "id": {"1"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
// The page still renders, with the error inline — and nothing was written.
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if core.statusVal != "" || len(core.captured) != 0 {
|
||||||
|
t.Error("an unknown action must write nothing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "unknown action") {
|
||||||
|
t.Error("error not surfaced on the page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksNoCore(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The open list is ordered by the ranker, and the reason is shown so the page
|
||||||
|
// says why a task is first instead of asking him to trust the order.
|
||||||
|
func TestHandleTasksOrdersOpenByRank(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
due := now
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now},
|
||||||
|
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
body := rec.Body.String()
|
||||||
|
if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") {
|
||||||
|
t.Error("want the dated task rendered first")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "сегодня") {
|
||||||
|
t.Error("want the ranker's reason shown in the why column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A candidate is ranked into place but never carries a priority reason: its due
|
||||||
|
// date is Maven's reading of a mail, not something he stated.
|
||||||
|
func TestHandleTasksHidesCandidateReason(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
due := now
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
if strings.Contains(rec.Body.String(), "сегодня") {
|
||||||
|
t.Error("a candidate must not be shown with a priority reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if len(core.captured) != 1 || core.captured[0].Weight != 3 {
|
||||||
|
t.Fatalf("captured = %+v, want weight 3", core.captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||||
|
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if core.captured[0].Weight != tasks.MaxWeight {
|
||||||
|
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,9 +101,17 @@ services:
|
|||||||
"-netdata", "http://127.0.0.1:19999",
|
"-netdata", "http://127.0.0.1:19999",
|
||||||
"-kuma", "http://127.0.0.1:3001/metrics",
|
"-kuma", "http://127.0.0.1:3001/metrics",
|
||||||
"-kuma-key", "uk5_mavpoll-key"]
|
"-kuma-key", "uk5_mavpoll-key"]
|
||||||
|
# Money tracking (Vikunja #125) is OFF: it needs a zenmoney token,
|
||||||
|
# which mavpoll reads from a FILE so it never appears in `ps`, in
|
||||||
|
# this file, or in shell history. To enable, mount the token and
|
||||||
|
# append: "-zenmoney-token-file", "/run/secrets/zenmoney.token"
|
||||||
|
# (optionally "-zenmoney-interval", "1h"). Core never sees the
|
||||||
|
# token — the poller writes facts(kind=env, source=poll:zenmoney)
|
||||||
|
# and mavend only reads those back when he asks.
|
||||||
depends_on: [mavend]
|
depends_on: [mavend]
|
||||||
volumes:
|
volumes:
|
||||||
- sockets:/run/maven
|
- sockets:/run/maven
|
||||||
|
# - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
|
|||||||
@@ -27,3 +27,36 @@
|
|||||||
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
||||||
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
||||||
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status (2026-08-01) — partially shipped, deliberately narrowed
|
||||||
|
|
||||||
|
Shipped on `overnight/behavior-profile`:
|
||||||
|
|
||||||
|
- `internal/memory/behavior.go` — `BuildProfile` counts habits per weekday out of
|
||||||
|
self-facts: distinct-day counts (`MinHabitDays = 2`), a median time-of-day, and
|
||||||
|
`FormatWeekdayRU` / `FormatOverallRU` for the spoken answer.
|
||||||
|
- `internal/router/habit.go` — `ParseHabitQuery`, which requires a habit marker
|
||||||
|
("обычно", "каждую", "привычки", …) and parses the weekday deterministically.
|
||||||
|
- `cmd/mavend/actions_query.go` — a `habits` query source, so "что я обычно делаю
|
||||||
|
по вторникам?" is answered.
|
||||||
|
|
||||||
|
**Not shipped, and not to be shipped as written:**
|
||||||
|
|
||||||
|
- *Step 3, LLM-generated profile stored as a fact.* The profile is COUNTED, not
|
||||||
|
generated. A 1.7B asked to summarise a year of habits produces fluent claims
|
||||||
|
about the owner's life that no row supports, and a wrong claim about him is the
|
||||||
|
most expensive kind of wrong maven can be. Counting is verifiable and cheap.
|
||||||
|
- *Step 5, incremental updates on fact write.* There is no cache to keep fresh —
|
||||||
|
the profile is recomputed on the question, so a new fact is already in the next
|
||||||
|
answer. A cached profile that can disagree with its own rows is two truths.
|
||||||
|
- *Step 4, proactive daily plan proposals via the dispatcher.* Maven is not a nag,
|
||||||
|
and a nudge at 08:00 every day proposing the day is the definition of one. The
|
||||||
|
sanctioned path from "she noticed a pattern" to "she acts on it" already exists:
|
||||||
|
`internal/pattern/detector.go` proposes a routine, and the owner accepts it on
|
||||||
|
`/routines`. It goes through him.
|
||||||
|
|
||||||
|
Still open, if wanted later: `MethodGetBehaviorProfile` + a `/dash` panel (step 7).
|
||||||
|
The counted profile needs no new IPC method to be *asked* about — the query source
|
||||||
|
reads `RecentFacts` over the existing surface — so this is a display concern only.
|
||||||
|
|||||||
+10
-1
@@ -65,7 +65,16 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
ipc.MethodCreateReminder,
|
ipc.MethodCreateReminder,
|
||||||
ipc.MethodMarkReminder,
|
ipc.MethodMarkReminder,
|
||||||
ipc.MethodRecordNudge,
|
ipc.MethodRecordNudge,
|
||||||
ipc.MethodResolveNudge:
|
ipc.MethodResolveNudge,
|
||||||
|
// Task capture (Vikunja #130). Listed explicitly rather than left to
|
||||||
|
// the default so the intent is on the record: capturing a task is a
|
||||||
|
// module write, not an allowlist mutation and not a new standing reason
|
||||||
|
// for Maven to speak — nothing in the tick loop reads tasks. It stays
|
||||||
|
// at AuthRead, the same rung as CreateReminder, which is the closest
|
||||||
|
// existing analogue.
|
||||||
|
ipc.MethodCaptureTask,
|
||||||
|
ipc.MethodListTasks,
|
||||||
|
ipc.MethodSetTaskStatus:
|
||||||
return AuthRead
|
return AuthRead
|
||||||
}
|
}
|
||||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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.Date()
|
||||||
|
loc := n.Posted.Location()
|
||||||
|
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventFromNotification(t *testing.T) {
|
||||||
|
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
title, text string
|
||||||
|
wantOK bool
|
||||||
|
wantSummary string
|
||||||
|
wantStart string // "15:04"
|
||||||
|
wantEnd string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "range in the body",
|
||||||
|
title: "Планёрка",
|
||||||
|
text: "10:00-10:30",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Планёрка",
|
||||||
|
wantStart: "10:00", wantEnd: "10:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "russian preposition and single time",
|
||||||
|
title: "Встреча с подрядчиком в 14:00",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Встреча с подрядчиком",
|
||||||
|
wantStart: "14:00", wantEnd: "14:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "en dash range",
|
||||||
|
title: "Sprint review",
|
||||||
|
text: "Today 16:00 – 17:00, Meet",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Sprint review",
|
||||||
|
wantStart: "16:00", wantEnd: "17:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "до as a range separator",
|
||||||
|
title: "Созвон",
|
||||||
|
text: "с 11:30 до 12:15",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Созвон",
|
||||||
|
wantStart: "11:30", wantEnd: "12:15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dotted clock",
|
||||||
|
title: "Обед 13.00",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Обед",
|
||||||
|
wantStart: "13:00", wantEnd: "13:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "range crossing midnight",
|
||||||
|
title: "Ночной релиз",
|
||||||
|
text: "23:30-00:30",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Ночной релиз",
|
||||||
|
wantStart: "23:30", wantEnd: "00:30",
|
||||||
|
},
|
||||||
|
// The conservative half: no clock reading, no event.
|
||||||
|
{name: "no time at all", title: "3 новых письма", wantOK: false},
|
||||||
|
{name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false},
|
||||||
|
{name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false},
|
||||||
|
{name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false},
|
||||||
|
{name: "time but nothing named", title: "10:00-10:30", wantOK: false},
|
||||||
|
{name: "impossible clock", title: "Смена 99:99", wantOK: false},
|
||||||
|
{name: "empty", wantOK: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ev, ok := EventFromNotification(Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: tt.title,
|
||||||
|
Text: tt.text,
|
||||||
|
Posted: posted,
|
||||||
|
})
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.Summary != tt.wantSummary {
|
||||||
|
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
|
||||||
|
}
|
||||||
|
if got := ev.Start.Format("15:04"); got != tt.wantStart {
|
||||||
|
t.Errorf("start = %s, want %s", got, tt.wantStart)
|
||||||
|
}
|
||||||
|
if got := ev.End.Format("15:04"); got != tt.wantEnd {
|
||||||
|
t.Errorf("end = %s, want %s", got, tt.wantEnd)
|
||||||
|
}
|
||||||
|
if !ev.End.After(ev.Start) {
|
||||||
|
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
||||||
|
}
|
||||||
|
// The event lands on the day the phone showed it, in the phone's
|
||||||
|
// location — not shifted into UTC.
|
||||||
|
if ev.Start.Location() != posted.Location() {
|
||||||
|
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||||
|
}
|
||||||
|
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
|
||||||
|
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ambient event must never be indistinguishable from a calendar read.
|
||||||
|
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||||
|
ev, ok := EventFromNotification(Notification{
|
||||||
|
Title: "Планёрка 10:00-10:30",
|
||||||
|
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an event")
|
||||||
|
}
|
||||||
|
if FactKey(ev) == "" || FactValue(ev) == "" {
|
||||||
|
t.Fatal("ambient events must use the shared fact encoding")
|
||||||
|
}
|
||||||
|
if AmbientConfidence >= 1.0 {
|
||||||
|
t.Fatal("ambient confidence must be below a calendar read's")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripClock(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"Встреча в 14:00", "Встреча"},
|
||||||
|
{"Планёрка 10:00-10:30", "Планёрка"},
|
||||||
|
{"с 11:30 до 12:15 Созвон", "Созвон"},
|
||||||
|
{"Ничего", "Ничего"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := stripClock(tt.in); got != tt.want {
|
||||||
|
t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package calendar is the one calendar data model the rest of maven shares:
|
||||||
|
// an Event, the iCal text it is parsed from and rendered to, and the fact
|
||||||
|
// encoding that puts it in the store.
|
||||||
|
//
|
||||||
|
// It exists because three separate features read or write the same events and
|
||||||
|
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
|
||||||
|
// #126/#127), the write-only render target that publishes maven's own
|
||||||
|
// reminders as a calendar (#127), and the day plan that recites them (#128).
|
||||||
|
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
|
||||||
|
// format was a Sprintf in two places.
|
||||||
|
//
|
||||||
|
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
|
||||||
|
// impurity, the way internal/morning and internal/loop do.
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fact sources. A calendar event reaches the store as a
|
||||||
|
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
|
||||||
|
// the source is the whole provenance story:
|
||||||
|
//
|
||||||
|
// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
|
||||||
|
// state stays in sqlite; the calendar is a render target (#127).
|
||||||
|
// - SourceWork — a work calendar, read-only by definition (#126). Nothing in
|
||||||
|
// maven ever writes to it: no code path pairs this source with a PUT.
|
||||||
|
// - SourceAmbient — inferred from an Android notification-listener relay
|
||||||
|
// rather than read from a server (#126). Confidence is below 1.0 because a
|
||||||
|
// notification is a signal about an event, not the event.
|
||||||
|
const (
|
||||||
|
SourcePersonal = "poll:caldav"
|
||||||
|
SourceWork = "poll:caldav:work"
|
||||||
|
SourceAmbient = "ambient:notif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientConfidence — the confidence a notification-derived event is stored
|
||||||
|
// with. A parsed notification line is evidence, not a reading of the calendar,
|
||||||
|
// so it must never be indistinguishable from one (#126).
|
||||||
|
const AmbientConfidence = 0.6
|
||||||
|
|
||||||
|
// Sources lists every source a calendar event may legitimately carry, for the
|
||||||
|
// store query that reads the calendar back out. Ordered from most to least
|
||||||
|
// trusted.
|
||||||
|
func Sources() []string {
|
||||||
|
return []string{SourcePersonal, SourceWork, SourceAmbient}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOnlySource reports whether events from this source may never be written
|
||||||
|
// back. The work calendar is read-only by definition — see #126: maven holding
|
||||||
|
// a credential that can write to an employer's calendar is the thing the task
|
||||||
|
// exists to avoid.
|
||||||
|
func ReadOnlySource(source string) bool {
|
||||||
|
return source == SourceWork || source == SourceAmbient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event — one calendar entry. UID is the iCal UID when the event was parsed
|
||||||
|
// from a server and the identity maven renders under when it publishes one;
|
||||||
|
// Start/End are instants. All-day events are not modelled: the busy gate and
|
||||||
|
// the day plan both need a time of day, and an all-day marker answers neither.
|
||||||
|
type Event struct {
|
||||||
|
UID string
|
||||||
|
Summary string
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactKey is the store key for an event: one key per day per summary, stable
|
||||||
|
// across polls so re-reading an unchanged calendar rewrites nothing.
|
||||||
|
//
|
||||||
|
// The date prefix is load-bearing — store.CalendarEvents selects a day range
|
||||||
|
// by key prefix, not by a timestamp column.
|
||||||
|
func FactKey(e Event) string {
|
||||||
|
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactValue is the human-readable rendering stored as the fact value, and the
|
||||||
|
// string the day plan and the query path read back.
|
||||||
|
func FactValue(e Event) string {
|
||||||
|
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||||
|
// range-scans between two of these.
|
||||||
|
func KeyPrefixForDay(day time.Time) string {
|
||||||
|
return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busy reports whether any event covers the instant now — the read the loop
|
||||||
|
// gate uses to suppress nudges during a meeting.
|
||||||
|
func Busy(events []Event, now time.Time) bool {
|
||||||
|
for _, e := range events {
|
||||||
|
if !now.Before(e.Start) && now.Before(e.End) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlapping returns the events intersecting [from, to), sorted by start.
|
||||||
|
func Overlapping(events []Event, from, to time.Time) []Event {
|
||||||
|
var out []Event
|
||||||
|
for _, e := range events {
|
||||||
|
if e.End.After(from) && e.Start.Before(to) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||||
|
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
||||||
|
// the day prefix carries the identity and this only disambiguates within a day.
|
||||||
|
func safeKey(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ' ' || r == '_':
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseICalDayKeepsOnlyToday(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
body := []byte(`BEGIN:VCALENDAR
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:a@example
|
||||||
|
DTSTART:20260703T090000Z
|
||||||
|
DTEND:20260703T100000Z
|
||||||
|
SUMMARY:Morning standup
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART:20260703T140000Z
|
||||||
|
DTEND:20260703T150000Z
|
||||||
|
SUMMARY:Team sync
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART:20260702T140000Z
|
||||||
|
DTEND:20260702T150000Z
|
||||||
|
SUMMARY:Yesterday retro
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART:20260704T090000Z
|
||||||
|
DTEND:20260704T100000Z
|
||||||
|
SUMMARY:Tomorrow standup
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;VALUE=DATE:20260704
|
||||||
|
DTEND;VALUE=DATE:20260705
|
||||||
|
SUMMARY:All-day event
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`)
|
||||||
|
|
||||||
|
events := ParseICalDay(body, now)
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Summary != "Morning standup" || events[0].UID != "a@example" {
|
||||||
|
t.Errorf("events[0] = %+v", events[0])
|
||||||
|
}
|
||||||
|
if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("events[0].Start = %v", events[0].Start)
|
||||||
|
}
|
||||||
|
if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("events[0].End = %v", events[0].End)
|
||||||
|
}
|
||||||
|
if events[1].Summary != "Team sync" {
|
||||||
|
t.Errorf("events[1].Summary = %q", events[1].Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: "today" is the owner's day, in the owner's location. Taking the
|
||||||
|
// day number off a local clock but building the boundaries in UTC made the
|
||||||
|
// evening fall outside the window on any box east of Greenwich.
|
||||||
|
func TestParseICalDayUsesOwnersDay(t *testing.T) {
|
||||||
|
plus4 := time.FixedZone("+04", 4*60*60)
|
||||||
|
// 01:00 on Aug 1 local is 21:00 on Jul 31 UTC.
|
||||||
|
now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4)
|
||||||
|
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
|
||||||
|
"DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" +
|
||||||
|
"END:VEVENT\nEND:VCALENDAR")
|
||||||
|
|
||||||
|
events := ParseICalDay(body, now)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("got %d events, want the in-progress one", len(events))
|
||||||
|
}
|
||||||
|
if !Busy(events, now.UTC()) {
|
||||||
|
t.Error("an event in progress right now must read as busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVEVENT(t *testing.T) {
|
||||||
|
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||||
|
e, ok := parseVEVENT(block)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a parsed event")
|
||||||
|
}
|
||||||
|
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("start = %v", e.Start)
|
||||||
|
}
|
||||||
|
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("end = %v", e.End)
|
||||||
|
}
|
||||||
|
if e.Summary != "Stand up meeting" {
|
||||||
|
t.Errorf("summary = %q", e.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||||
|
if _, ok := parseVEVENT(allDay); ok {
|
||||||
|
t.Error("all-day event should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDT(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
want time.Time
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||||
|
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
|
||||||
|
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
|
||||||
|
{"garbage", "DTSTART:garbage", time.Time{}, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := parseDT(tt.line)
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||||
|
}
|
||||||
|
if !got.Equal(tt.want) {
|
||||||
|
t.Errorf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeKey(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"Stand up meeting", "Stand-up-meeting"},
|
||||||
|
{"Hello_World", "Hello-World"},
|
||||||
|
{"special@#$chars!!", "specialchars"},
|
||||||
|
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := safeKey(tt.in); got != tt.want {
|
||||||
|
t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactKeyAndValue(t *testing.T) {
|
||||||
|
e := Event{
|
||||||
|
Summary: "Team sync",
|
||||||
|
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
|
||||||
|
t.Errorf("FactKey = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
|
||||||
|
t.Errorf("FactValue = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
|
||||||
|
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
|
||||||
|
t.Error("FactKey must start with the day prefix the store range-scans on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusyAndOverlapping(t *testing.T) {
|
||||||
|
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
events := []Event{
|
||||||
|
{Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)},
|
||||||
|
{Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)},
|
||||||
|
}
|
||||||
|
if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) {
|
||||||
|
t.Error("should be busy inside the early event")
|
||||||
|
}
|
||||||
|
if Busy(events, base.Add(12*time.Hour)) {
|
||||||
|
t.Error("should be free at noon")
|
||||||
|
}
|
||||||
|
// Half-open: the end instant is free.
|
||||||
|
if Busy(events, base.Add(10*time.Hour)) {
|
||||||
|
t.Error("the end instant should not count as busy")
|
||||||
|
}
|
||||||
|
got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour))
|
||||||
|
if len(got) != 1 || got[0].Summary != "early" {
|
||||||
|
t.Fatalf("Overlapping = %+v", got)
|
||||||
|
}
|
||||||
|
all := Overlapping(events, base, base.AddDate(0, 0, 1))
|
||||||
|
if len(all) != 2 || all[0].Summary != "early" {
|
||||||
|
t.Fatalf("Overlapping must sort by start: %+v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceTrust(t *testing.T) {
|
||||||
|
if ReadOnlySource(SourcePersonal) {
|
||||||
|
t.Error("the personal calendar is the one maven may render to")
|
||||||
|
}
|
||||||
|
if !ReadOnlySource(SourceWork) {
|
||||||
|
t.Error("the work calendar must be read-only")
|
||||||
|
}
|
||||||
|
if !ReadOnlySource(SourceAmbient) {
|
||||||
|
t.Error("an ambient notification is not a writable calendar")
|
||||||
|
}
|
||||||
|
if AmbientConfidence >= 1.0 {
|
||||||
|
t.Error("ambient events must be less trusted than a calendar read")
|
||||||
|
}
|
||||||
|
if len(Sources()) != 3 {
|
||||||
|
t.Errorf("Sources() = %v", Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseICal scans iCal text for VEVENT components and returns the events
|
||||||
|
// overlapping [from, to). All-day events are skipped: parseDT reports no time
|
||||||
|
// for a VALUE=DATE value, and an event with no clock reading answers neither
|
||||||
|
// the busy gate nor the day plan.
|
||||||
|
func ParseICal(body []byte, from, to time.Time) []Event {
|
||||||
|
var events []Event
|
||||||
|
text := string(body)
|
||||||
|
for {
|
||||||
|
i := strings.Index(text, "BEGIN:VEVENT")
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = text[i+len("BEGIN:VEVENT"):]
|
||||||
|
j := strings.Index(text, "END:VEVENT")
|
||||||
|
if j < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
block := text[:j]
|
||||||
|
text = text[j+len("END:VEVENT"):]
|
||||||
|
|
||||||
|
e, ok := parseVEVENT(block)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.End.After(from) && e.Start.Before(to) {
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
|
||||||
|
// location — the window cmd/mavcaldav polls.
|
||||||
|
//
|
||||||
|
// The location matters. The old inline version took the day number off a local
|
||||||
|
// clock reading but built the boundaries in UTC, so east of Greenwich the
|
||||||
|
// window was shifted by the offset and part of the evening fell outside
|
||||||
|
// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The
|
||||||
|
// owner's day is the day the day plan and the busy gate mean.
|
||||||
|
func ParseICalDay(body []byte, now time.Time) []Event {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
return ParseICal(body, start, start.AddDate(0, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
|
||||||
|
// Reports false for all-day events and parse failures.
|
||||||
|
func parseVEVENT(block string) (Event, bool) {
|
||||||
|
var e Event
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "DTSTART"):
|
||||||
|
if t, ok := parseDT(line); ok {
|
||||||
|
e.Start = t
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "DTEND"):
|
||||||
|
if t, ok := parseDT(line); ok {
|
||||||
|
e.End = t
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "SUMMARY"):
|
||||||
|
e.Summary = afterColon(line)
|
||||||
|
case strings.HasPrefix(line, "UID"):
|
||||||
|
e.UID = afterColon(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Start.IsZero() || e.End.IsZero() {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func afterColon(line string) string {
|
||||||
|
if i := strings.Index(line, ":"); i >= 0 {
|
||||||
|
return strings.TrimSpace(line[i+1:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDT parses a DTSTART/DTEND value:
|
||||||
|
//
|
||||||
|
// - UTC: DTEND:20260703T100000Z
|
||||||
|
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
||||||
|
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
|
||||||
|
//
|
||||||
|
// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the
|
||||||
|
// CalDAV server and the poller run in the same timezone, and the busy gate only
|
||||||
|
// needs busy/not-busy to be right.
|
||||||
|
func parseDT(line string) (time.Time, bool) {
|
||||||
|
if strings.Contains(line, "VALUE=DATE:") {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(line, ":")
|
||||||
|
if i < 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z")
|
||||||
|
t, err := time.Parse("20060102T150405", val)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t.UTC(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV
|
||||||
|
// collection. One event per file is the CalDAV convention, so callers normally
|
||||||
|
// pass a single event.
|
||||||
|
//
|
||||||
|
// This is the write half of #127 and it only ever renders: the canonical state
|
||||||
|
// is sqlite, the calendar is a view of it. Nothing reads a rendered file back.
|
||||||
|
func RenderICal(events []Event) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//maven//local calendar//RU\r\n")
|
||||||
|
for _, e := range events {
|
||||||
|
b.WriteString("BEGIN:VEVENT\r\n")
|
||||||
|
fmt.Fprintf(&b, "UID:%s\r\n", escapeText(e.UID))
|
||||||
|
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "DTSTART:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "DTEND:%s\r\n", e.End.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeText(e.Summary))
|
||||||
|
b.WriteString("END:VEVENT\r\n")
|
||||||
|
}
|
||||||
|
b.WriteString("END:VCALENDAR\r\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeText applies RFC 5545 TEXT escaping and strips the line breaks that
|
||||||
|
// would otherwise let a reminder payload inject iCal properties.
|
||||||
|
func escapeText(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||||
|
s = strings.ReplaceAll(s, ";", "\\;")
|
||||||
|
s = strings.ReplaceAll(s, ",", "\\,")
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "\\n")
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderICalRoundTrips(t *testing.T) {
|
||||||
|
e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0)
|
||||||
|
if e.UID != "maven-reminder-7" {
|
||||||
|
t.Errorf("UID = %q", e.UID)
|
||||||
|
}
|
||||||
|
if got := e.End.Sub(e.Start); got != DefaultReminderDuration {
|
||||||
|
t.Errorf("duration = %v, want %v", got, DefaultReminderDuration)
|
||||||
|
}
|
||||||
|
if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want {
|
||||||
|
t.Errorf("ReminderPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := RenderICal([]Event{e})
|
||||||
|
if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") {
|
||||||
|
t.Fatalf("not a VCALENDAR body:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour))
|
||||||
|
if len(back) != 1 {
|
||||||
|
t.Fatalf("got %d events back, want 1:\n%s", len(back), body)
|
||||||
|
}
|
||||||
|
if back[0].UID != e.UID || back[0].Summary != e.Summary {
|
||||||
|
t.Errorf("round trip lost identity: %+v", back[0])
|
||||||
|
}
|
||||||
|
if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) {
|
||||||
|
t.Errorf("round trip lost times: %+v", back[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderICalIsDeterministic(t *testing.T) {
|
||||||
|
e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0)
|
||||||
|
if RenderICal([]Event{e}) != RenderICal([]Event{e}) {
|
||||||
|
t.Error("the same reminder must render byte-identically, or every poll re-PUTs it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reminder payload is owner-supplied text. It must not be able to close the
|
||||||
|
// VEVENT and inject properties of its own.
|
||||||
|
func TestRenderICalEscapesInjection(t *testing.T) {
|
||||||
|
e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC),
|
||||||
|
"обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0)
|
||||||
|
body := RenderICal([]Event{e})
|
||||||
|
// Count line-initial occurrences: the escaped text still contains the
|
||||||
|
// characters "BEGIN:VEVENT", it just can no longer start a line.
|
||||||
|
if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 {
|
||||||
|
t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body)
|
||||||
|
}
|
||||||
|
if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 {
|
||||||
|
t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) {
|
||||||
|
t.Errorf("newlines should be escaped, not dropped:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||||
|
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||||
|
if e.Summary != "напоминание" {
|
||||||
|
t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReminderUIDPrefix namespaces every event maven publishes. Two reasons it is a
|
||||||
|
// fixed prefix and not a random UUID: the render is idempotent (the same
|
||||||
|
// reminder always lands on the same UID, so re-rendering overwrites instead of
|
||||||
|
// duplicating), and everything maven owns in the target collection is
|
||||||
|
// identifiable at a glance — she never touches a file she did not create.
|
||||||
|
const ReminderUIDPrefix = "maven-reminder-"
|
||||||
|
|
||||||
|
// DefaultReminderDuration — how long a rendered reminder occupies. A reminder
|
||||||
|
// is an instant, a calendar entry is a span, so one has to be invented; 30
|
||||||
|
// minutes reads as a block in a calendar app without swallowing the afternoon.
|
||||||
|
const DefaultReminderDuration = 30 * time.Minute
|
||||||
|
|
||||||
|
// ReminderEvent maps a reminder to the event that represents it. id and fire
|
||||||
|
// come from the store; payload is the RU text as the owner said it, rendered
|
||||||
|
// verbatim as the summary — the calendar is a view of sqlite, not a place to
|
||||||
|
// rephrase.
|
||||||
|
func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration) Event {
|
||||||
|
if dur <= 0 {
|
||||||
|
dur = DefaultReminderDuration
|
||||||
|
}
|
||||||
|
summary := strings.TrimSpace(payload)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "напоминание"
|
||||||
|
}
|
||||||
|
return Event{
|
||||||
|
UID: fmt.Sprintf("%s%d", ReminderUIDPrefix, id),
|
||||||
|
Summary: summary,
|
||||||
|
Start: fire,
|
||||||
|
End: fire.Add(dur),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderPath is the collection-relative filename for a rendered reminder.
|
||||||
|
// One event per resource, per the CalDAV convention.
|
||||||
|
func ReminderPath(id int64) string {
|
||||||
|
return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchSince is the whole read path in one call: connect, log in, examine the
|
||||||
|
// mailbox read-only, list what arrived since a date, fetch and parse the ones
|
||||||
|
// the caller has not seen, log out.
|
||||||
|
//
|
||||||
|
// It is a function rather than a long-lived object because a mail poller should
|
||||||
|
// not hold an authenticated session (and therefore his credential in a live TLS
|
||||||
|
// state) between polls. Connect, read, drop.
|
||||||
|
//
|
||||||
|
// skip decides which UIDs are already known — the poller's seen-set. max bounds
|
||||||
|
// one poll: a mailbox that received 400 messages overnight must not turn into
|
||||||
|
// 400 LLM calls, and the newest max are the ones a task could still be hiding
|
||||||
|
// in. Junk messages are returned too, flagged, so the caller can mark them seen
|
||||||
|
// without a second protocol round.
|
||||||
|
type FetchSince struct {
|
||||||
|
Addr string // host or host:993
|
||||||
|
User string
|
||||||
|
Mailbox string // e.g. "INBOX"
|
||||||
|
Timeout time.Duration
|
||||||
|
Since time.Time
|
||||||
|
Max int
|
||||||
|
Skip func(uid uint32) bool
|
||||||
|
|
||||||
|
// dial is the connection seam. nil means Dial (implicit TLS); the tests set
|
||||||
|
// it to an in-process fake. Unexported so no configuration path can point
|
||||||
|
// the reader at a non-TLS transport.
|
||||||
|
dial func(addr string, timeout time.Duration) (*Conn, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run performs one read. password is passed here, not stored in the struct, so
|
||||||
|
// the configuration of a mailbox and the secret for it are never the same value
|
||||||
|
// sitting in the same place.
|
||||||
|
func (f FetchSince) Run(password string) ([]Message, error) {
|
||||||
|
if f.Addr == "" || f.User == "" || f.Mailbox == "" {
|
||||||
|
return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)")
|
||||||
|
}
|
||||||
|
dial := f.dial
|
||||||
|
if dial == nil {
|
||||||
|
dial = Dial
|
||||||
|
}
|
||||||
|
c, err := dial(f.Addr, f.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Login(f.User, password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer c.Logout()
|
||||||
|
if err := c.Select(f.Mailbox); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uids, err := c.SearchSince(f.Since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest UIDs first — IMAP hands them back ascending, and when Max clips the
|
||||||
|
// list the recent mail is what matters.
|
||||||
|
wanted := make([]uint32, 0, len(uids))
|
||||||
|
for i := len(uids) - 1; i >= 0; i-- {
|
||||||
|
if f.Skip != nil && f.Skip(uids[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wanted = append(wanted, uids[i])
|
||||||
|
if f.Max > 0 && len(wanted) >= f.Max {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Message, 0, len(wanted))
|
||||||
|
for _, uid := range wanted {
|
||||||
|
raw, err := c.Fetch(uid)
|
||||||
|
if err != nil {
|
||||||
|
// One unreadable message does not abandon the poll; the rest of the
|
||||||
|
// mailbox is still worth reading. The error names the UID, not the
|
||||||
|
// message.
|
||||||
|
return out, fmt.Errorf("email: fetch uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue // vanished between SEARCH and FETCH
|
||||||
|
}
|
||||||
|
msg, err := ParseMessage(uid, raw)
|
||||||
|
if err != nil {
|
||||||
|
continue // unparsable headers — nothing to review, skip silently
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchSinceRun(t *testing.T) {
|
||||||
|
mk := func(subject string) string {
|
||||||
|
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
|
||||||
|
}
|
||||||
|
f := &fakeIMAP{
|
||||||
|
uids: []uint32{1, 2, 3},
|
||||||
|
msgs: map[uint32]string{1: mk("one"), 2: mk("two"), 3: mk("three")},
|
||||||
|
}
|
||||||
|
fs := FetchSince{
|
||||||
|
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
|
||||||
|
Max: 2,
|
||||||
|
Skip: func(uid uint32) bool { return uid == 3 },
|
||||||
|
dial: func(addr string, timeout time.Duration) (*Conn, error) {
|
||||||
|
cli, srv := net.Pipe()
|
||||||
|
go f.serve(t, srv)
|
||||||
|
return NewConn(cli, timeout)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgs, err := fs.Run("secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
// Newest first, the already-seen UID skipped, Max respected.
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("got %d messages, want 2: %+v", len(msgs), msgs)
|
||||||
|
}
|
||||||
|
if msgs[0].Subject != "two" || msgs[1].Subject != "one" {
|
||||||
|
t.Errorf("subjects = %q,%q, want two,one (newest first)", msgs[0].Subject, msgs[1].Subject)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(f.cmds, " "), "UID FETCH 3") {
|
||||||
|
t.Error("a skipped UID must not be fetched again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSinceRequiresConfig(t *testing.T) {
|
||||||
|
if _, err := (FetchSince{}).Run("secret"); err == nil {
|
||||||
|
t.Fatal("an unconfigured mailbox must not be read")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A minimal IMAP4rev1 client — LOGIN, SELECT, UID SEARCH, UID FETCH with
|
||||||
|
// BODY.PEEK, LOGOUT, and nothing else.
|
||||||
|
//
|
||||||
|
// Why hand-rolled instead of go-imap: the whole surface Maven needs is five
|
||||||
|
// commands, and this is the one code path that holds his mailbox credential and
|
||||||
|
// reads his private mail. A ~200-line client with no dependencies is auditable
|
||||||
|
// in one sitting; a general-purpose IMAP library is a much larger amount of
|
||||||
|
// code doing much more than we asked, in the most sensitive place in the tree.
|
||||||
|
// If IDLE, CONDSTORE or server-side threading ever become worth having, that
|
||||||
|
// trade should be re-made deliberately.
|
||||||
|
//
|
||||||
|
// BODY.PEEK[] rather than BODY[] is load-bearing: Maven reads his mail and must
|
||||||
|
// leave no trace of having done so. Reading a message here does not mark it
|
||||||
|
// \Seen, so the unread state in his own mail client stays his.
|
||||||
|
|
||||||
|
// DefaultIMAPPort — implicit-TLS IMAP. There is no cleartext and no STARTTLS
|
||||||
|
// path in this client: an option to send his password over a plain socket is an
|
||||||
|
// option to get it wrong once.
|
||||||
|
const DefaultIMAPPort = "993"
|
||||||
|
|
||||||
|
// Conn — one authenticated IMAP connection. Not safe for concurrent use; the
|
||||||
|
// poller drives one connection at a time.
|
||||||
|
type Conn struct {
|
||||||
|
rwc io.ReadWriteCloser
|
||||||
|
r *bufio.Reader
|
||||||
|
tag int
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens an implicit-TLS connection and reads the server greeting.
|
||||||
|
func Dial(addr string, timeout time.Duration) (*Conn, error) {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
host, addr = addr, net.JoinHostPort(addr, DefaultIMAPPort)
|
||||||
|
}
|
||||||
|
d := &net.Dialer{Timeout: timeout}
|
||||||
|
// ServerName is set from the host we asked for: certificate verification is
|
||||||
|
// the only thing standing between his password and a MITM on the way out.
|
||||||
|
c, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email: dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
return NewConn(c, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConn wraps an already-open stream (the tests speak IMAP over a pipe) and
|
||||||
|
// consumes the greeting.
|
||||||
|
func NewConn(rwc io.ReadWriteCloser, timeout time.Duration) (*Conn, error) {
|
||||||
|
c := &Conn{rwc: rwc, r: bufio.NewReaderSize(rwc, 64<<10), timeout: timeout}
|
||||||
|
line, err := c.readLine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email: greeting: %w", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
|
||||||
|
c.rwc.Close()
|
||||||
|
return nil, fmt.Errorf("email: server refused connection: %s", line)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) Close() error { return c.rwc.Close() }
|
||||||
|
|
||||||
|
// Login authenticates with LOGIN. The password is passed as an argument and
|
||||||
|
// never stored on the Conn: nothing in this package keeps a credential alive
|
||||||
|
// past the command that uses it, so no struct dump or panic trace can carry it.
|
||||||
|
func (c *Conn) Login(user, pass string) error {
|
||||||
|
// The command line itself is never logged (see exec) — a LOGIN line IS the
|
||||||
|
// credential.
|
||||||
|
if _, err := c.exec(fmt.Sprintf("LOGIN %s %s", quote(user), quote(pass))); err != nil {
|
||||||
|
return fmt.Errorf("email: login: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select opens a mailbox read-only. EXAMINE, not SELECT: read-only at the
|
||||||
|
// protocol level means no command in this session can change a flag, expunge a
|
||||||
|
// message, or move anything, even by mistake.
|
||||||
|
func (c *Conn) Select(mailbox string) error {
|
||||||
|
if _, err := c.exec(fmt.Sprintf("EXAMINE %s", quote(mailbox))); err != nil {
|
||||||
|
return fmt.Errorf("email: examine %s: %w", mailbox, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSince returns the UIDs of messages received on or after since. An
|
||||||
|
// unlimited search is not offered: the first poll against a years-old mailbox
|
||||||
|
// would otherwise fetch everything and hand a decade of mail to the model.
|
||||||
|
//
|
||||||
|
// The IMAP SINCE key has date granularity (and compares the server's internal
|
||||||
|
// date), so the result can include messages slightly older than since. The
|
||||||
|
// caller dedupes by UID anyway, so a wider window costs one extra fetch.
|
||||||
|
func (c *Conn) SearchSince(since time.Time) ([]uint32, error) {
|
||||||
|
cmd := fmt.Sprintf("UID SEARCH SINCE %s", since.Format("2-Jan-2006"))
|
||||||
|
lines, err := c.exec(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email: search: %w", err)
|
||||||
|
}
|
||||||
|
var uids []uint32
|
||||||
|
for _, l := range lines {
|
||||||
|
rest, ok := untagged(l, "SEARCH")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range strings.Fields(rest) {
|
||||||
|
n, err := strconv.ParseUint(f, 10, 32)
|
||||||
|
if err == nil {
|
||||||
|
uids = append(uids, uint32(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var literalSize = regexp.MustCompile(`\{(\d+)\}$`)
|
||||||
|
|
||||||
|
// Fetch returns the raw RFC 5322 bytes of one message, by UID.
|
||||||
|
//
|
||||||
|
// Returns (nil, nil) when the UID no longer exists — a message he deleted
|
||||||
|
// between SEARCH and FETCH is normal, not an error.
|
||||||
|
func (c *Conn) Fetch(uid uint32) ([]byte, error) {
|
||||||
|
tag := c.nextTag()
|
||||||
|
if err := c.send(fmt.Sprintf("%s UID FETCH %d (BODY.PEEK[])", tag, uid)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raw []byte
|
||||||
|
for {
|
||||||
|
line, err := c.readLine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
if done, err := c.tagged(tag, line); done {
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
m := literalSize.FindStringSubmatch(strings.TrimSpace(line))
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(c.r, buf); err != nil {
|
||||||
|
return nil, fmt.Errorf("email: fetch %d: literal: %w", uid, err)
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
raw = buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout ends the session politely. A failure is not worth reporting — the
|
||||||
|
// connection is being closed either way.
|
||||||
|
func (c *Conn) Logout() {
|
||||||
|
_, _ = c.exec("LOGOUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- protocol plumbing -----------------------------------------------------
|
||||||
|
|
||||||
|
func (c *Conn) nextTag() string {
|
||||||
|
c.tag++
|
||||||
|
return fmt.Sprintf("a%03d", c.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec sends one command and returns the untagged response lines.
|
||||||
|
//
|
||||||
|
// Neither the command nor the response is ever logged here. LOGIN goes through
|
||||||
|
// this function, and a debug line "sent: a001 LOGIN ..." is how a credential
|
||||||
|
// ends up in a log file forever.
|
||||||
|
func (c *Conn) exec(cmd string) ([]string, error) {
|
||||||
|
tag := c.nextTag()
|
||||||
|
if err := c.send(tag + " " + cmd); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for {
|
||||||
|
line, err := c.readLine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if done, err := c.tagged(tag, line); done {
|
||||||
|
return lines, err
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
// A response line may carry a literal (e.g. a header FETCH). Nothing we
|
||||||
|
// send asks for one outside Fetch, but skip it if it appears so the
|
||||||
|
// stream stays aligned.
|
||||||
|
if m := literalSize.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
||||||
|
if n, err := strconv.Atoi(m[1]); err == nil {
|
||||||
|
if _, err := io.CopyN(io.Discard, c.r, int64(n)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagged reports whether line completes the command with this tag, and turns a
|
||||||
|
// NO/BAD completion into an error. The error text is the server's, which never
|
||||||
|
// echoes a password.
|
||||||
|
func (c *Conn) tagged(tag, line string) (bool, error) {
|
||||||
|
if !strings.HasPrefix(line, tag+" ") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(line[len(tag):])
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(rest, "OK"):
|
||||||
|
return true, nil
|
||||||
|
case strings.HasPrefix(rest, "NO"), strings.HasPrefix(rest, "BAD"):
|
||||||
|
return true, fmt.Errorf("server said: %s", rest)
|
||||||
|
default:
|
||||||
|
return true, fmt.Errorf("unexpected completion: %s", rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) send(line string) error {
|
||||||
|
c.setDeadline()
|
||||||
|
if _, err := io.WriteString(c.rwc, line+"\r\n"); err != nil {
|
||||||
|
return fmt.Errorf("email: write: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) readLine() (string, error) {
|
||||||
|
c.setDeadline()
|
||||||
|
line, err := c.r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimRight(line, "\r\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDeadline applies the per-connection timeout when the transport supports
|
||||||
|
// one. A hung IMAP server must not park the poller forever.
|
||||||
|
func (c *Conn) setDeadline() {
|
||||||
|
if c.timeout <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d, ok := c.rwc.(interface{ SetDeadline(time.Time) error }); ok {
|
||||||
|
_ = d.SetDeadline(time.Now().Add(c.timeout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// untagged splits "* SEARCH 1 2 3" into its payload when the key matches.
|
||||||
|
func untagged(line, key string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(line, "* ") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(line[2:])
|
||||||
|
if !strings.HasPrefix(rest, key) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(rest[len(key):]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// quote renders an IMAP quoted string. Passwords routinely contain characters
|
||||||
|
// that would otherwise end the argument early, and CR/LF are stripped rather
|
||||||
|
// than escaped because there is no legal way to send them — a credential file
|
||||||
|
// with a stray newline must not become a second command.
|
||||||
|
func quote(s string) string {
|
||||||
|
s = strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||||
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeIMAP is a scripted server: enough of IMAP to exercise the client, and
|
||||||
|
// nothing more. It records the commands it received so a test can assert on the
|
||||||
|
// protocol (BODY.PEEK rather than BODY, EXAMINE rather than SELECT).
|
||||||
|
type fakeIMAP struct {
|
||||||
|
msgs map[uint32]string
|
||||||
|
uids []uint32
|
||||||
|
cmds []string
|
||||||
|
failOn string // substring of a command to answer NO
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIMAP) serve(t *testing.T, c net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
defer c.Close()
|
||||||
|
fmt.Fprint(c, "* OK fake IMAP ready\r\n")
|
||||||
|
r := bufio.NewReader(c)
|
||||||
|
for {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
parts := strings.SplitN(line, " ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, cmd := parts[0], parts[1]
|
||||||
|
f.cmds = append(f.cmds, cmd)
|
||||||
|
if f.failOn != "" && strings.Contains(cmd, f.failOn) {
|
||||||
|
fmt.Fprintf(c, "%s NO computer says no\r\n", tag)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(cmd)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
|
||||||
|
fmt.Fprintf(c, "%s OK done\r\n", tag)
|
||||||
|
case strings.HasPrefix(upper, "UID SEARCH"):
|
||||||
|
var ids []string
|
||||||
|
for _, u := range f.uids {
|
||||||
|
ids = append(ids, strconv.FormatUint(uint64(u), 10))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "* SEARCH %s\r\n", strings.Join(ids, " "))
|
||||||
|
fmt.Fprintf(c, "%s OK search done\r\n", tag)
|
||||||
|
case strings.HasPrefix(upper, "UID FETCH"):
|
||||||
|
uid64, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
|
||||||
|
raw, ok := f.msgs[uint32(uid64)]
|
||||||
|
if ok {
|
||||||
|
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, len(raw))
|
||||||
|
fmt.Fprint(c, raw)
|
||||||
|
fmt.Fprint(c, ")\r\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "%s OK fetch done\r\n", tag)
|
||||||
|
case strings.HasPrefix(upper, "LOGOUT"):
|
||||||
|
fmt.Fprint(c, "* BYE\r\n")
|
||||||
|
fmt.Fprintf(c, "%s OK bye\r\n", tag)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(c, "%s BAD unknown\r\n", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialFake wires a client Conn to an in-process server over net.Pipe.
|
||||||
|
func dialFake(t *testing.T, f *fakeIMAP) *Conn {
|
||||||
|
t.Helper()
|
||||||
|
cli, srv := net.Pipe()
|
||||||
|
go f.serve(t, srv)
|
||||||
|
c, err := NewConn(cli, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("greeting: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { c.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIMAPRoundTrip(t *testing.T) {
|
||||||
|
body := "Subject: hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCall the bank.\r\n"
|
||||||
|
f := &fakeIMAP{uids: []uint32{4, 9}, msgs: map[uint32]string{4: body, 9: body}}
|
||||||
|
c := dialFake(t, f)
|
||||||
|
|
||||||
|
if err := c.Login("kami", `pa"ss\word`); err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Select("INBOX"); err != nil {
|
||||||
|
t.Fatalf("select: %v", err)
|
||||||
|
}
|
||||||
|
uids, err := c.SearchSince(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
if len(uids) != 2 || uids[0] != 4 || uids[1] != 9 {
|
||||||
|
t.Fatalf("uids = %v, want [4 9]", uids)
|
||||||
|
}
|
||||||
|
raw, err := c.Fetch(9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch: %v", err)
|
||||||
|
}
|
||||||
|
if string(raw) != body {
|
||||||
|
t.Errorf("fetched %q, want the literal verbatim", raw)
|
||||||
|
}
|
||||||
|
c.Logout()
|
||||||
|
|
||||||
|
joined := strings.Join(f.cmds, "\n")
|
||||||
|
// Read-only at the protocol level, and peeking — Maven must leave no trace
|
||||||
|
// of having read his mail.
|
||||||
|
if !strings.Contains(joined, "EXAMINE") || strings.Contains(joined, "SELECT ") {
|
||||||
|
t.Errorf("want EXAMINE (read-only), got:\n%s", joined)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "BODY.PEEK[]") {
|
||||||
|
t.Errorf("want BODY.PEEK, got:\n%s", joined)
|
||||||
|
}
|
||||||
|
// The password must have been quoted and escaped, not truncated at the quote.
|
||||||
|
if !strings.Contains(joined, `"pa\"ss\\word"`) {
|
||||||
|
t.Errorf("password not quoted correctly:\n%s", joined)
|
||||||
|
}
|
||||||
|
// SINCE must carry the IMAP date form.
|
||||||
|
if !strings.Contains(joined, "SINCE 1-Aug-2026") {
|
||||||
|
t.Errorf("want a SINCE date, got:\n%s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIMAPServerNoIsAnError(t *testing.T) {
|
||||||
|
f := &fakeIMAP{failOn: "LOGIN"}
|
||||||
|
c := dialFake(t, f)
|
||||||
|
err := c.Login("kami", "wrong")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a NO completion must be an error")
|
||||||
|
}
|
||||||
|
// The error is the server's text; it must not echo the credential.
|
||||||
|
if strings.Contains(err.Error(), "wrong") {
|
||||||
|
t.Errorf("error leaks the password: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIMAPFetchMissingUID(t *testing.T) {
|
||||||
|
f := &fakeIMAP{uids: []uint32{1}, msgs: map[uint32]string{}}
|
||||||
|
c := dialFake(t, f)
|
||||||
|
raw, err := c.Fetch(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch: %v", err)
|
||||||
|
}
|
||||||
|
if raw != nil {
|
||||||
|
t.Errorf("a vanished UID should give nil, got %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteStripsNewlines(t *testing.T) {
|
||||||
|
if got := quote("pass\r\nA1 LOGOUT"); strings.ContainsAny(got, "\r\n") {
|
||||||
|
t.Errorf("quote kept a line break: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The junk filter — the cheapest and most important half of reading mail.
|
||||||
|
//
|
||||||
|
// A mailbox is mostly machine-generated: newsletters, receipts nobody acts on,
|
||||||
|
// social notifications, marketing. Sending all of it to a 1.7B and asking "is
|
||||||
|
// there a task here" produces confident nonsense at a rate proportional to the
|
||||||
|
// volume, so junk is decided by HEADERS, before any model sees the message.
|
||||||
|
//
|
||||||
|
// The rules are all bulk-mail markers that senders set on themselves, never
|
||||||
|
// guesses about content:
|
||||||
|
//
|
||||||
|
// - List-Unsubscribe / List-Id — by definition a mailing list. If he can
|
||||||
|
// unsubscribe from it, it is not asking him to do anything.
|
||||||
|
// - Precedence: bulk|junk|list — the sender declaring itself bulk.
|
||||||
|
// - Auto-Submitted other than "no" (RFC 3834) — generated by a machine.
|
||||||
|
// - X-Spam-Flag: YES, X-Spam-Status: Yes — the spam filter upstream already
|
||||||
|
// decided; we do not second-guess it in the other direction.
|
||||||
|
// - X-GM-LABELS / X-Gmail-Labels containing a Gmail category — Gmail's own
|
||||||
|
// Promotions/Social/Forums/Spam classification, when the server sends it.
|
||||||
|
//
|
||||||
|
// Deliberately NOT here: sender allow/deny lists and subject keyword matching.
|
||||||
|
// Both are configuration that ages badly and both would be a place for his
|
||||||
|
// contacts to end up in a config file. If a real correspondent's mail is being
|
||||||
|
// dropped, the fix is a rule about a header, not a list of names.
|
||||||
|
//
|
||||||
|
// A junk verdict never deletes anything and never touches a flag on the server.
|
||||||
|
// It means "do not spend the model on this", nothing more.
|
||||||
|
|
||||||
|
// junkHeaders — headers whose mere presence marks bulk mail.
|
||||||
|
var junkPresence = []string{"List-Unsubscribe", "List-Id", "List-Post"}
|
||||||
|
|
||||||
|
// gmailCategories — Gmail's category labels, lowercased as they appear in
|
||||||
|
// X-GM-LABELS. "important" and "inbox" are labels too, and are NOT categories.
|
||||||
|
// Matching is by these exact tokens (substring is fine — they are namespaced
|
||||||
|
// and cannot appear in a hand-made label by accident), so a user label named
|
||||||
|
// "Social Club" is not mistaken for Gmail's Social category.
|
||||||
|
var gmailCategories = []string{
|
||||||
|
"category_promotions", "category_social", "category_forums", "category_updates",
|
||||||
|
`\spam`, `\junk`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyJunk returns whether the message is bulk/automated and why. The
|
||||||
|
// reason is a short header name, safe to log — it names the marker, never the
|
||||||
|
// sender or the subject.
|
||||||
|
func classifyJunk(h mail.Header) (bool, string) {
|
||||||
|
for _, name := range junkPresence {
|
||||||
|
if strings.TrimSpace(h.Get(name)) != "" {
|
||||||
|
return true, strings.ToLower(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
|
||||||
|
case "bulk", "junk", "list":
|
||||||
|
return true, "precedence"
|
||||||
|
}
|
||||||
|
if v := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); v != "" && v != "no" {
|
||||||
|
return true, "auto-submitted"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(h.Get("X-Spam-Flag")), "yes") {
|
||||||
|
return true, "x-spam-flag"
|
||||||
|
}
|
||||||
|
if v := strings.ToLower(strings.TrimSpace(h.Get("X-Spam-Status"))); strings.HasPrefix(v, "yes") {
|
||||||
|
return true, "x-spam-status"
|
||||||
|
}
|
||||||
|
labels := strings.ToLower(h.Get("X-GM-LABELS") + " " + h.Get("X-Gmail-Labels"))
|
||||||
|
for _, c := range gmailCategories {
|
||||||
|
if c == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(labels, c) {
|
||||||
|
return true, "gmail-category"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func headers(t *testing.T, raw string) mail.Header {
|
||||||
|
t.Helper()
|
||||||
|
m, err := mail.ReadMessage(strings.NewReader(strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n\r\nbody\r\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read headers: %v", err)
|
||||||
|
}
|
||||||
|
return m.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyJunk(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
junk bool
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"personal", "From: a@b.c\nSubject: привет", false, ""},
|
||||||
|
{"list-unsubscribe", "From: a@b.c\nList-Unsubscribe: <mailto:u@b.c>", true, "list-unsubscribe"},
|
||||||
|
{"list-id", "From: a@b.c\nList-Id: <golang-nuts.example>", true, "list-id"},
|
||||||
|
{"precedence bulk", "From: a@b.c\nPrecedence: bulk", true, "precedence"},
|
||||||
|
{"auto-submitted", "From: a@b.c\nAuto-Submitted: auto-generated", true, "auto-submitted"},
|
||||||
|
{"auto-submitted no", "From: a@b.c\nAuto-Submitted: no", false, ""},
|
||||||
|
{"spam flag", "From: a@b.c\nX-Spam-Flag: YES", true, "x-spam-flag"},
|
||||||
|
{"spam status", "From: a@b.c\nX-Spam-Status: Yes, score=9.1", true, "x-spam-status"},
|
||||||
|
{"spam status no", "From: a@b.c\nX-Spam-Status: No, score=0.1", false, ""},
|
||||||
|
{"gmail promo", "From: a@b.c\nX-Gmail-Labels: Inbox,CATEGORY_PROMOTIONS", true, "gmail-category"},
|
||||||
|
{"user label", "From: a@b.c\nX-Gmail-Labels: Social Club,Important", false, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
junk, reason := classifyJunk(headers(t, c.raw))
|
||||||
|
if junk != c.junk || reason != c.reason {
|
||||||
|
t.Errorf("classifyJunk = (%v, %q), want (%v, %q)", junk, reason, c.junk, c.reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewsletterFixtureIsJunk(t *testing.T) {
|
||||||
|
msg, err := ParseMessage(9, fixture(t, "newsletter.eml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !msg.Junk {
|
||||||
|
t.Fatal("a newsletter with List-Unsubscribe + Precedence: bulk must be junk")
|
||||||
|
}
|
||||||
|
// The reason is what gets logged, so it must never carry mail content.
|
||||||
|
if strings.Contains(msg.JunkReason, "@") || strings.Contains(msg.JunkReason, "Скидки") {
|
||||||
|
t.Errorf("junk reason leaks content: %q", msg.JunkReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// Package email is the reading half of the email reader (Vikunja #246,
|
||||||
|
// docs/plans/01-email-reader.md): a small IMAP client, a MIME-to-plaintext
|
||||||
|
// converter, and the junk filter that decides a message is not worth reading at
|
||||||
|
// all. Extraction lives in extract.go and writes nothing itself.
|
||||||
|
//
|
||||||
|
// Two constraints shape everything here, both from CLAUDE.md:
|
||||||
|
//
|
||||||
|
// - Mail is personal. Nothing in this package logs a body, a subject, or an
|
||||||
|
// address; callers get the text and decide. Mail text is never search input
|
||||||
|
// — no function here reaches the network except the IMAP connection itself.
|
||||||
|
// - Off unless configured. There is no default host, no default account, and
|
||||||
|
// no fallback that would make a mailbox get read because a field was empty.
|
||||||
|
//
|
||||||
|
// The IMAP subset is deliberately tiny (LOGIN, SELECT, UID SEARCH, UID FETCH
|
||||||
|
// with BODY.PEEK, LOGOUT). No IDLE: a poll every few minutes is what a task
|
||||||
|
// candidate needs, and IDLE would mean holding a connection and a credential
|
||||||
|
// open forever for latency nobody is waiting on.
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxBodyBytes — how much of one message body is kept. A task hides in the
|
||||||
|
// first screenful; the rest is signature, quoted history and legal boilerplate,
|
||||||
|
// and it would only spend the resident model's 4096-token context.
|
||||||
|
const MaxBodyBytes = 4000
|
||||||
|
|
||||||
|
// Message — one mail, reduced to the fields extraction and review need.
|
||||||
|
//
|
||||||
|
// Raw is deliberately absent: once a message is parsed the original bytes are
|
||||||
|
// dropped, so no caller can accidentally log or forward the whole mail.
|
||||||
|
type Message struct {
|
||||||
|
UID uint32
|
||||||
|
From string
|
||||||
|
Subject string
|
||||||
|
Date string // as sent, unparsed — display only
|
||||||
|
Body string // plaintext, decoded, HTML-stripped, truncated
|
||||||
|
// Junk is set by the junk filter (see junk.go). A junk message is carried
|
||||||
|
// rather than dropped so the poller can count it and still mark it seen.
|
||||||
|
Junk bool
|
||||||
|
JunkReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMessage turns one RFC 5322 message into a Message.
|
||||||
|
//
|
||||||
|
// It never fails on a body it cannot understand: an unparsable or
|
||||||
|
// unsupported-charset body yields an empty Body and the headers still come
|
||||||
|
// through, because a subject line alone is often the whole task ("Счёт за
|
||||||
|
// интернет"). Only a message whose headers cannot be read at all is an error.
|
||||||
|
func ParseMessage(uid uint32, raw []byte) (Message, error) {
|
||||||
|
m, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, fmt.Errorf("email: parse message: %w", err)
|
||||||
|
}
|
||||||
|
msg := Message{
|
||||||
|
UID: uid,
|
||||||
|
From: decodeHeader(m.Header.Get("From")),
|
||||||
|
Subject: decodeHeader(m.Header.Get("Subject")),
|
||||||
|
Date: m.Header.Get("Date"),
|
||||||
|
}
|
||||||
|
msg.Junk, msg.JunkReason = classifyJunk(m.Header)
|
||||||
|
body, err := plaintextBody(m.Header.Get("Content-Type"), m.Header.Get("Content-Transfer-Encoding"), m.Body)
|
||||||
|
if err == nil {
|
||||||
|
msg.Body = truncate(collapse(body), MaxBodyBytes)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// plaintextBody walks the MIME tree and returns the best plaintext it can.
|
||||||
|
//
|
||||||
|
// Preference order inside a multipart: text/plain first, text/html stripped
|
||||||
|
// only when there is no plain part. multipart/mixed attachments are skipped
|
||||||
|
// wholesale — an attachment is a file, not a sentence, and reading one would
|
||||||
|
// mean parsing arbitrary formats from the network.
|
||||||
|
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
|
||||||
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if contentType == "" || err != nil {
|
||||||
|
// No Content-Type at all is legal and means text/plain; a broken one is
|
||||||
|
// treated the same rather than dropping the message.
|
||||||
|
mediaType, params = "text/plain", nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(mediaType, "multipart/"):
|
||||||
|
boundary := params["boundary"]
|
||||||
|
if boundary == "" {
|
||||||
|
return "", fmt.Errorf("email: multipart without boundary")
|
||||||
|
}
|
||||||
|
return multipartText(multipart.NewReader(body, boundary))
|
||||||
|
case mediaType == "text/html":
|
||||||
|
raw, err := decodeBody(body, encoding, params["charset"])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return stripHTML(raw), nil
|
||||||
|
case mediaType == "text/plain":
|
||||||
|
return decodeBody(body, encoding, params["charset"])
|
||||||
|
default:
|
||||||
|
// A single-part non-text message (a bare PDF, say). No body, headers only.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipartText reads one multipart level, recursing into nested multiparts.
|
||||||
|
// Returns the plain part if any part yielded one, else the stripped HTML.
|
||||||
|
func multipartText(mr *multipart.Reader) (string, error) {
|
||||||
|
var plain, html string
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// A truncated multipart still gives up whatever came before it.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if part.FileName() != "" {
|
||||||
|
part.Close()
|
||||||
|
continue // attachment
|
||||||
|
}
|
||||||
|
ct := part.Header.Get("Content-Type")
|
||||||
|
mediaType, _, _ := mime.ParseMediaType(ct)
|
||||||
|
text, err := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
|
||||||
|
part.Close()
|
||||||
|
if err != nil || strings.TrimSpace(text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mediaType == "text/html" && !strings.HasPrefix(mediaType, "multipart/") {
|
||||||
|
if html == "" {
|
||||||
|
html = text
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plain == "" {
|
||||||
|
plain = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(plain) != "" {
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
|
return html, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBody applies the transfer encoding, then the charset.
|
||||||
|
//
|
||||||
|
// Charset support is UTF-8 (and ASCII, its subset) only, on purpose: x/text's
|
||||||
|
// encoding tables are not vendored here, and guessing at windows-1251 bytes
|
||||||
|
// would feed the model mojibake it would happily extract a task from. An
|
||||||
|
// unsupported charset returns an error, which ParseMessage turns into an empty
|
||||||
|
// body — subject-only, which is honest.
|
||||||
|
func decodeBody(r io.Reader, encoding, charset string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "quoted-printable":
|
||||||
|
r = quotedprintable.NewReader(r)
|
||||||
|
case "base64":
|
||||||
|
r = newBase64Reader(r)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(r, 1<<20))
|
||||||
|
if err != nil && len(b) == 0 {
|
||||||
|
return "", fmt.Errorf("email: read body: %w", err)
|
||||||
|
}
|
||||||
|
switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
|
||||||
|
case "", "utf-8", "utf8", "us-ascii", "ascii":
|
||||||
|
return string(b), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("email: unsupported charset %q", cs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeHeader decodes RFC 2047 encoded words ("=?utf-8?B?...?="), which is how
|
||||||
|
// every Russian subject line arrives. Undecodable headers come back as-is
|
||||||
|
// rather than empty: a mangled subject is still a hint, and it is only ever
|
||||||
|
// shown to him as evidence.
|
||||||
|
func decodeHeader(v string) string {
|
||||||
|
dec := new(mime.WordDecoder)
|
||||||
|
out, err := dec.DecodeHeader(v)
|
||||||
|
if err != nil {
|
||||||
|
return collapse(v)
|
||||||
|
}
|
||||||
|
return collapse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
scriptStyle = regexp.MustCompile(`(?is)<(script|style)\b[^>]*>.*?</\s*(script|style)\s*>`)
|
||||||
|
htmlBreak = regexp.MustCompile(`(?i)<\s*(br\s*/?|/p|/div|/tr|/li|/h[1-6])\s*>`)
|
||||||
|
htmlTag = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||||
|
htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// stripHTML reduces an HTML part to text. A regex stripper, not a parser:
|
||||||
|
// x/net/html is not vendored, and the consumer is a model reading prose — a
|
||||||
|
// stray angle bracket costs nothing, whereas a new dependency for the privacy-
|
||||||
|
// sensitive path costs review.
|
||||||
|
func stripHTML(s string) string {
|
||||||
|
s = scriptStyle.ReplaceAllString(s, " ")
|
||||||
|
s = htmlComment.ReplaceAllString(s, " ")
|
||||||
|
s = htmlBreak.ReplaceAllString(s, "\n")
|
||||||
|
s = htmlTag.ReplaceAllString(s, " ")
|
||||||
|
return unescapeEntities(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities = strings.NewReplacer(
|
||||||
|
" ", " ", "&", "&", "<", "<", ">", ">",
|
||||||
|
""", `"`, "'", "'", "'", "'", "—", "—", "–", "–",
|
||||||
|
)
|
||||||
|
|
||||||
|
func unescapeEntities(s string) string { return entities.Replace(s) }
|
||||||
|
|
||||||
|
// collapse squeezes runs of whitespace, keeping single newlines. Mail bodies
|
||||||
|
// arrive with hard-wrapped lines and blocks of blank space; the model does not
|
||||||
|
// need them and they are pure context budget.
|
||||||
|
func collapse(s string) string {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
|
||||||
|
var out []string
|
||||||
|
blank := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
l = strings.TrimSpace(strings.Join(strings.Fields(l), " "))
|
||||||
|
if l == "" {
|
||||||
|
blank++
|
||||||
|
if blank > 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blank = 0
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate cuts to n bytes on a rune boundary.
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := s[:n]
|
||||||
|
for len(cut) > 0 && !isRuneStart(cut[len(cut)-1]) {
|
||||||
|
cut = cut[:len(cut)-1]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cut) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRuneStart(b byte) bool { return b&0xC0 != 0x80 }
|
||||||
|
|
||||||
|
// newBase64Reader — base64.NewDecoder already skips the CRLFs mail bodies wrap
|
||||||
|
// with, so this is only a named seam for decodeBody to read cleanly.
|
||||||
|
func newBase64Reader(r io.Reader) io.Reader {
|
||||||
|
return base64.NewDecoder(base64.StdEncoding, r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixture(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePlainRussian(t *testing.T) {
|
||||||
|
msg, err := ParseMessage(7, fixture(t, "plain_ru.eml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if msg.UID != 7 {
|
||||||
|
t.Errorf("uid = %d, want 7", msg.UID)
|
||||||
|
}
|
||||||
|
if want := "Нужно закрыть задачу"; msg.Subject != want {
|
||||||
|
t.Errorf("subject = %q, want %q", msg.Subject, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.From, "Антон") {
|
||||||
|
t.Errorf("from = %q, want the decoded display name", msg.From)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Body, "Надо отправить акт до пятницы.") {
|
||||||
|
t.Errorf("body = %q, want the quoted-printable text decoded", msg.Body)
|
||||||
|
}
|
||||||
|
if msg.Junk {
|
||||||
|
t.Errorf("a personal mail must not be junk (%s)", msg.JunkReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHTMLOnlyIsStripped(t *testing.T) {
|
||||||
|
msg, err := ParseMessage(1, fixture(t, "html_only.eml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg.Body, "<") || strings.Contains(msg.Body, "color:red") || strings.Contains(msg.Body, "x()") {
|
||||||
|
t.Errorf("body still has markup/script/style: %q", msg.Body)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Счёт за интернет: 700", "Оплатить до 5 августа."} {
|
||||||
|
if !strings.Contains(msg.Body, want) {
|
||||||
|
t.Errorf("body = %q, want it to contain %q", msg.Body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// must have become a real space, not vanished into the number.
|
||||||
|
if strings.Contains(msg.Body, " ") {
|
||||||
|
t.Errorf("entity left unescaped: %q", msg.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrefersPlainAndSkipsAttachments(t *testing.T) {
|
||||||
|
msg, err := ParseMessage(2, fixture(t, "mixed_attachment.eml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(msg.Body); got != "Sign the contract before Monday." {
|
||||||
|
t.Errorf("body = %q, want the text/plain alternative only", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg.Body, "PDF") {
|
||||||
|
t.Errorf("attachment bytes leaked into the body: %q", msg.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unsupported charset must degrade to headers-only rather than to mojibake
|
||||||
|
// the model would then extract a task from.
|
||||||
|
func TestParseUnsupportedCharsetKeepsHeaders(t *testing.T) {
|
||||||
|
msg, err := ParseMessage(3, fixture(t, "cp1251.eml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if msg.Subject != "Legacy" {
|
||||||
|
t.Errorf("subject = %q, want Legacy", msg.Subject)
|
||||||
|
}
|
||||||
|
if msg.Body != "" {
|
||||||
|
t.Errorf("body = %q, want empty for an undecodable charset", msg.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTruncatesLongBody(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("Subject: long\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||||
|
for i := 0; i < 2000; i++ {
|
||||||
|
b.WriteString("длинная строка ")
|
||||||
|
}
|
||||||
|
msg, err := ParseMessage(4, []byte(b.String()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(msg.Body) > MaxBodyBytes+8 {
|
||||||
|
t.Errorf("body kept %d bytes, want ≤ %d", len(msg.Body), MaxBodyBytes)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(msg.Body, "…") {
|
||||||
|
t.Errorf("truncated body should be marked: %q", msg.Body[len(msg.Body)-20:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapseSqueezesBlankLines(t *testing.T) {
|
||||||
|
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
||||||
|
if got != "a b\n\nc" {
|
||||||
|
t.Errorf("collapse = %q, want %q", got, "a b\n\nc")
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
From: legacy@example.org
|
||||||
|
To: kami@example.org
|
||||||
|
Subject: Legacy
|
||||||
|
Date: Fri, 01 Aug 2026 05:00:00 +0400
|
||||||
|
Content-Type: text/plain; charset="windows-1251"
|
||||||
|
|
||||||
|
Ï
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
From: billing@isp.example
|
||||||
|
To: kami@example.org
|
||||||
|
Subject: =?utf-8?B?0KHRh9GR0YIg0LfQsCDQuNC90YLQtdGA0L3QtdGC?=
|
||||||
|
Date: Fri, 01 Aug 2026 08:00:00 +0400
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/alternative; boundary="B1"
|
||||||
|
|
||||||
|
--B1
|
||||||
|
Content-Type: text/html; charset="utf-8"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
PGh0bWw+PGhlYWQ+PHN0eWxlPnB7Y29sb3I6cmVkfTwvc3R5bGU+PC9oZWFkPjxib2R5PjxwPtCh0YfRkdGCINC30LAg0LjQvdGC0LXRgNC90LXRgjogNzAwJm5ic3A74oK9PC9wPjxwPtCe0L/Qu9Cw0YLQuNGC0Ywg0LTQviA1INCw0LLQs9GD0YHRgtCwLjwvcD48c2NyaXB0PngoKTwvc2NyaXB0PjwvYm9keT48L2h0bWw+
|
||||||
|
--B1--
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
From: hr@work.example
|
||||||
|
To: kami@example.org
|
||||||
|
Subject: Contract
|
||||||
|
Date: Fri, 01 Aug 2026 07:00:00 +0400
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/mixed; boundary="M1"
|
||||||
|
|
||||||
|
--M1
|
||||||
|
Content-Type: multipart/alternative; boundary="A1"
|
||||||
|
|
||||||
|
--A1
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
Sign the contract before Monday.
|
||||||
|
--A1
|
||||||
|
Content-Type: text/html; charset="utf-8"
|
||||||
|
|
||||||
|
<p>Sign the contract before Monday.</p>
|
||||||
|
--A1--
|
||||||
|
--M1
|
||||||
|
Content-Type: application/pdf; name="contract.pdf"
|
||||||
|
Content-Disposition: attachment; filename="contract.pdf"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
JVBERi0xLjQgbm90IHJlYWxseSBhIHBkZg==
|
||||||
|
--M1--
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
From: news@shop.example
|
||||||
|
To: kami@example.org
|
||||||
|
Subject: =?utf-8?B?0KHQutC40LTQutC4INGC0L7Qu9GM0LrQviDRgdC10LPQvtC00L3Rjw==?=
|
||||||
|
Date: Fri, 01 Aug 2026 06:00:00 +0400
|
||||||
|
List-Unsubscribe: <mailto:unsub@shop.example>
|
||||||
|
Precedence: bulk
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
Sale!
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
From: =?utf-8?B?0JDQvdGC0L7QvQ==?= <anton@example.org>
|
||||||
|
To: kami@example.org
|
||||||
|
Subject: =?utf-8?B?0J3Rg9C20L3QviDQt9Cw0LrRgNGL0YLRjCDQt9Cw0LTQsNGH0YM=?=
|
||||||
|
Date: Fri, 01 Aug 2026 09:12:00 +0400
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
Message-ID: <plain-ru@example.org>
|
||||||
|
|
||||||
|
=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82! =D0=9D=D0=B0=D0=B4=D0=BE =D0=BE=D1=82=
|
||||||
|
=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C =D0=B0=D0=BA=D1=82 =D0=B4=D0=BE =
|
||||||
|
=D0=BF=D1=8F=D1=82=D0=BD=D0=B8=D1=86=D1=8B.
|
||||||
|
|
||||||
|
--
|
||||||
|
Anton
|
||||||
@@ -93,6 +93,63 @@ type WriteFactReq struct {
|
|||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task — one captured piece of work (Vikunja #130). Status is
|
||||||
|
// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
|
||||||
|
// "done" or "dropped". Source is provenance in the facts vocabulary:
|
||||||
|
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
|
||||||
|
// task came from, empty for anything he stated himself.
|
||||||
|
type Task struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CreatedTs time.Time `json:"created_ts"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Evidence string `json:"evidence,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Due *time.Time `json:"due,omitempty"`
|
||||||
|
Weight int `json:"weight,omitempty"`
|
||||||
|
Resolved *time.Time `json:"resolved,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||||
|
// through this one shape: the voice path, the web form, and (Vikunja #246) the
|
||||||
|
// email reader, which has not been built yet.
|
||||||
|
//
|
||||||
|
// An extractor that reads mail sets Source "email:<account>", Status
|
||||||
|
// "candidate", and Evidence to whatever makes the task reviewable (the subject
|
||||||
|
// line). It must NOT set Status "open" — work Maven inferred from something she
|
||||||
|
// read is a suggestion until the owner confirms it on the /tasks page. Capture
|
||||||
|
// is idempotent on normalised text among live tasks, so re-reading the same
|
||||||
|
// mailbox is free.
|
||||||
|
type CaptureTaskReq struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Evidence string `json:"evidence,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"` // "" ⇒ open
|
||||||
|
Due *time.Time `json:"due,omitempty"`
|
||||||
|
Weight int `json:"weight,omitempty"`
|
||||||
|
Ts time.Time `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||||
|
// in which case ID is the existing row. A caller tells the owner "уже в
|
||||||
|
// списке" rather than claiming it saved something new.
|
||||||
|
type CaptureTaskResp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Created bool `json:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listTasksReq struct {
|
||||||
|
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||||
|
}
|
||||||
|
type listTasksResp struct {
|
||||||
|
Tasks []Task `json:"tasks"`
|
||||||
|
}
|
||||||
|
type setTaskStatusReq struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Ts time.Time `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
// idReq — methods keyed by a single id.
|
// idReq — methods keyed by a single id.
|
||||||
type idReq struct {
|
type idReq struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -294,6 +351,18 @@ type CoreAPI interface {
|
|||||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||||
|
// intake seam for the voice path, the web form and the future email
|
||||||
|
// extractor. Idempotent per live normalised text; the response says
|
||||||
|
// whether a row was actually created.
|
||||||
|
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||||
|
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||||
|
// "live" is candidate + open (outstanding work).
|
||||||
|
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||||
|
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||||
|
// open→done|dropped. Any other move is refused.
|
||||||
|
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
|
||||||
|
|
||||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||||
// this after every tick; the store adapter returns an error (trace is not
|
// this after every tick; the store adapter returns an error (trace is not
|
||||||
// persisted — it's a daemon-level cache).
|
// persisted — it's a daemon-level cache).
|
||||||
@@ -306,6 +375,14 @@ type CoreAPI interface {
|
|||||||
// TickTrace.
|
// TickTrace.
|
||||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||||
|
|
||||||
|
// DayPlan returns today's ordered plan — calendar events, pending
|
||||||
|
// reminders and any morning checklist still outstanding (see
|
||||||
|
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||||
|
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||||
|
// The store adapter returns an error (the plan needs the daemon's routine
|
||||||
|
// config) — same shape as TickTrace and MorningStatus.
|
||||||
|
DayPlan(ctx context.Context) (DayPlan, error)
|
||||||
|
|
||||||
// Chat routes a text utterance through the reactive handler's core path
|
// Chat routes a text utterance through the reactive handler's core path
|
||||||
// (router → dialogue → action → replier) and returns the reply text.
|
// (router → dialogue → action → replier) and returns the reply text.
|
||||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||||
@@ -359,6 +436,26 @@ type MorningRoutineStatus struct {
|
|||||||
Items []MorningRoutineItem `json:"items"`
|
Items []MorningRoutineItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DayPlanItem — one line of the day plan. Kind is "event", "reminder" or
|
||||||
|
// "checklist"; Uncertain marks an item whose provenance is below a full
|
||||||
|
// calendar read (a meeting relayed off a phone notification), so a UI can hedge
|
||||||
|
// the same way the spoken form does.
|
||||||
|
type DayPlanItem struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Uncertain bool `json:"uncertain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DayPlan — the plan for one calendar day. Spoken is the RU sentence maven
|
||||||
|
// says when asked, rendered core-side so the voice reply and the web view can
|
||||||
|
// never drift apart.
|
||||||
|
type DayPlan struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Items []DayPlanItem `json:"items"`
|
||||||
|
Spoken string `json:"spoken"`
|
||||||
|
}
|
||||||
|
|
||||||
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
||||||
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
||||||
type storeEncryptionKeyReq struct {
|
type storeEncryptionKeyReq struct {
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ var readOnlyMethods = map[Method]bool{
|
|||||||
MethodLookupTool: true,
|
MethodLookupTool: true,
|
||||||
MethodListTools: true,
|
MethodListTools: true,
|
||||||
MethodListProposedRoutines: true,
|
MethodListProposedRoutines: true,
|
||||||
|
MethodListTasks: true,
|
||||||
MethodTickTrace: true,
|
MethodTickTrace: true,
|
||||||
MethodMorningStatus: true,
|
MethodMorningStatus: true,
|
||||||
|
MethodDayPlan: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial connects to a core socket at path and returns a Client. The module
|
// Dial connects to a core socket at path and returns a Client. The module
|
||||||
@@ -426,6 +428,26 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
|||||||
return r.Routines, nil
|
return r.Routines, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
|
var r CaptureTaskResp
|
||||||
|
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||||
|
return CaptureTaskResp{}, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||||
|
var r listTasksResp
|
||||||
|
if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.Tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||||
|
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
@@ -458,6 +480,14 @@ func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, err
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
|
var p DayPlan
|
||||||
|
if err := c.call(ctx, MethodDayPlan, nil, &p); err != nil {
|
||||||
|
return DayPlan{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
NewID int64 `json:"new_id"`
|
NewID int64 `json:"new_id"`
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, e
|
|||||||
return nil, errors.New("store: morning status not available via direct store API")
|
return nil, errors.New("store: morning status not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
|
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||||
ts, err := a.s.ListTools(ctx, status)
|
ts, err := a.s.ListTools(ctx, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -227,6 +231,48 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
|||||||
return mapErr(a.s.DeleteTool(ctx, name))
|
return mapErr(a.s.DeleteTool(ctx, name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
|
id, created, err := a.s.CaptureTask(ctx, store.Task{
|
||||||
|
CreatedTs: req.Ts,
|
||||||
|
Text: req.Text,
|
||||||
|
Source: req.Source,
|
||||||
|
Evidence: req.Evidence,
|
||||||
|
Status: req.Status,
|
||||||
|
Due: req.Due,
|
||||||
|
Weight: req.Weight,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return CaptureTaskResp{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return CaptureTaskResp{ID: id, Created: created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||||
|
ts, err := a.s.ListTasks(ctx, status)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Task, len(ts))
|
||||||
|
for i, t := range ts {
|
||||||
|
out[i] = Task{
|
||||||
|
ID: t.ID,
|
||||||
|
CreatedTs: t.CreatedTs,
|
||||||
|
Text: t.Text,
|
||||||
|
Source: t.Source,
|
||||||
|
Evidence: t.Evidence,
|
||||||
|
Status: t.Status,
|
||||||
|
Due: t.Due,
|
||||||
|
Weight: t.Weight,
|
||||||
|
Resolved: t.ResolvedTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||||
|
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
rs, err := a.s.ListProposedRoutines(ctx)
|
rs, err := a.s.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -696,6 +742,22 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
||||||
return api.DeleteTool(ctx, p.Name)
|
return api.DeleteTool(ctx, p.Name)
|
||||||
}),
|
}),
|
||||||
|
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
|
return api.CaptureTask(ctx, p)
|
||||||
|
}),
|
||||||
|
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||||
|
out, err := api.ListTasks(ctx, p.Status)
|
||||||
|
if err != nil {
|
||||||
|
return listTasksResp{}, err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []Task{}
|
||||||
|
}
|
||||||
|
return listTasksResp{Tasks: out}, nil
|
||||||
|
}),
|
||||||
|
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||||
|
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts)
|
||||||
|
}),
|
||||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||||
out, err := api.ListProposedRoutines(ctx)
|
out, err := api.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -729,6 +791,9 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
// MorningStatus intentionally has no nil→[]T{} normalization here — the
|
// MorningStatus intentionally has no nil→[]T{} normalization here — the
|
||||||
// pre-table arm marshaled api.MorningStatus's result as-is (a nil slice
|
// pre-table arm marshaled api.MorningStatus's result as-is (a nil slice
|
||||||
// serializes as JSON null), and this preserves that exact wire shape.
|
// serializes as JSON null), and this preserves that exact wire shape.
|
||||||
|
MethodDayPlan: withoutParams(func(ctx context.Context, api CoreAPI) (DayPlan, error) {
|
||||||
|
return api.DayPlan(ctx)
|
||||||
|
}),
|
||||||
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
||||||
return api.MorningStatus(ctx)
|
return api.MorningStatus(ctx)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error
|
|||||||
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
||||||
return ErrNotImplemented
|
return ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
|
return CaptureTaskResp{}, ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||||
|
return nil, ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||||
|
return ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
@@ -113,6 +122,9 @@ func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
|||||||
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
|
return DayPlan{}, ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||||
return "", ErrNotImplemented
|
return "", ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ const (
|
|||||||
MethodRevertFact Method = "revert_fact"
|
MethodRevertFact Method = "revert_fact"
|
||||||
MethodTickTrace Method = "tick_trace"
|
MethodTickTrace Method = "tick_trace"
|
||||||
MethodMorningStatus Method = "morning_status"
|
MethodMorningStatus Method = "morning_status"
|
||||||
|
MethodDayPlan Method = "day_plan"
|
||||||
MethodChat Method = "chat"
|
MethodChat Method = "chat"
|
||||||
|
MethodCaptureTask Method = "capture_task"
|
||||||
|
MethodListTasks Method = "list_tasks"
|
||||||
|
MethodSetTaskStatus Method = "set_task_status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
||||||
|
//
|
||||||
|
// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md
|
||||||
|
// asks for an LLM to write a behaviour profile daily and store it as a fact;
|
||||||
|
// this does not do that, on purpose. A 1.7B asked to summarise a year of habits
|
||||||
|
// will produce fluent claims about the owner's life that no row in the store
|
||||||
|
// supports, and a wrong claim about him is the most expensive kind of wrong
|
||||||
|
// maven can be. Counting distinct days per weekday is verifiable, cheap enough
|
||||||
|
// to run on the question, and cannot invent a habit he does not have.
|
||||||
|
//
|
||||||
|
// Recomputed on read rather than cached as a fact for the same reason the store
|
||||||
|
// is append-only: a cached profile can disagree with the rows it came from, and
|
||||||
|
// then there are two truths. The plan's step 5 ("profile updates on fact write")
|
||||||
|
// exists to keep a cache fresh; there is no cache, so a new fact is already in
|
||||||
|
// the next answer.
|
||||||
|
//
|
||||||
|
// It is also read-only and unprompted-free. The plan's step 4 — a morning
|
||||||
|
// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a
|
||||||
|
// nag, and proposing plans at 08:00 every day is the definition of one. Pattern
|
||||||
|
// inference that leads to a routine the owner accepts already exists in
|
||||||
|
// internal/pattern with the proposal queue on /routines; that is the sanctioned
|
||||||
|
// path from "she noticed" to "she acts", and it goes through him.
|
||||||
|
|
||||||
|
// Observation — one thing the owner was recorded doing, reduced to what a habit
|
||||||
|
// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them
|
||||||
|
// so this package stays free of both.
|
||||||
|
type Observation struct {
|
||||||
|
At time.Time
|
||||||
|
Key string
|
||||||
|
Kind string // "self" | "env" | "config"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity — one recurring thing, as counted. Days is the number of DISTINCT
|
||||||
|
// days it was observed on, which is the number that decides whether something
|
||||||
|
// is a habit; Count can be inflated by one busy day.
|
||||||
|
//
|
||||||
|
// TypicalAt is the median time of day it happens at, rounded to the minute — a
|
||||||
|
// median and not a mean, so one 03:00 outlier does not move "он обычно пьёт
|
||||||
|
// воду утром" into the night.
|
||||||
|
type Activity struct {
|
||||||
|
Key string
|
||||||
|
Days int
|
||||||
|
Count int
|
||||||
|
TypicalAt time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile — the counted behaviour model. Weekly holds the activities that
|
||||||
|
// recur on a given weekday, Overall the ones that recur at all.
|
||||||
|
type Profile struct {
|
||||||
|
Since time.Time
|
||||||
|
Until time.Time
|
||||||
|
Weekly map[time.Weekday][]Activity
|
||||||
|
All []Activity
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinHabitDays — how many distinct days an activity must appear on before maven
|
||||||
|
// will call it usual. Two is the smallest number that can distinguish a habit
|
||||||
|
// from a one-off; below that she says she does not know yet, which is true.
|
||||||
|
const MinHabitDays = 2
|
||||||
|
|
||||||
|
// nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not
|
||||||
|
// behaviour. Calendar events carry the day in the key so they can never repeat;
|
||||||
|
// cooldown and quiet rows are maven's own tuning state, not his habits.
|
||||||
|
var nonBehaviouralKeyPrefixes = []string{
|
||||||
|
"calendar_event_",
|
||||||
|
"cooldown:",
|
||||||
|
"quiet",
|
||||||
|
"behavior_profile",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildProfile counts habits out of observations. now bounds the window's upper
|
||||||
|
// end and supplies the location every day boundary is taken in — a habit is
|
||||||
|
// "on Tuesdays" in the owner's timezone or it is nothing.
|
||||||
|
//
|
||||||
|
// Only self-facts count. An env row is the world (weather, a relayed meeting),
|
||||||
|
// and a config row is maven's own state; neither says anything about what he
|
||||||
|
// usually does.
|
||||||
|
func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||||
|
loc := now.Location()
|
||||||
|
p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}}
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
days map[string]struct{}
|
||||||
|
count int
|
||||||
|
mins []int
|
||||||
|
}
|
||||||
|
// key → bucket, and (weekday, key) → bucket.
|
||||||
|
all := map[string]*bucket{}
|
||||||
|
weekly := map[time.Weekday]map[string]*bucket{}
|
||||||
|
|
||||||
|
for _, o := range obs {
|
||||||
|
if o.Kind != "self" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(o.Key)
|
||||||
|
if key == "" || nonBehavioural(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := o.At.In(loc)
|
||||||
|
if at.IsZero() || at.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Since.IsZero() || at.Before(p.Since) {
|
||||||
|
p.Since = at
|
||||||
|
}
|
||||||
|
day := at.Format("2006-01-02")
|
||||||
|
minute := at.Hour()*60 + at.Minute()
|
||||||
|
|
||||||
|
bump := func(m map[string]*bucket) {
|
||||||
|
b := m[key]
|
||||||
|
if b == nil {
|
||||||
|
b = &bucket{days: map[string]struct{}{}}
|
||||||
|
m[key] = b
|
||||||
|
}
|
||||||
|
b.days[day] = struct{}{}
|
||||||
|
b.count++
|
||||||
|
b.mins = append(b.mins, minute)
|
||||||
|
}
|
||||||
|
bump(all)
|
||||||
|
wd := at.Weekday()
|
||||||
|
if weekly[wd] == nil {
|
||||||
|
weekly[wd] = map[string]*bucket{}
|
||||||
|
}
|
||||||
|
bump(weekly[wd])
|
||||||
|
}
|
||||||
|
|
||||||
|
harvest := func(m map[string]*bucket) []Activity {
|
||||||
|
var out []Activity
|
||||||
|
for key, b := range m {
|
||||||
|
if len(b.days) < MinHabitDays {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Activity{
|
||||||
|
Key: key,
|
||||||
|
Days: len(b.days),
|
||||||
|
Count: b.count,
|
||||||
|
TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Most-established first, then earliest in the day, then by key so the
|
||||||
|
// same history always reads back the same way.
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Days != out[j].Days {
|
||||||
|
return out[i].Days > out[j].Days
|
||||||
|
}
|
||||||
|
if out[i].TypicalAt != out[j].TypicalAt {
|
||||||
|
return out[i].TypicalAt < out[j].TypicalAt
|
||||||
|
}
|
||||||
|
return out[i].Key < out[j].Key
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
p.All = harvest(all)
|
||||||
|
for wd, m := range weekly {
|
||||||
|
if acts := harvest(m); len(acts) > 0 {
|
||||||
|
p.Weekly[wd] = acts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonBehavioural(key string) bool {
|
||||||
|
for _, p := range nonBehaviouralKeyPrefixes {
|
||||||
|
if strings.HasPrefix(key, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// medianInt — the middle value, averaging the two middles on an even count.
|
||||||
|
func medianInt(xs []int) int {
|
||||||
|
if len(xs) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
s := make([]int, len(xs))
|
||||||
|
copy(s, xs)
|
||||||
|
sort.Ints(s)
|
||||||
|
mid := len(s) / 2
|
||||||
|
if len(s)%2 == 1 {
|
||||||
|
return s[mid]
|
||||||
|
}
|
||||||
|
return (s[mid-1] + s[mid]) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekdayRU — accusative, as "по вторникам" and "в среду" both need it read
|
||||||
|
// back. Index is time.Weekday.
|
||||||
|
var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"}
|
||||||
|
|
||||||
|
// activityRU glosses the loop's known fact keys. An unknown key is read back
|
||||||
|
// verbatim: it is what the store holds, and inventing a Russian phrase for a key
|
||||||
|
// maven does not recognise would be putting words in his mouth.
|
||||||
|
var activityRU = map[string]string{
|
||||||
|
"water": "пьёшь воду",
|
||||||
|
"meal": "ешь",
|
||||||
|
"sleep": "спишь",
|
||||||
|
"break": "делаешь перерыв",
|
||||||
|
"shower": "принимаешь душ",
|
||||||
|
"walk": "гуляешь",
|
||||||
|
"pills": "пьёшь витамины",
|
||||||
|
"workout": "тренируешься",
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatWeekdayRU reads back what he usually does on a given weekday.
|
||||||
|
// Second person singular and informal, as she speaks TO him.
|
||||||
|
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||||
|
acts := p.Weekly[wd]
|
||||||
|
day := weekdayRU[int(wd)%7]
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatOverallRU reads back the habits that hold across the whole week.
|
||||||
|
func (p Profile) FormatOverallRU() string {
|
||||||
|
if len(p.All) == 0 {
|
||||||
|
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("обычно ты %s.", joinActivities(p.All))
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
||||||
|
// not an answer; the most established few are.
|
||||||
|
const maxRecited = 5
|
||||||
|
|
||||||
|
func joinActivities(acts []Activity) string {
|
||||||
|
if len(acts) > maxRecited {
|
||||||
|
acts = acts[:maxRecited]
|
||||||
|
}
|
||||||
|
parts := make([]string, len(acts))
|
||||||
|
for i, a := range acts {
|
||||||
|
gloss, ok := activityRU[a.Key]
|
||||||
|
if !ok {
|
||||||
|
gloss = a.Key
|
||||||
|
}
|
||||||
|
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||||
|
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// habitHistory — n weeks of the same weekday, at the given local time.
|
||||||
|
func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation {
|
||||||
|
var out []Observation
|
||||||
|
d := from
|
||||||
|
for d.Weekday() != wd {
|
||||||
|
d = d.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
for i := 0; i < weeks; i++ {
|
||||||
|
day := d.AddDate(0, 0, -7*i)
|
||||||
|
out = append(out, Observation{
|
||||||
|
At: time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()),
|
||||||
|
Key: key,
|
||||||
|
Kind: "self",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func behaviorNow() time.Time {
|
||||||
|
// A Monday, so "по вторникам" is a past weekday and not today.
|
||||||
|
return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProfileCountsWeekdayHabits(t *testing.T) {
|
||||||
|
now := behaviorNow()
|
||||||
|
obs := append(
|
||||||
|
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||||||
|
habitHistory("water", time.Tuesday, 9, 0, 3, now)...,
|
||||||
|
)
|
||||||
|
p := BuildProfile(obs, now)
|
||||||
|
|
||||||
|
tue := p.Weekly[time.Tuesday]
|
||||||
|
if len(tue) != 2 {
|
||||||
|
t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue)
|
||||||
|
}
|
||||||
|
// Most-established first.
|
||||||
|
if tue[0].Key != "workout" || tue[0].Days != 4 {
|
||||||
|
t.Errorf("first = %+v, want workout on 4 days", tue[0])
|
||||||
|
}
|
||||||
|
if tue[0].TypicalAt != 19*time.Hour {
|
||||||
|
t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt)
|
||||||
|
}
|
||||||
|
if len(p.Weekly[time.Wednesday]) != 0 {
|
||||||
|
t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday])
|
||||||
|
}
|
||||||
|
if len(p.All) != 2 {
|
||||||
|
t.Errorf("the week-wide list should hold both: %+v", p.All)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A one-off is not a habit. Saying "ты обычно X" off a single row is a
|
||||||
|
// confidently wrong claim about his life.
|
||||||
|
func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
|
||||||
|
now := behaviorNow()
|
||||||
|
obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now)
|
||||||
|
// Three rows, same day — a busy Tuesday, not a habit.
|
||||||
|
obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"})
|
||||||
|
obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"})
|
||||||
|
|
||||||
|
p := BuildProfile(obs, now)
|
||||||
|
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
||||||
|
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
||||||
|
}
|
||||||
|
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
|
||||||
|
t.Errorf("empty profile reads %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only self-facts describe him. Env rows are the world and config rows are
|
||||||
|
// maven's own tuning state; counting either as a habit would be a category
|
||||||
|
// error the owner would then be told about.
|
||||||
|
func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) {
|
||||||
|
now := behaviorNow()
|
||||||
|
var obs []Observation
|
||||||
|
for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) {
|
||||||
|
o.Kind = "env"
|
||||||
|
obs = append(obs, o)
|
||||||
|
}
|
||||||
|
for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) {
|
||||||
|
obs = append(obs, o) // kind=self, but a machinery key
|
||||||
|
}
|
||||||
|
for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) {
|
||||||
|
obs = append(obs, o)
|
||||||
|
}
|
||||||
|
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||||||
|
t.Fatalf("nothing here is a habit of his: %+v", p.All)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The median, not the mean: one 03:00 outlier must not move a morning habit
|
||||||
|
// into the night.
|
||||||
|
func TestBuildProfileTypicalTimeIsMedian(t *testing.T) {
|
||||||
|
now := behaviorNow()
|
||||||
|
obs := habitHistory("water", time.Tuesday, 9, 0, 4, now)
|
||||||
|
obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"})
|
||||||
|
p := BuildProfile(obs, now)
|
||||||
|
if len(p.All) != 1 {
|
||||||
|
t.Fatalf("got %+v", p.All)
|
||||||
|
}
|
||||||
|
if p.All[0].TypicalAt != 9*time.Hour {
|
||||||
|
t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileFormatRUPersona(t *testing.T) {
|
||||||
|
now := behaviorNow()
|
||||||
|
obs := append(
|
||||||
|
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||||||
|
habitHistory("water", time.Tuesday, 9, 5, 3, now)...,
|
||||||
|
)
|
||||||
|
p := BuildProfile(obs, now)
|
||||||
|
|
||||||
|
got := p.FormatWeekdayRU(time.Tuesday)
|
||||||
|
want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05."
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q\nwant %q", got, want)
|
||||||
|
}
|
||||||
|
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
|
||||||
|
t.Errorf("an unknown weekday reads %q", empty)
|
||||||
|
}
|
||||||
|
// Persona: she addresses him informally, never in the masculine about
|
||||||
|
// herself, and never with a pet name.
|
||||||
|
for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} {
|
||||||
|
// Whole words: "ничего" contains "его", and a substring test would
|
||||||
|
// call a correct sentence a persona violation.
|
||||||
|
for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r)
|
||||||
|
}) {
|
||||||
|
switch tok {
|
||||||
|
case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его":
|
||||||
|
t.Errorf("%q uses %q", s, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unrecognised key is read back verbatim rather than glossed into something
|
||||||
|
// maven made up.
|
||||||
|
func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) {
|
||||||
|
now := behaviorNow()
|
||||||
|
p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now)
|
||||||
|
if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A future-dated row is a clock problem, not a habit.
|
||||||
|
func TestBuildProfileIgnoresFutureRows(t *testing.T) {
|
||||||
|
now := behaviorNow()
|
||||||
|
obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0))
|
||||||
|
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||||||
|
t.Fatalf("future rows counted: %+v", p.All)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package morning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The day plan (Vikunja #128).
|
||||||
|
//
|
||||||
|
// It lives here, with the morning routine engine, because it is the same
|
||||||
|
// question asked at a different scale: the routine knows what is still missing
|
||||||
|
// from a window, the plan knows what the whole day holds. A parallel system
|
||||||
|
// would have to re-read the same facts and re-decide what "today" means.
|
||||||
|
//
|
||||||
|
// It is pure, like the rest of this package: the daemon reads the calendar,
|
||||||
|
// the reminders and the checklist facts, and BuildPlan puts them in order.
|
||||||
|
//
|
||||||
|
// It is also NOT a nag. A plan she can recite when asked is the whole feature;
|
||||||
|
// nothing here fires, schedules or announces. Unprompted delivery stays with
|
||||||
|
// the existing morning nudge and the dispatcher's policy.
|
||||||
|
|
||||||
|
// PlanKind — where a plan line came from. It survives into the reply and the
|
||||||
|
// web view because the three read differently: an event is something happening
|
||||||
|
// to the owner, a reminder is something he asked for, a checklist item is
|
||||||
|
// something he has not done yet.
|
||||||
|
type PlanKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PlanEvent PlanKind = "event"
|
||||||
|
PlanReminder PlanKind = "reminder"
|
||||||
|
PlanChecklist PlanKind = "checklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlanEntry — one timed thing on the day, as the daemon read it out of the
|
||||||
|
// store. Text is rendered verbatim; the plan does not rephrase.
|
||||||
|
//
|
||||||
|
// Uncertain marks provenance below a full-confidence read — a work meeting
|
||||||
|
// relayed off a phone notification (#126). It travels through to the reply so
|
||||||
|
// she hedges instead of reciting a guess as fact.
|
||||||
|
type PlanEntry struct {
|
||||||
|
At time.Time
|
||||||
|
Text string
|
||||||
|
Kind PlanKind
|
||||||
|
Uncertain bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan — the ordered day. Date is the calendar day it describes.
|
||||||
|
type Plan struct {
|
||||||
|
Date time.Time
|
||||||
|
Items []PlanEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPlan orders everything known about the day Now falls on: calendar
|
||||||
|
// events, pending reminders, and one line per morning routine that still has
|
||||||
|
// unfinished items.
|
||||||
|
//
|
||||||
|
// Entries outside that calendar day are dropped — a plan for today that
|
||||||
|
// includes tomorrow's meeting is wrong in a way that is worse than terse.
|
||||||
|
// Ordering is by time, then by kind, then by text, so the same day always reads
|
||||||
|
// the same way.
|
||||||
|
func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminders []PlanEntry, now time.Time) Plan {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
p := Plan{Date: dayStart}
|
||||||
|
for _, group := range [][]PlanEntry{events, reminders} {
|
||||||
|
for _, e := range group {
|
||||||
|
at := e.At.In(now.Location())
|
||||||
|
if at.Before(dayStart) || !at.Before(dayEnd) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.At = at
|
||||||
|
p.Items = append(p.Items, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Items = append(p.Items, checklistEntries(routines, facts, now)...)
|
||||||
|
|
||||||
|
sort.SliceStable(p.Items, func(i, j int) bool {
|
||||||
|
a, b := p.Items[i], p.Items[j]
|
||||||
|
if !a.At.Equal(b.At) {
|
||||||
|
return a.At.Before(b.At)
|
||||||
|
}
|
||||||
|
if a.Kind != b.Kind {
|
||||||
|
return a.Kind < b.Kind
|
||||||
|
}
|
||||||
|
return a.Text < b.Text
|
||||||
|
})
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// checklistEntries renders one line per routine with work left in it, placed at
|
||||||
|
// the routine's nudge time — where the checklist actually matters in the day.
|
||||||
|
// A routine that does not apply today, is not in its window, or is already
|
||||||
|
// complete contributes nothing: the plan says what is left, not what was done.
|
||||||
|
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
||||||
|
var out []PlanEntry
|
||||||
|
for _, r := range routines {
|
||||||
|
st := Evaluate(r, facts, now)
|
||||||
|
if !st.Active || len(st.Missing) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labels := make([]string, 0, len(st.Missing))
|
||||||
|
for _, it := range st.Missing {
|
||||||
|
label := it.Label
|
||||||
|
if label == "" {
|
||||||
|
label = it.Key
|
||||||
|
}
|
||||||
|
labels = append(labels, label)
|
||||||
|
}
|
||||||
|
at := r.NudgeAt
|
||||||
|
if at == "" {
|
||||||
|
at = r.WindowEnd
|
||||||
|
}
|
||||||
|
when, ok := todayAt(at, now)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PlanEntry{
|
||||||
|
At: when,
|
||||||
|
Text: fmt.Sprintf("%s — осталось: %s", r.Name, strings.Join(labels, ", ")),
|
||||||
|
Kind: PlanChecklist,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// After returns the part of the plan that has not happened yet — the answer to
|
||||||
|
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
|
||||||
|
// empty result still knows which day it is empty for.
|
||||||
|
func (p Plan) After(now time.Time) Plan {
|
||||||
|
out := Plan{Date: p.Date}
|
||||||
|
for _, it := range p.Items {
|
||||||
|
if it.At.Before(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, it)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRU renders the plan as maven says it. Feminine self-reference,
|
||||||
|
// informal address, no pet names — and no exhortation: she reads the day back,
|
||||||
|
// she does not tell him to get on with it.
|
||||||
|
func (p Plan) FormatRU() string {
|
||||||
|
if len(p.Items) == 0 {
|
||||||
|
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
|
||||||
|
}
|
||||||
|
parts := make([]string, len(p.Items))
|
||||||
|
for i, it := range p.Items {
|
||||||
|
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||||
|
if it.Uncertain {
|
||||||
|
line = "похоже, " + line
|
||||||
|
}
|
||||||
|
parts[i] = line
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package morning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// planAt is at() for the plan tests' day (2026-08-03, a Monday); the existing
|
||||||
|
// at() in morning_test.go is pinned to a different date.
|
||||||
|
func planAt(now time.Time, hh, mm int) time.Time {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
return time.Date(y, m, d, hh, mm, 0, 0, now.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
func planFixture(t *testing.T) (Plan, time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
routines := []Routine{{
|
||||||
|
Name: "утро",
|
||||||
|
WindowStart: "07:00",
|
||||||
|
WindowEnd: "11:00",
|
||||||
|
NudgeAt: "10:30",
|
||||||
|
Items: []Item{
|
||||||
|
{Key: "water", FactKey: "drank_water", Label: "выпить воды"},
|
||||||
|
{Key: "pills", FactKey: "took_pills", Label: "витамины"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
facts := map[string]store.Fact{
|
||||||
|
"drank_water": {Ts: planAt(now, 8, 0)},
|
||||||
|
}
|
||||||
|
events := []PlanEntry{
|
||||||
|
{At: planAt(now, 14, 0), Text: "Планёрка @ 14:00-14:30", Kind: PlanEvent, Uncertain: true},
|
||||||
|
{At: planAt(now, 10, 0), Text: "Standup @ 10:00-10:30", Kind: PlanEvent},
|
||||||
|
}
|
||||||
|
reminders := []PlanEntry{
|
||||||
|
{At: planAt(now, 18, 30), Text: "позвонить маме", Kind: PlanReminder},
|
||||||
|
}
|
||||||
|
return BuildPlan(routines, facts, events, reminders, now), now
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlanOrdersTheDay(t *testing.T) {
|
||||||
|
p, now := planFixture(t)
|
||||||
|
|
||||||
|
if !p.Date.Equal(planAt(now, 0, 0)) {
|
||||||
|
t.Errorf("Date = %v, want midnight of now's day", p.Date)
|
||||||
|
}
|
||||||
|
want := []struct {
|
||||||
|
hhmm string
|
||||||
|
kind PlanKind
|
||||||
|
}{
|
||||||
|
{"10:00", PlanEvent},
|
||||||
|
{"10:30", PlanChecklist},
|
||||||
|
{"14:00", PlanEvent},
|
||||||
|
{"18:30", PlanReminder},
|
||||||
|
}
|
||||||
|
if len(p.Items) != len(want) {
|
||||||
|
t.Fatalf("got %d items, want %d: %+v", len(p.Items), len(want), p.Items)
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if got := p.Items[i].At.Format("15:04"); got != w.hhmm {
|
||||||
|
t.Errorf("item %d at %s, want %s", i, got, w.hhmm)
|
||||||
|
}
|
||||||
|
if p.Items[i].Kind != w.kind {
|
||||||
|
t.Errorf("item %d kind %q, want %q", i, p.Items[i].Kind, w.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The checklist line says what is LEFT. An item already evidenced today must
|
||||||
|
// not be read back as outstanding.
|
||||||
|
func TestBuildPlanChecklistListsOnlyMissing(t *testing.T) {
|
||||||
|
p, _ := planFixture(t)
|
||||||
|
var line string
|
||||||
|
for _, it := range p.Items {
|
||||||
|
if it.Kind == PlanChecklist {
|
||||||
|
line = it.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
t.Fatal("no checklist line in the plan")
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "витамины") {
|
||||||
|
t.Errorf("missing item not listed: %q", line)
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "выпить воды") {
|
||||||
|
t.Errorf("a completed item must not be read back as outstanding: %q", line)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "утро — осталось:") {
|
||||||
|
t.Errorf("line = %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlanSkipsCompleteAndInactiveRoutines(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
routines := []Routine{
|
||||||
|
{
|
||||||
|
Name: "утро", WindowStart: "07:00", WindowEnd: "11:00",
|
||||||
|
Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Not in its window at 09:00.
|
||||||
|
Name: "вечер", WindowStart: "20:00", WindowEnd: "23:00",
|
||||||
|
Items: []Item{{Key: "walk", FactKey: "walked", Label: "прогулка"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}}
|
||||||
|
p := BuildPlan(routines, facts, nil, nil, now)
|
||||||
|
if len(p.Items) != 0 {
|
||||||
|
t.Fatalf("a complete routine and an out-of-window one must contribute nothing: %+v", p.Items)
|
||||||
|
}
|
||||||
|
if got, want := p.FormatRU(), "на 03.08.2026 ничего не запланировано."; got != want {
|
||||||
|
t.Errorf("got %q\nwant %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plan for today that includes tomorrow's meeting is worse than terse.
|
||||||
|
func TestBuildPlanDropsOtherDays(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
events := []PlanEntry{
|
||||||
|
{At: planAt(now, 10, 0), Text: "today", Kind: PlanEvent},
|
||||||
|
{At: planAt(now, 10, 0).AddDate(0, 0, 1), Text: "tomorrow", Kind: PlanEvent},
|
||||||
|
{At: planAt(now, 10, 0).AddDate(0, 0, -1), Text: "yesterday", Kind: PlanEvent},
|
||||||
|
{At: planAt(now, 12, 0), Text: " ", Kind: PlanEvent},
|
||||||
|
}
|
||||||
|
p := BuildPlan(nil, nil, events, nil, now)
|
||||||
|
if len(p.Items) != 1 || p.Items[0].Text != "today" {
|
||||||
|
t.Fatalf("got %+v", p.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanFormatRU(t *testing.T) {
|
||||||
|
p, _ := planFixture(t)
|
||||||
|
got := p.FormatRU()
|
||||||
|
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||||
|
"10:30 — утро — осталось: витамины; " +
|
||||||
|
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
||||||
|
"18:30 — позвонить маме."
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q\nwant %q", got, want)
|
||||||
|
}
|
||||||
|
// Persona: she recites, she does not exhort, and she never speaks of
|
||||||
|
// herself in the masculine or addresses him formally.
|
||||||
|
for _, bad := range []string{"рад ", "понял", "вы ", "ваш", "милый", "дорогой", "давай же", "не забудь"} {
|
||||||
|
if strings.Contains(strings.ToLower(got), bad) {
|
||||||
|
t.Errorf("plan text contains %q: %q", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAfter(t *testing.T) {
|
||||||
|
p, now := planFixture(t)
|
||||||
|
rest := p.After(planAt(now, 11, 0))
|
||||||
|
if len(rest.Items) != 2 {
|
||||||
|
t.Fatalf("got %d items, want the 14:00 and 18:30 ones: %+v", len(rest.Items), rest.Items)
|
||||||
|
}
|
||||||
|
if !rest.Date.Equal(p.Date) {
|
||||||
|
t.Error("After must keep the date, so an empty rest-of-day still knows which day")
|
||||||
|
}
|
||||||
|
empty := p.After(planAt(now, 23, 0))
|
||||||
|
if len(empty.Items) != 0 {
|
||||||
|
t.Errorf("got %+v", empty.Items)
|
||||||
|
}
|
||||||
|
if !strings.Contains(empty.FormatRU(), "ничего не запланировано") {
|
||||||
|
t.Errorf("empty plan reads %q", empty.FormatRU())
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-4
@@ -4,16 +4,112 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CalendarEventFormatter formats calendar events into a Russian reply string.
|
// CalendarEventFormatter formats calendar events into a Russian reply string.
|
||||||
type CalendarEventFormatter struct{}
|
type CalendarEventFormatter struct{}
|
||||||
|
|
||||||
// Format returns a Russian reply for the given calendar events on the given date.
|
// CalendarEntry — one event to recite. Uncertain marks an event maven did not
|
||||||
func (CalendarEventFormatter) Format(events []string, date time.Time) string {
|
// read off a calendar server: the work calendar arrives as relayed phone
|
||||||
|
// notifications (Vikunja #126), stored below full confidence, and she says so
|
||||||
|
// rather than reciting a guess as fact.
|
||||||
|
type CalendarEntry struct {
|
||||||
|
Text string
|
||||||
|
Uncertain bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPlanWords — the tokens that ask for the day as a whole rather than for a
|
||||||
|
// calendar listing. Whole words, not substrings: "планёрка" is a MEETING, and a
|
||||||
|
// notification about one must not be mistaken for a request for the plan.
|
||||||
|
var dayPlanWords = []string{
|
||||||
|
"план", "плана", "плану", "плане", "планом",
|
||||||
|
"планы", "планов", "планам", "планах",
|
||||||
|
"расписание", "расписании", "распорядок", "распорядке",
|
||||||
|
"plan", "plans", "schedule", "agenda",
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherDayWords — a day that is not today. The plan is built for the clock's
|
||||||
|
// own day only, so an utterance naming another one belongs to the calendar
|
||||||
|
// listing instead. Claiming it here would answer the wrong day, which is worse
|
||||||
|
// than answering more tersely.
|
||||||
|
var otherDayWords = []string{
|
||||||
|
"завтра", "послезавтра", "вчера", "позавчера",
|
||||||
|
"tomorrow", "yesterday",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDayPlanQuery reports whether an utterance asks for today's plan (Vikunja
|
||||||
|
// #128) — "какие планы на сегодня?", "что у меня по плану?", "что дальше?".
|
||||||
|
//
|
||||||
|
// Deliberately narrow. The calendar listing already answers "что у меня
|
||||||
|
// сегодня?" and a plan that hijacks every date-bearing question would bury the
|
||||||
|
// events under checklist lines. Only a plan-shaped ask, and only about today.
|
||||||
|
func IsDayPlanQuery(text string) bool {
|
||||||
|
toks := planTokens(text)
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, w := range otherDayWords {
|
||||||
|
if t == w {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, w := range dayPlanWords {
|
||||||
|
if t == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "что дальше?" / "what's next?" — the rest of the day, with no plan word
|
||||||
|
// in it. Both tokens rather than adjacency, because "what's" splits into
|
||||||
|
// "what" and "s" and because "и что потом дальше" is the same question.
|
||||||
|
return (hasTok(toks, "что") && hasTok(toks, "дальше")) ||
|
||||||
|
(hasTok(toks, "what") && hasTok(toks, "next"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTok(toks []string, w string) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
if t == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// planTokens lowercases and splits on everything that is not a letter or a
|
||||||
|
// digit, so "планы?" and "что-дальше" tokenize like the plain words do.
|
||||||
|
func planTokens(text string) []string {
|
||||||
|
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format returns a Russian reply for the given calendar events on the given
|
||||||
|
// date. Every event is treated as certain — use FormatEntries when provenance
|
||||||
|
// differs between them.
|
||||||
|
func (f CalendarEventFormatter) Format(events []string, date time.Time) string {
|
||||||
|
entries := make([]CalendarEntry, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
entries[i] = CalendarEntry{Text: e}
|
||||||
|
}
|
||||||
|
return f.FormatEntries(entries, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatEntries returns a Russian reply, hedging the entries maven is not sure
|
||||||
|
// about. "похоже" and not "возможно": the notification did arrive, what is
|
||||||
|
// uncertain is whether it describes the meeting correctly.
|
||||||
|
func (CalendarEventFormatter) FormatEntries(entries []CalendarEntry, date time.Time) string {
|
||||||
dateStr := date.Format("02.01.2006")
|
dateStr := date.Format("02.01.2006")
|
||||||
if len(events) == 0 {
|
if len(entries) == 0 {
|
||||||
return fmt.Sprintf("на %s ничего нет.", dateStr)
|
return fmt.Sprintf("на %s ничего нет.", dateStr)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(events, "; "))
|
parts := make([]string, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Uncertain {
|
||||||
|
parts[i] = "похоже, " + e.Text
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts[i] = e.Text
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(parts, "; "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,60 @@ func TestCalendarEventFormatter(t *testing.T) {
|
|||||||
t.Errorf("multiple: got %q", got)
|
t.Errorf("multiple: got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalendarEventFormatterHedgesUncertainEntries(t *testing.T) {
|
||||||
|
f := CalendarEventFormatter{}
|
||||||
|
date := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// An event relayed off a phone notification is not a calendar read, and she
|
||||||
|
// says so instead of reciting a guess as fact.
|
||||||
|
got := f.FormatEntries([]CalendarEntry{
|
||||||
|
{Text: "Standup @ 10:00-10:30"},
|
||||||
|
{Text: "Планёрка @ 14:00-14:30", Uncertain: true},
|
||||||
|
}, date)
|
||||||
|
want := "на 06.07.2026: Standup @ 10:00-10:30; похоже, Планёрка @ 14:00-14:30"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q\nwant %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format is FormatEntries with everything certain.
|
||||||
|
if got := f.FormatEntries(nil, date); got != "на 06.07.2026 ничего нет." {
|
||||||
|
t.Errorf("empty: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDayPlanQuery(t *testing.T) {
|
||||||
|
yes := []string{
|
||||||
|
"какие планы на сегодня?",
|
||||||
|
"что у меня по плану",
|
||||||
|
"расскажи план",
|
||||||
|
"мой распорядок на сегодня",
|
||||||
|
"расписание?",
|
||||||
|
"что дальше?",
|
||||||
|
"what's next",
|
||||||
|
"what is my plan today",
|
||||||
|
}
|
||||||
|
for _, s := range yes {
|
||||||
|
if !IsDayPlanQuery(s) {
|
||||||
|
t.Errorf("IsDayPlanQuery(%q) = false, want true", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
no := []string{
|
||||||
|
// The calendar listing owns these.
|
||||||
|
"что у меня сегодня?",
|
||||||
|
"какие планы на завтра?",
|
||||||
|
"план на послезавтра",
|
||||||
|
"что было вчера",
|
||||||
|
// "планёрка" is a meeting, not a request for the plan.
|
||||||
|
"когда планёрка?",
|
||||||
|
"запиши планёрку на 14:00",
|
||||||
|
"какая погода?",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, s := range no {
|
||||||
|
if IsDayPlanQuery(s) {
|
||||||
|
t.Errorf("IsDayPlanQuery(%q) = true, want false", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Habit queries — "что я обычно делаю по вторникам?" (Vikunja #254).
|
||||||
|
//
|
||||||
|
// Deterministic matching, like the calendar and plan matchers: the LLM router
|
||||||
|
// classifies the intent, but WHICH weekday was asked about is a lookup, not a
|
||||||
|
// generation. A model that answers "по вторникам" for a question about Thursday
|
||||||
|
// gives a confidently wrong account of the owner's own life.
|
||||||
|
|
||||||
|
// HabitQuery — a parsed "what do I usually do" question. Weekday is set only
|
||||||
|
// when the utterance names one; otherwise the answer covers the whole week.
|
||||||
|
type HabitQuery struct {
|
||||||
|
Weekday time.Weekday
|
||||||
|
HasWeekday bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// habitMarkers — the words that make a question about habit rather than about
|
||||||
|
// today. Without one of these, "что я делаю" is a question about right now, and
|
||||||
|
// the recall path owns it.
|
||||||
|
var habitMarkers = []string{
|
||||||
|
"обычно", "обычное", "чаще", "постоянно", "привычки", "привычка", "привычках",
|
||||||
|
"регулярно", "каждый", "каждую", "каждое", "usually", "habits", "habit",
|
||||||
|
"typically", "normally",
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekdayWords — every form of a weekday name maven needs to recognise,
|
||||||
|
// including the "по …ам" plural the question is usually phrased in.
|
||||||
|
var weekdayWords = map[string]time.Weekday{
|
||||||
|
"понедельник": time.Monday, "понедельникам": time.Monday,
|
||||||
|
"вторник": time.Tuesday, "вторникам": time.Tuesday,
|
||||||
|
"среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday,
|
||||||
|
"четверг": time.Thursday, "четвергам": time.Thursday,
|
||||||
|
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
|
||||||
|
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
|
||||||
|
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
|
||||||
|
"monday": time.Monday, "mondays": time.Monday,
|
||||||
|
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
|
||||||
|
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
|
||||||
|
"thursday": time.Thursday, "thursdays": time.Thursday,
|
||||||
|
"friday": time.Friday, "fridays": time.Friday,
|
||||||
|
"saturday": time.Saturday, "saturdays": time.Saturday,
|
||||||
|
"sunday": time.Sunday, "sundays": time.Sunday,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseHabitQuery reports whether an utterance asks what the owner usually
|
||||||
|
// does, and on which weekday if it names one.
|
||||||
|
//
|
||||||
|
// A habit marker is required. "что я делаю в среду?" without one is a question
|
||||||
|
// about this coming Wednesday — the calendar's job — and answering it with a
|
||||||
|
// statistical average would be answering a different question.
|
||||||
|
func ParseHabitQuery(text string) (HabitQuery, bool) {
|
||||||
|
toks := planTokens(text)
|
||||||
|
marked := false
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, m := range habitMarkers {
|
||||||
|
if t == m {
|
||||||
|
marked = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
return HabitQuery{}, false
|
||||||
|
}
|
||||||
|
for _, t := range toks {
|
||||||
|
if wd, ok := weekdayWords[t]; ok {
|
||||||
|
return HabitQuery{Weekday: wd, HasWeekday: true}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HabitQuery{}, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseHabitQuery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
ok bool
|
||||||
|
wd time.Weekday
|
||||||
|
hasWD bool
|
||||||
|
}{
|
||||||
|
{"что я обычно делаю по вторникам?", true, time.Tuesday, true},
|
||||||
|
{"что я обычно делаю?", true, 0, false},
|
||||||
|
{"какие у меня привычки", true, 0, false},
|
||||||
|
{"что я каждую пятницу делаю", true, time.Friday, true},
|
||||||
|
{"what do i usually do on mondays?", true, time.Monday, true},
|
||||||
|
// No habit marker: this is a question about the coming Wednesday, and
|
||||||
|
// the calendar owns it. Answering with an average answers the wrong
|
||||||
|
// question.
|
||||||
|
{"что я делаю в среду?", false, 0, false},
|
||||||
|
{"что у меня сегодня?", false, 0, false},
|
||||||
|
{"какие планы на сегодня?", false, 0, false},
|
||||||
|
{"", false, 0, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
q, ok := ParseHabitQuery(tt.in)
|
||||||
|
if ok != tt.ok {
|
||||||
|
t.Errorf("ParseHabitQuery(%q) ok = %v, want %v", tt.in, ok, tt.ok)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.HasWeekday != tt.hasWD {
|
||||||
|
t.Errorf("ParseHabitQuery(%q) hasWeekday = %v, want %v", tt.in, q.HasWeekday, tt.hasWD)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.HasWeekday && q.Weekday != tt.wd {
|
||||||
|
t.Errorf("ParseHabitQuery(%q) weekday = %v, want %v", tt.in, q.Weekday, tt.wd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Money questions, matched deterministically (Vikunja #125).
|
||||||
|
//
|
||||||
|
// No new intent, for the same reason as tasks: the intent enum is a contract
|
||||||
|
// with the relabelling prompt. "сколько я потратил?" is a query; which figure
|
||||||
|
// it asks for is a lookup, not something to ask a 1.7B — and a model asked to
|
||||||
|
// invent a spending total will happily do it.
|
||||||
|
|
||||||
|
// MoneyWindow — which period a money question asks about.
|
||||||
|
type MoneyWindow int
|
||||||
|
|
||||||
|
const (
|
||||||
|
MoneyNone MoneyWindow = iota
|
||||||
|
MoneyToday
|
||||||
|
MoneyMonth
|
||||||
|
)
|
||||||
|
|
||||||
|
// moneyNouns — the words that make a question be about his money.
|
||||||
|
var moneyNouns = []string{
|
||||||
|
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
|
||||||
|
"заработал", "потрачено", "денег", "spend", "spent", "expenses",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMoneyQuery reports whether an utterance asks about spending or income,
|
||||||
|
// and over which window. Defaults to the month: "сколько я потратил?" without a
|
||||||
|
// period is the month-to-date question, which is the one worth answering.
|
||||||
|
//
|
||||||
|
// Narrow on purpose. A money noun alone is not enough — "я потратил весь день
|
||||||
|
// на это" is him talking about his day, so an amount word or an explicit
|
||||||
|
// question word has to be there too.
|
||||||
|
func ParseMoneyQuery(text string) (MoneyWindow, bool) {
|
||||||
|
toks := planTokens(text)
|
||||||
|
if len(toks) == 0 {
|
||||||
|
return MoneyNone, false
|
||||||
|
}
|
||||||
|
hasNoun := false
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, n := range moneyNouns {
|
||||||
|
if t == n {
|
||||||
|
hasNoun = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNoun {
|
||||||
|
return MoneyNone, false
|
||||||
|
}
|
||||||
|
// "весь день", "время", "силы" — spending that is not money.
|
||||||
|
for _, t := range toks {
|
||||||
|
switch t {
|
||||||
|
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
|
||||||
|
return MoneyNone, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
|
||||||
|
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
|
||||||
|
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
|
||||||
|
if !asking {
|
||||||
|
return MoneyNone, false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
switch {
|
||||||
|
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
|
||||||
|
return MoneyToday, true
|
||||||
|
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
|
||||||
|
return MoneyMonth, true
|
||||||
|
}
|
||||||
|
return MoneyMonth, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseMoneyQuery(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
window MoneyWindow
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"сколько я потратил сегодня?", MoneyToday, true},
|
||||||
|
{"сколько я потратил в этом месяце?", MoneyMonth, true},
|
||||||
|
{"сколько я потратил?", MoneyMonth, true}, // month-to-date by default
|
||||||
|
{"покажи мои траты", MoneyMonth, true},
|
||||||
|
{"какие у меня расходы за месяц", MoneyMonth, true},
|
||||||
|
{"how much did I spend today", MoneyToday, true},
|
||||||
|
{"сколько я заработал в этом месяце", MoneyMonth, true},
|
||||||
|
// Not about money.
|
||||||
|
{"я потратил весь день на это", MoneyNone, false},
|
||||||
|
{"потратил много сил", MoneyNone, false},
|
||||||
|
{"какая погода?", MoneyNone, false},
|
||||||
|
{"я купил молоко", MoneyNone, false},
|
||||||
|
{"", MoneyNone, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
w, ok := ParseMoneyQuery(c.in)
|
||||||
|
if ok != c.ok || w != c.window {
|
||||||
|
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Task capture and task listing, matched deterministically (Vikunja #130).
|
||||||
|
//
|
||||||
|
// No new intent. The router's intent enum is a contract shared with the
|
||||||
|
// relabelling prompt in the training workspace (`llm/check_prompt_parity.py`
|
||||||
|
// enforces it), so adding an eighth intent would mean retraining before a task
|
||||||
|
// could be captured at all. A task phrased out loud is a note-shaped or
|
||||||
|
// query-shaped utterance with an explicit marker in it, and the marker is a
|
||||||
|
// lookup — the same reasoning the calendar, plan and habit matchers already
|
||||||
|
// follow. What the model classifies is unchanged; what these functions decide
|
||||||
|
// is which store the turn lands in.
|
||||||
|
|
||||||
|
// taskCapturePrefixes — the leading phrases that mean "put this on the list".
|
||||||
|
// A prefix, not a keyword anywhere in the sentence: "добавь в задачи купить
|
||||||
|
// молоко" is a capture, "я не добавил молоко в список" is him talking, and only
|
||||||
|
// position tells them apart.
|
||||||
|
//
|
||||||
|
// Everything here is an explicit instruction. There is deliberately no entry
|
||||||
|
// for "надо" / "нужно" — "надо бы поспать" is a thing he says, not a task he
|
||||||
|
// files, and a capture path that guesses would fill the list with his moods.
|
||||||
|
var taskCapturePrefixes = []string{
|
||||||
|
"добавь в задачи",
|
||||||
|
"добавь в список задач",
|
||||||
|
"добавь в список дел",
|
||||||
|
"добавь в список",
|
||||||
|
"добавь задачу",
|
||||||
|
"запиши в задачи",
|
||||||
|
"запиши задачу",
|
||||||
|
"новая задача",
|
||||||
|
"в задачи",
|
||||||
|
"add a task",
|
||||||
|
"add task",
|
||||||
|
"add to my tasks",
|
||||||
|
"add to tasks",
|
||||||
|
"new task",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskCapture — a parsed capture: the task itself, plus the importance he
|
||||||
|
// stated out loud if he stated one (Vikunja #129). Weight 0 means he said
|
||||||
|
// nothing about importance, which the ranker treats as exactly that — no
|
||||||
|
// urgency is inferred from the wording.
|
||||||
|
type TaskCapture struct {
|
||||||
|
Text string
|
||||||
|
Weight int
|
||||||
|
}
|
||||||
|
|
||||||
|
// urgencyMarkers — the words that set a weight, strongest first. Only these
|
||||||
|
// two rungs: "срочно" is a deadline he has not named, "важно" is a preference,
|
||||||
|
// and a third shade of urgent would be a distinction he never makes out loud.
|
||||||
|
var urgencyMarkers = []struct {
|
||||||
|
word string
|
||||||
|
weight int
|
||||||
|
}{
|
||||||
|
{"срочно", 3},
|
||||||
|
{"urgent", 3},
|
||||||
|
{"важно", 2},
|
||||||
|
{"important", 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTaskCapture reports whether an utterance explicitly files a task, and
|
||||||
|
// returns the task text with the marker stripped. A marker with nothing after it
|
||||||
|
// is not a capture (there is no task in "добавь в задачи") — the caller falls
|
||||||
|
// through to whatever it would otherwise have done with the turn.
|
||||||
|
func ParseTaskCapture(text string) (TaskCapture, bool) {
|
||||||
|
trimmed := strings.TrimSpace(text)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
best := ""
|
||||||
|
for _, p := range taskCapturePrefixes {
|
||||||
|
if strings.HasPrefix(lower, p) && len(p) > len(best) {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == "" {
|
||||||
|
return TaskCapture{}, false
|
||||||
|
}
|
||||||
|
// Cut on the rune length of the matched prefix. ToLower does not change the
|
||||||
|
// byte length of Russian or English letters, so the index carries over.
|
||||||
|
rest := strings.TrimSpace(trimmed[len(best):])
|
||||||
|
rest = strings.TrimLeft(rest, ":—- ")
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
rest = strings.TrimRight(rest, ".!")
|
||||||
|
rest, weight := stripUrgency(rest)
|
||||||
|
if rest == "" {
|
||||||
|
return TaskCapture{}, false
|
||||||
|
}
|
||||||
|
return TaskCapture{Text: rest, Weight: weight}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripUrgency pulls a leading or trailing urgency word out of the task text
|
||||||
|
// and returns the weight it implies. Only at the edges: "срочно оплатить
|
||||||
|
// интернет" and "оплатить интернет срочно" are the same instruction, while
|
||||||
|
// "позвонить в срочную помощь" is a task whose text happens to contain the
|
||||||
|
// stem, and cutting a word out of the middle of it would mangle the task.
|
||||||
|
//
|
||||||
|
// The word is removed from the text, because the list should read "оплатить
|
||||||
|
// интернет (важно)" and not "важно оплатить интернет (важно)".
|
||||||
|
func stripUrgency(text string) (string, int) {
|
||||||
|
for _, m := range urgencyMarkers {
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, m.word+" "):
|
||||||
|
return strings.TrimSpace(text[len(m.word):]), m.weight
|
||||||
|
case strings.HasSuffix(lower, " "+m.word):
|
||||||
|
return strings.TrimSpace(text[:len(text)-len(m.word)]), m.weight
|
||||||
|
case lower == m.word:
|
||||||
|
// Nothing but the marker — no task in it.
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskListWords — the nouns that make a question be about the task list.
|
||||||
|
var taskListWords = []string{"задачи", "задачах", "задач", "задачам", "дела", "делах", "дел", "tasks", "todo", "todos"}
|
||||||
|
|
||||||
|
// taskListVerbs — the asks that pair with those nouns. "что мне нужно сделать?"
|
||||||
|
// has no task noun in it at all, so it is matched as a phrase below.
|
||||||
|
var taskListWordsShortcut = []string{"задачи", "задач", "tasks"}
|
||||||
|
|
||||||
|
// IsTaskListQuery reports whether an utterance asks for the outstanding task
|
||||||
|
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
|
||||||
|
//
|
||||||
|
// Narrow on purpose. "как дела?" is a greeting, not a query about work, and it
|
||||||
|
// contains a task noun; it is excluded explicitly. Anything that mentions a
|
||||||
|
// task noun without asking for the list falls through to ordinary recall.
|
||||||
|
func IsTaskListQuery(text string) bool {
|
||||||
|
toks := planTokens(text)
|
||||||
|
if len(toks) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// "как дела" — the greeting. Excluded before anything else matches.
|
||||||
|
if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// "что мне нужно сделать" / "что мне делать" — no task noun at all.
|
||||||
|
if (hasTok(toks, "что") || hasTok(toks, "чем")) &&
|
||||||
|
(hasTok(toks, "сделать") || hasTok(toks, "заняться")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if hasTok(toks, "what") && hasTok(toks, "do") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
hasNoun := false
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, w := range taskListWords {
|
||||||
|
if t == w {
|
||||||
|
hasNoun = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNoun {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A task noun plus any of: a question word, "список", or a bare
|
||||||
|
// one/two-word ask ("задачи", "мои задачи").
|
||||||
|
if hasTok(toks, "какие") || hasTok(toks, "какая") || hasTok(toks, "что") ||
|
||||||
|
hasTok(toks, "сколько") || hasTok(toks, "список") || hasTok(toks, "покажи") ||
|
||||||
|
hasTok(toks, "напомни") || hasTok(toks, "my") || hasTok(toks, "list") ||
|
||||||
|
hasTok(toks, "show") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(toks) <= 2 {
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, w := range taskListWordsShortcut {
|
||||||
|
if t == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTaskCapture(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
text string
|
||||||
|
weight int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"добавь в задачи купить молоко", "купить молоко", 0, true},
|
||||||
|
{"Добавь в список дел: позвонить в банк", "позвонить в банк", 0, true},
|
||||||
|
{"запиши задачу починить кран.", "починить кран", 0, true},
|
||||||
|
{"новая задача — оплатить интернет", "оплатить интернет", 0, true},
|
||||||
|
{"add a task buy milk", "buy milk", 0, true},
|
||||||
|
// Urgency he stated out loud, leading or trailing, stripped from the text.
|
||||||
|
{"добавь в задачи срочно оплатить интернет", "оплатить интернет", 3, true},
|
||||||
|
{"добавь в задачи оплатить интернет срочно", "оплатить интернет", 3, true},
|
||||||
|
{"новая задача важно позвонить маме", "позвонить маме", 2, true},
|
||||||
|
// The stem inside the task text is part of the task, not a marker.
|
||||||
|
{"добавь в задачи позвонить в срочную помощь", "позвонить в срочную помощь", 0, true},
|
||||||
|
// A marker with nothing after it files nothing.
|
||||||
|
{"добавь в задачи", "", 0, false},
|
||||||
|
{"новая задача", "", 0, false},
|
||||||
|
{"добавь в задачи срочно", "", 0, false},
|
||||||
|
// Not a capture: he is talking, not filing.
|
||||||
|
{"надо бы поспать", "", 0, false},
|
||||||
|
{"я не добавил молоко в список", "", 0, false},
|
||||||
|
{"какие у меня задачи?", "", 0, false},
|
||||||
|
{"", "", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := ParseTaskCapture(c.in)
|
||||||
|
if ok != c.ok || got.Text != c.text || got.Weight != c.weight {
|
||||||
|
t.Errorf("ParseTaskCapture(%q) = (%+v, %v), want (%q, w=%d, %v)", c.in, got, ok, c.text, c.weight, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTaskListQuery(t *testing.T) {
|
||||||
|
yes := []string{
|
||||||
|
"какие у меня задачи?",
|
||||||
|
"что мне нужно сделать?",
|
||||||
|
"покажи список дел",
|
||||||
|
"сколько у меня задач?",
|
||||||
|
"задачи",
|
||||||
|
"мои задачи",
|
||||||
|
"what should I do",
|
||||||
|
}
|
||||||
|
for _, s := range yes {
|
||||||
|
if !IsTaskListQuery(s) {
|
||||||
|
t.Errorf("IsTaskListQuery(%q) = false, want true", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
no := []string{
|
||||||
|
"как дела?",
|
||||||
|
"какая погода?",
|
||||||
|
"напомни мне позвонить маме в шесть",
|
||||||
|
"я сделал зарядку",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, s := range no {
|
||||||
|
if IsTaskListQuery(s) {
|
||||||
|
t.Errorf("IsTaskListQuery(%q) = true, want false", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-5
@@ -6,7 +6,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
|
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
|
||||||
@@ -79,17 +82,29 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalendarEvents returns caldav facts whose key date falls within [from, to).
|
// CalendarEvents returns calendar facts whose key date falls within [from, to).
|
||||||
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
|
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
|
||||||
|
//
|
||||||
|
// Every calendar source is included, not just the personal CalDAV poll: the work
|
||||||
|
// calendar arrives as ambient:notif notifications (Vikunja #126) and belongs in
|
||||||
|
// the same answer. The source stays on each Fact, along with its confidence, so
|
||||||
|
// the caller can hedge a reading it did not get from a calendar server —
|
||||||
|
// filtering by source here would have thrown that judgement away.
|
||||||
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||||
prefixFrom := fmt.Sprintf("calendar_event_%s", from.Format("20060102"))
|
prefixFrom := calendar.KeyPrefixForDay(from)
|
||||||
prefixTo := fmt.Sprintf("calendar_event_%s", to.Format("20060102"))
|
prefixTo := calendar.KeyPrefixForDay(to)
|
||||||
|
sources := calendar.Sources()
|
||||||
|
args := make([]any, 0, len(sources)+2)
|
||||||
|
for _, src := range sources {
|
||||||
|
args = append(args, src)
|
||||||
|
}
|
||||||
|
args = append(args, prefixFrom, prefixTo)
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
||||||
FROM facts
|
FROM facts
|
||||||
WHERE source = 'poll:caldav'
|
WHERE source IN (`+placeholders(len(sources))+`)
|
||||||
AND key >= ? AND key < ?
|
AND key >= ? AND key < ?
|
||||||
ORDER BY key`, prefixFrom, prefixTo)
|
ORDER BY key`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("calendar events: %w", err)
|
return nil, fmt.Errorf("calendar events: %w", err)
|
||||||
}
|
}
|
||||||
@@ -254,3 +269,8 @@ func scanFact(r rowScanner) (Fact, error) {
|
|||||||
f.VoidsID = voids
|
f.VoidsID = voids
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// placeholders renders n comma-separated SQL bind markers.
|
||||||
|
func placeholders(n int) string {
|
||||||
|
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
expires_ts INTEGER NOT NULL
|
expires_ts INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
|
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
|
||||||
|
|
||||||
|
// #14 — the task capture store (Vikunja #130). Deliberately NOT facts:
|
||||||
|
// a fact is a claim about the world that gets superseded, a task is a
|
||||||
|
// piece of work with a lifecycle (captured → open → done), and the
|
||||||
|
// prioritiser needs to read the live set cheaply.
|
||||||
|
//
|
||||||
|
// status: 'candidate' is a task Maven derived from something she read
|
||||||
|
// (mail, later) and has NOT been confirmed by the owner; 'open' is a task
|
||||||
|
// he actually stated (or confirmed). Nothing schedules or announces off
|
||||||
|
// this table — capture is not a nag.
|
||||||
|
//
|
||||||
|
// norm is the normalised dedupe key. The unique index is PARTIAL, over
|
||||||
|
// live rows only: re-capturing "купить молоко" after last week's one is
|
||||||
|
// done must work, while the same mail arriving twice must not produce two
|
||||||
|
// rows.
|
||||||
|
`CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_ts INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
norm TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
evidence TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('candidate','open','done','dropped')),
|
||||||
|
due_ts INTEGER,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 0,
|
||||||
|
resolved_ts INTEGER
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestStore(t *testing.T) *Store {
|
func newTestStore(t *testing.T) *Store {
|
||||||
@@ -378,3 +380,47 @@ func TestCalendarEvents(t *testing.T) {
|
|||||||
t.Fatalf("expected 0 events on July 8, got %d", len(events))
|
t.Fatalf("expected 0 events on July 8, got %d", len(events))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The work calendar arrives as relayed phone notifications, not a CalDAV read
|
||||||
|
// (Vikunja #126). Those events belong in the same day's answer, and their
|
||||||
|
// provenance has to survive the query so the caller can hedge them.
|
||||||
|
func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal",
|
||||||
|
`"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
|
||||||
|
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work",
|
||||||
|
`"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
|
||||||
|
// A fact that merely looks like one must still be excluded by source.
|
||||||
|
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged",
|
||||||
|
`"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{})
|
||||||
|
|
||||||
|
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CalendarEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events)
|
||||||
|
}
|
||||||
|
bySource := map[string]Fact{}
|
||||||
|
for _, e := range events {
|
||||||
|
bySource[e.Source] = e
|
||||||
|
}
|
||||||
|
if _, ok := bySource[calendar.SourcePersonal]; !ok {
|
||||||
|
t.Error("the personal CalDAV event is missing")
|
||||||
|
}
|
||||||
|
amb, ok := bySource[calendar.SourceAmbient]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the ambient work event is missing")
|
||||||
|
}
|
||||||
|
if amb.Confidence >= 1.0 {
|
||||||
|
t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence)
|
||||||
|
}
|
||||||
|
if _, ok := bySource["tap:voice"]; ok {
|
||||||
|
t.Error("a non-calendar source must not be read as a calendar event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tasks — the capture store (Vikunja #130). One row per piece of work, with a
|
||||||
|
// lifecycle instead of a valid-time: captured, then either done or dropped.
|
||||||
|
//
|
||||||
|
// Why not facts: a fact is a claim about the world and a correction supersedes
|
||||||
|
// it (append-only, voids_id). A task is not a claim — it is work, it has a
|
||||||
|
// state that moves forward once, and the read the prioritiser needs is "every
|
||||||
|
// live task right now", which over an append-only log would mean replaying
|
||||||
|
// history on every question.
|
||||||
|
//
|
||||||
|
// Nothing in this file schedules, fires or announces anything. Capture is a
|
||||||
|
// store, not a trigger: a task exists to be answered when asked about, and the
|
||||||
|
// owner's reminders remain the only thing that speaks unprompted.
|
||||||
|
const (
|
||||||
|
// TaskCandidate — Maven derived this task from something she read (mail,
|
||||||
|
// once the email reader exists) and the owner has not confirmed it. A
|
||||||
|
// candidate is inert: it is listed as a candidate and never counted as work
|
||||||
|
// he agreed to.
|
||||||
|
TaskCandidate = "candidate"
|
||||||
|
// TaskOpen — work the owner stated himself, or a candidate he confirmed.
|
||||||
|
TaskOpen = "open"
|
||||||
|
// TaskDone — finished.
|
||||||
|
TaskDone = "done"
|
||||||
|
// TaskDropped — declined, or a candidate rejected. Kept for provenance, so
|
||||||
|
// the same mail cannot resurrect it silently; nothing re-proposes a
|
||||||
|
// dropped task.
|
||||||
|
TaskDropped = "dropped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task — one captured piece of work.
|
||||||
|
//
|
||||||
|
// Source is provenance in the same vocabulary facts use: "tap:voice" for
|
||||||
|
// something he said, "tap:web" for the review page, "email:<account>" for a
|
||||||
|
// mail-derived candidate. Evidence is the free-text trail a derived task came
|
||||||
|
// from (a subject line), empty for anything he stated himself — it is what
|
||||||
|
// makes a candidate reviewable instead of mysterious.
|
||||||
|
//
|
||||||
|
// Due is optional. Weight is an explicit importance hint (0 = none), which the
|
||||||
|
// prioritiser reads; capture never invents one.
|
||||||
|
type Task struct {
|
||||||
|
ID int64
|
||||||
|
CreatedTs time.Time
|
||||||
|
Text string
|
||||||
|
Source string
|
||||||
|
Evidence string
|
||||||
|
Status string
|
||||||
|
Due *time.Time
|
||||||
|
Weight int
|
||||||
|
ResolvedTs *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTaskNotFound = errors.New("store: task not found")
|
||||||
|
ErrTaskEmpty = errors.New("store: task text is empty")
|
||||||
|
ErrTaskStatus = errors.New("store: invalid task status")
|
||||||
|
)
|
||||||
|
|
||||||
|
// liveTaskStatuses — the two statuses that count as outstanding work.
|
||||||
|
var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
|
||||||
|
|
||||||
|
// CaptureTask inserts a task, or returns the existing live task when the same
|
||||||
|
// work is already outstanding. created reports which happened, so a caller can
|
||||||
|
// tell the owner "уже в списке" instead of pretending it wrote something.
|
||||||
|
//
|
||||||
|
// Dedupe is on the normalised text among LIVE rows only (see the partial unique
|
||||||
|
// index in migration #14): a weekly errand can be captured again once the last
|
||||||
|
// one is done, but a mail that gets re-read produces no second row. This is the
|
||||||
|
// property the email intake depends on — it may call CaptureTask for every
|
||||||
|
// message it extracts from, as often as it likes, without growing the list.
|
||||||
|
func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) {
|
||||||
|
text := strings.TrimSpace(t.Text)
|
||||||
|
if text == "" {
|
||||||
|
return 0, false, ErrTaskEmpty
|
||||||
|
}
|
||||||
|
status := t.Status
|
||||||
|
if status == "" {
|
||||||
|
status = TaskOpen
|
||||||
|
}
|
||||||
|
if status != TaskCandidate && status != TaskOpen {
|
||||||
|
// Capturing straight into a resolved state is meaningless — a task is
|
||||||
|
// captured live and moved later.
|
||||||
|
return 0, false, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
|
||||||
|
}
|
||||||
|
norm := NormalizeTaskText(text)
|
||||||
|
created2 := t.CreatedTs
|
||||||
|
if created2.IsZero() {
|
||||||
|
created2 = time.Now()
|
||||||
|
}
|
||||||
|
var due sql.NullInt64
|
||||||
|
if t.Due != nil {
|
||||||
|
due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`,
|
||||||
|
created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("capture task: %w", err)
|
||||||
|
}
|
||||||
|
if n, err := res.RowsAffected(); err != nil {
|
||||||
|
return 0, false, fmt.Errorf("capture task: rows affected: %w", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("capture task: last insert id: %w", err)
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already live — hand back the row that won.
|
||||||
|
existing, err := s.lookupLiveTaskByNorm(ctx, norm)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return existing.ID, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupLiveTaskByNorm finds the outstanding task with this normalised text.
|
||||||
|
func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, taskSelect+`
|
||||||
|
WHERE norm = ? AND status IN ('candidate','open')`, norm)
|
||||||
|
t, err := scanTask(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Task{}, ErrTaskNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Task{}, fmt.Errorf("lookup live task: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskSelect = `SELECT id, created_ts, text, source, evidence, status, due_ts, weight, resolved_ts FROM tasks`
|
||||||
|
|
||||||
|
// LookupTask returns one task by id.
|
||||||
|
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, taskSelect+` WHERE id = ?`, id)
|
||||||
|
t, err := scanTask(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Task{}, fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Task{}, fmt.Errorf("lookup task: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks returns tasks in one status, newest first. An empty status returns
|
||||||
|
// every row; "live" returns candidate + open, which is what every read path
|
||||||
|
// that means "outstanding work" wants.
|
||||||
|
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||||
|
q := taskSelect
|
||||||
|
var args []any
|
||||||
|
switch status {
|
||||||
|
case "":
|
||||||
|
case "live":
|
||||||
|
q += ` WHERE status IN (?,?)`
|
||||||
|
args = append(args, liveTaskStatuses[0], liveTaskStatuses[1])
|
||||||
|
default:
|
||||||
|
q += ` WHERE status = ?`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
q += ` ORDER BY created_ts DESC, id DESC`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tasks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Task
|
||||||
|
for rows.Next() {
|
||||||
|
t, err := scanTask(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tasks: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTaskStatus moves a task once, forward. The legal moves are:
|
||||||
|
//
|
||||||
|
// candidate → open (the owner confirms a derived task)
|
||||||
|
// candidate → dropped (he rejects it)
|
||||||
|
// open → done (finished)
|
||||||
|
// open → dropped (abandoned)
|
||||||
|
//
|
||||||
|
// Anything else — including re-resolving a resolved task — is refused with
|
||||||
|
// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
|
||||||
|
// tools use: an answered question is not answered twice.
|
||||||
|
//
|
||||||
|
// Resolving frees the dedupe key, which is the point: the work can recur.
|
||||||
|
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||||
|
var from []string
|
||||||
|
switch status {
|
||||||
|
case TaskOpen:
|
||||||
|
from = []string{TaskCandidate}
|
||||||
|
case TaskDone:
|
||||||
|
from = []string{TaskOpen}
|
||||||
|
case TaskDropped:
|
||||||
|
from = []string{TaskCandidate, TaskOpen}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %q", ErrTaskStatus, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolved_ts is only meaningful for a terminal state; confirming a
|
||||||
|
// candidate leaves it null (the task is still live).
|
||||||
|
var resolved sql.NullInt64
|
||||||
|
if status == TaskDone || status == TaskDropped {
|
||||||
|
resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `UPDATE tasks SET status = ?, resolved_ts = ? WHERE id = ? AND status IN (?` +
|
||||||
|
strings.Repeat(",?", len(from)-1) + `)`
|
||||||
|
args := []any{status, resolved, id}
|
||||||
|
for _, f := range from {
|
||||||
|
args = append(args, f)
|
||||||
|
}
|
||||||
|
res, err := s.db.ExecContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set task status: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set task status: rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
|
||||||
|
// whitespace collapsed. Exported because the intake seam (and its tests) needs
|
||||||
|
// to reason about what will and will not be treated as the same task.
|
||||||
|
//
|
||||||
|
// Deliberately shallow — no stemming, no synonyms. Russian morphology would
|
||||||
|
// need a real lemmatiser to do better, and a normaliser that guesses would
|
||||||
|
// silently swallow two different tasks. This only catches the case that
|
||||||
|
// actually happens: the same sentence arriving twice with different casing or
|
||||||
|
// punctuation.
|
||||||
|
func NormalizeTaskText(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
space := true // leading space collapses to nothing
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
switch {
|
||||||
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||||
|
b.WriteRune(r)
|
||||||
|
space = false
|
||||||
|
case !space:
|
||||||
|
b.WriteRune(' ')
|
||||||
|
space = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTask(sc scanner) (Task, error) {
|
||||||
|
var t Task
|
||||||
|
var created int64
|
||||||
|
var due, resolved sql.NullInt64
|
||||||
|
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.Status, &due, &t.Weight, &resolved); err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
|
t.CreatedTs = time.UnixMilli(created).UTC()
|
||||||
|
t.Due = millisToTime(due)
|
||||||
|
t.ResolvedTs = millisToTime(resolved)
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCaptureTaskDedupesLiveWork(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
|
t.Fatal("first capture must create a row")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same work, different casing and punctuation — one task, not two.
|
||||||
|
again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
t.Error("second capture of the same live work must not create a row")
|
||||||
|
}
|
||||||
|
if again != id {
|
||||||
|
t.Errorf("dedupe returned id %d, want the existing %d", again, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
live, err := st.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(live) != 1 {
|
||||||
|
t.Fatalf("live tasks = %d, want 1", len(live))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The dedupe key is free again: a recurring errand must be capturable.
|
||||||
|
id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !created || id2 == id {
|
||||||
|
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
|
||||||
|
}
|
||||||
|
live, err := st.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(live) != 1 || live[0].ID != id2 {
|
||||||
|
t.Fatalf("live = %+v, want only the new task %d", live, id2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
due := now.Add(48 * time.Hour)
|
||||||
|
|
||||||
|
id, _, err := st.CaptureTask(ctx, Task{
|
||||||
|
Text: "продлить страховку",
|
||||||
|
Source: "email:kami",
|
||||||
|
Evidence: "Re: страховой полис истекает",
|
||||||
|
Status: TaskCandidate,
|
||||||
|
Due: &due,
|
||||||
|
Weight: 2,
|
||||||
|
CreatedTs: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := st.LookupTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != TaskCandidate {
|
||||||
|
t.Errorf("status = %q, want candidate", got.Status)
|
||||||
|
}
|
||||||
|
if got.Evidence != "Re: страховой полис истекает" {
|
||||||
|
t.Errorf("evidence = %q", got.Evidence)
|
||||||
|
}
|
||||||
|
if got.Due == nil || !got.Due.Equal(due.UTC()) {
|
||||||
|
t.Errorf("due = %v, want %v", got.Due, due.UTC())
|
||||||
|
}
|
||||||
|
if got.Weight != 2 {
|
||||||
|
t.Errorf("weight = %d, want 2", got.Weight)
|
||||||
|
}
|
||||||
|
if got.ResolvedTs != nil {
|
||||||
|
t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
cand, _, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// candidate → done is not a legal move: he has to confirm it first.
|
||||||
|
if err := st.SetTaskStatus(ctx, cand, TaskDone, now); !errors.Is(err, ErrTaskNotFound) {
|
||||||
|
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Already resolved — a second resolve must not move it again.
|
||||||
|
if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour)); !errors.Is(err, ErrTaskNotFound) {
|
||||||
|
t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
|
||||||
|
}
|
||||||
|
got, err := st.LookupTask(ctx, cand)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != TaskDone {
|
||||||
|
t.Errorf("status = %q, want done", got.Status)
|
||||||
|
}
|
||||||
|
if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
|
||||||
|
t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
||||||
|
t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
||||||
|
t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskRejectsEmptyText(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
if _, _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
|
||||||
|
t.Errorf("err = %v, want ErrTaskEmpty", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTasksFiltersByStatus(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
open1, _, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
|
||||||
|
_, _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
|
||||||
|
done, _, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
|
||||||
|
if err := st.SetTaskStatus(ctx, done, TaskDone, now.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cands, err := st.ListTasks(ctx, TaskCandidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cands) != 1 || cands[0].Text != "вторая" {
|
||||||
|
t.Fatalf("candidates = %+v", cands)
|
||||||
|
}
|
||||||
|
opens, err := st.ListTasks(ctx, TaskOpen)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(opens) != 1 || opens[0].ID != open1 {
|
||||||
|
t.Fatalf("open = %+v", opens)
|
||||||
|
}
|
||||||
|
all, err := st.ListTasks(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("all = %d, want 3", len(all))
|
||||||
|
}
|
||||||
|
// Newest first.
|
||||||
|
if all[0].Text != "третья" {
|
||||||
|
t.Errorf("first = %q, want newest ('третья')", all[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTaskText(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"Купить молоко!", "купить молоко"},
|
||||||
|
{" купить МОЛОКО ", "купить молоко"},
|
||||||
|
{"позвонить в банк (важно)", "позвонить в банк важно"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := NormalizeTaskText(c.in); got != c.want {
|
||||||
|
t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Package tasks ranks captured work (Vikunja #129).
|
||||||
|
//
|
||||||
|
// The ordering is COMPUTED, not generated. Asking a 1.7B model which of his
|
||||||
|
// tasks matters most would produce a fluent opinion about his life with no
|
||||||
|
// basis in anything, and a confidently wrong priority is worse than no
|
||||||
|
// priority at all — the same reasoning as the behaviour profile in
|
||||||
|
// internal/memory, which counts instead of summarising.
|
||||||
|
//
|
||||||
|
// So: four signals, all of them things he told her, and a reason string naming
|
||||||
|
// the one that decided each row. Nothing here invents urgency. A task with no
|
||||||
|
// due date and no weight scores nothing and sits where its age puts it, which
|
||||||
|
// is the honest answer to "which of these matters?" when he never said.
|
||||||
|
//
|
||||||
|
// Ranking is a READ. It sorts and renders; it never writes, schedules or
|
||||||
|
// announces. Maven is not a nag: a task rising to the top of this list is not a
|
||||||
|
// reason to speak, only the order she recites in when asked.
|
||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
|
||||||
|
// without importing the store.
|
||||||
|
const (
|
||||||
|
StatusCandidate = "candidate"
|
||||||
|
StatusOpen = "open"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item — one task to rank. The subset of a task that ranking depends on;
|
||||||
|
// callers map their own row type onto it.
|
||||||
|
type Item struct {
|
||||||
|
ID int64
|
||||||
|
Text string
|
||||||
|
Status string
|
||||||
|
Created time.Time
|
||||||
|
Due *time.Time
|
||||||
|
Weight int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranked — one task with its score and the reason that decided it.
|
||||||
|
type Ranked struct {
|
||||||
|
Item
|
||||||
|
Score float64
|
||||||
|
// Reason — the dominant signal, in Russian, for the page and the spoken
|
||||||
|
// list. Empty when nothing distinguished this task: no due date, no
|
||||||
|
// weight, not old. Saying "потому что" about a task he never prioritised
|
||||||
|
// would be making something up.
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoring weights. Deliberately coarse round numbers: this is a knob, not
|
||||||
|
// math, and the only property that has to hold is the ordering between classes
|
||||||
|
// (overdue beats today beats this week beats undated).
|
||||||
|
const (
|
||||||
|
scoreOverdue = 100 // he already missed it
|
||||||
|
scoreOverduePer = 5 // per further day late, capped
|
||||||
|
scoreOverdueCap = 40
|
||||||
|
scoreDueToday = 60
|
||||||
|
scoreDueTomorrow = 40
|
||||||
|
scoreDueWeek = 20
|
||||||
|
scoreDueLater = 5
|
||||||
|
scorePerWeight = 15 // "срочно" / "важно" / the web form's select
|
||||||
|
scorePerWeekOld = 1 // so nothing rots at the bottom forever
|
||||||
|
scoreAgeCap = 10
|
||||||
|
// MaxWeight — the highest importance hint capture accepts. Three rungs is
|
||||||
|
// as many as anyone can rank by hand honestly.
|
||||||
|
MaxWeight = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rank scores every item and returns them ordered: confirmed work first, then
|
||||||
|
// candidates, each by score descending, oldest first on a tie.
|
||||||
|
//
|
||||||
|
// Candidates never outrank open work, whatever their due date. A task Maven
|
||||||
|
// derived from something she read is a suggestion until he confirms it, and
|
||||||
|
// putting her guess above his own stated work would be reading his priorities
|
||||||
|
// back to him wrong.
|
||||||
|
func Rank(items []Item, now time.Time) []Ranked {
|
||||||
|
out := make([]Ranked, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
score, reason := score(it, now)
|
||||||
|
out = append(out, Ranked{Item: it, Score: score, Reason: reason})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
ci, cj := out[i].Status == StatusCandidate, out[j].Status == StatusCandidate
|
||||||
|
if ci != cj {
|
||||||
|
return !ci // open before candidate
|
||||||
|
}
|
||||||
|
if out[i].Score != out[j].Score {
|
||||||
|
return out[i].Score > out[j].Score
|
||||||
|
}
|
||||||
|
return out[i].Created.Before(out[j].Created) // oldest first, FIFO
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// score — the per-item scoring function. Returns the score and the dominant
|
||||||
|
// reason. Deadline beats weight when both are present: a date is a fact about
|
||||||
|
// the world, a weight is how he felt when he filed it.
|
||||||
|
func score(it Item, now time.Time) (float64, string) {
|
||||||
|
var total float64
|
||||||
|
reason := ""
|
||||||
|
|
||||||
|
if it.Due != nil {
|
||||||
|
days := dayDelta(*it.Due, now)
|
||||||
|
switch {
|
||||||
|
case days < 0:
|
||||||
|
late := -days
|
||||||
|
bonus := float64(late * scoreOverduePer)
|
||||||
|
if bonus > scoreOverdueCap {
|
||||||
|
bonus = scoreOverdueCap
|
||||||
|
}
|
||||||
|
total += scoreOverdue + bonus
|
||||||
|
reason = "просрочено"
|
||||||
|
if late == 1 {
|
||||||
|
reason = "просрочено на день"
|
||||||
|
} else if late > 1 {
|
||||||
|
reason = fmt.Sprintf("просрочено на %d дн.", late)
|
||||||
|
}
|
||||||
|
case days == 0:
|
||||||
|
total += scoreDueToday
|
||||||
|
reason = "сегодня"
|
||||||
|
case days == 1:
|
||||||
|
total += scoreDueTomorrow
|
||||||
|
reason = "завтра"
|
||||||
|
case days <= 7:
|
||||||
|
total += scoreDueWeek
|
||||||
|
reason = fmt.Sprintf("через %d дн.", days)
|
||||||
|
default:
|
||||||
|
total += scoreDueLater
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := it.Weight
|
||||||
|
if w > MaxWeight {
|
||||||
|
w = MaxWeight
|
||||||
|
}
|
||||||
|
if w > 0 {
|
||||||
|
total += float64(w * scorePerWeight)
|
||||||
|
if reason == "" {
|
||||||
|
reason = "важно"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !it.Created.IsZero() {
|
||||||
|
weeks := int(now.Sub(it.Created).Hours() / (24 * 7))
|
||||||
|
if weeks > 0 {
|
||||||
|
age := float64(weeks * scorePerWeekOld)
|
||||||
|
if age > scoreAgeCap {
|
||||||
|
age = scoreAgeCap
|
||||||
|
}
|
||||||
|
total += age
|
||||||
|
if reason == "" && weeks >= 2 {
|
||||||
|
reason = "давно в списке"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayDelta — calendar days from now to due, in due's own location. Whole days,
|
||||||
|
// not hours: a task due today is due today whether it is 09:00 or 23:00, and an
|
||||||
|
// hours-based comparison would call this evening's task "overdue" all afternoon.
|
||||||
|
func dayDelta(due, now time.Time) int {
|
||||||
|
loc := due.Location()
|
||||||
|
d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc)
|
||||||
|
n := now.In(loc)
|
||||||
|
n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
|
||||||
|
return int(d.Sub(n).Hours() / 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpokenLimit — how many tasks the spoken list names before it summarises the
|
||||||
|
// rest. A recital of twenty items is noise; five is a list he can hold.
|
||||||
|
const SpokenLimit = 5
|
||||||
|
|
||||||
|
// FormatRU renders a ranked list the way Maven says it. Confirmed work first,
|
||||||
|
// with the reason attached where there is one; candidates named as
|
||||||
|
// unconfirmed, never recited as his work.
|
||||||
|
//
|
||||||
|
// One renderer for the voice reply and the web page, for the same reason
|
||||||
|
// DayPlan.Spoken is built core-side: two formatters drift, and then she says
|
||||||
|
// one order and shows another.
|
||||||
|
func FormatRU(ranked []Ranked) string {
|
||||||
|
var open, cands []Ranked
|
||||||
|
for _, r := range ranked {
|
||||||
|
if r.Status == StatusCandidate {
|
||||||
|
cands = append(cands, r)
|
||||||
|
} else {
|
||||||
|
open = append(open, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(open) == 0 && len(cands) == 0 {
|
||||||
|
return "задач нет."
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if len(open) > 0 {
|
||||||
|
b.WriteString("сначала: ")
|
||||||
|
b.WriteString(joinRU(open, SpokenLimit, true))
|
||||||
|
b.WriteString(".")
|
||||||
|
}
|
||||||
|
if len(cands) > 0 {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteString(" ")
|
||||||
|
}
|
||||||
|
b.WriteString("ещё я нашла, но ты не подтвердил: ")
|
||||||
|
b.WriteString(joinRU(cands, SpokenLimit, false))
|
||||||
|
b.WriteString(".")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinRU lists up to limit tasks, then says how many are left. withReasons
|
||||||
|
// attaches the parenthesised reason — candidates are listed bare, since their
|
||||||
|
// due dates are Maven's reading of a mail and not something he stated.
|
||||||
|
func joinRU(rs []Ranked, limit int, withReasons bool) string {
|
||||||
|
shown := rs
|
||||||
|
rest := 0
|
||||||
|
if len(rs) > limit {
|
||||||
|
shown, rest = rs[:limit], len(rs)-limit
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(shown))
|
||||||
|
for _, r := range shown {
|
||||||
|
if withReasons && r.Reason != "" {
|
||||||
|
parts = append(parts, r.Text+" ("+r.Reason+")")
|
||||||
|
} else {
|
||||||
|
parts = append(parts, r.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := strings.Join(parts, "; ")
|
||||||
|
if rest > 0 {
|
||||||
|
s += fmt.Sprintf("; и ещё %d", rest)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func at(y int, m time.Month, d int) *time.Time {
|
||||||
|
t := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() time.Time { return time.Date(2026, 8, 1, 14, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func texts(rs []Ranked) []string {
|
||||||
|
out := make([]string, len(rs))
|
||||||
|
for i, r := range rs {
|
||||||
|
out[i] = r.Text
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankOrdersByDeadline(t *testing.T) {
|
||||||
|
items := []Item{
|
||||||
|
{ID: 1, Text: "через неделю", Status: StatusOpen, Due: at(2026, 8, 7), Created: now()},
|
||||||
|
{ID: 2, Text: "просрочено", Status: StatusOpen, Due: at(2026, 7, 28), Created: now()},
|
||||||
|
{ID: 3, Text: "без срока", Status: StatusOpen, Created: now()},
|
||||||
|
{ID: 4, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()},
|
||||||
|
{ID: 5, Text: "завтра", Status: StatusOpen, Due: at(2026, 8, 2), Created: now()},
|
||||||
|
}
|
||||||
|
got := texts(Rank(items, now()))
|
||||||
|
want := []string{"просрочено", "сегодня", "завтра", "через неделю", "без срока"}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("order = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankCandidatesNeverOutrankOpenWork(t *testing.T) {
|
||||||
|
items := []Item{
|
||||||
|
{ID: 1, Text: "его задача", Status: StatusOpen, Created: now()},
|
||||||
|
// Everything about this one screams urgent — and it is still a guess.
|
||||||
|
{ID: 2, Text: "из письма", Status: StatusCandidate, Due: at(2026, 7, 1), Weight: 3, Created: now()},
|
||||||
|
}
|
||||||
|
got := Rank(items, now())
|
||||||
|
if got[0].Text != "его задача" {
|
||||||
|
t.Errorf("order = %v, want his own work first", texts(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankWeightLiftsUndatedWork(t *testing.T) {
|
||||||
|
items := []Item{
|
||||||
|
{ID: 1, Text: "обычная", Status: StatusOpen, Created: now()},
|
||||||
|
{ID: 2, Text: "важная", Status: StatusOpen, Weight: 2, Created: now()},
|
||||||
|
}
|
||||||
|
got := Rank(items, now())
|
||||||
|
if got[0].Text != "важная" {
|
||||||
|
t.Errorf("order = %v, want the weighted task first", texts(got))
|
||||||
|
}
|
||||||
|
if got[0].Reason != "важно" {
|
||||||
|
t.Errorf("reason = %q, want важно", got[0].Reason)
|
||||||
|
}
|
||||||
|
// A deadline still beats a weight: a date is a fact, a weight is a feeling.
|
||||||
|
items = append(items, Item{ID: 3, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()})
|
||||||
|
got = Rank(items, now())
|
||||||
|
if got[0].Text != "сегодня" {
|
||||||
|
t.Errorf("order = %v, want the dated task first", texts(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankOldestFirstOnATie(t *testing.T) {
|
||||||
|
old := now().AddDate(0, 0, -3)
|
||||||
|
items := []Item{
|
||||||
|
{ID: 1, Text: "новая", Status: StatusOpen, Created: now()},
|
||||||
|
{ID: 2, Text: "старая", Status: StatusOpen, Created: old},
|
||||||
|
}
|
||||||
|
got := Rank(items, now())
|
||||||
|
if got[0].Text != "старая" {
|
||||||
|
t.Errorf("order = %v, want FIFO on equal urgency", texts(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankNoInventedReason(t *testing.T) {
|
||||||
|
got := Rank([]Item{{ID: 1, Text: "что-то", Status: StatusOpen, Created: now()}}, now())
|
||||||
|
if got[0].Reason != "" {
|
||||||
|
t.Errorf("reason = %q — nothing distinguished this task, so there is nothing to say", got[0].Reason)
|
||||||
|
}
|
||||||
|
if got[0].Score != 0 {
|
||||||
|
t.Errorf("score = %v, want 0", got[0].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankAgeIsCappedAndNamed(t *testing.T) {
|
||||||
|
items := []Item{
|
||||||
|
{ID: 1, Text: "прошлогодняя", Status: StatusOpen, Created: now().AddDate(-1, 0, 0)},
|
||||||
|
{ID: 2, Text: "трёхнедельная", Status: StatusOpen, Created: now().AddDate(0, 0, -21)},
|
||||||
|
}
|
||||||
|
got := Rank(items, now())
|
||||||
|
if got[0].Score != scoreAgeCap {
|
||||||
|
t.Errorf("oldest score = %v, want the cap %v", got[0].Score, float64(scoreAgeCap))
|
||||||
|
}
|
||||||
|
if got[0].Reason != "давно в списке" {
|
||||||
|
t.Errorf("reason = %q", got[0].Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task due at 23:00 today is due today, not overdue since this morning.
|
||||||
|
func TestRankDueTodayIsNotOverdue(t *testing.T) {
|
||||||
|
due := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC)
|
||||||
|
got := Rank([]Item{{ID: 1, Text: "вечером", Status: StatusOpen, Due: &due, Created: now()}}, now())
|
||||||
|
if got[0].Reason != "сегодня" {
|
||||||
|
t.Errorf("reason = %q, want сегодня", got[0].Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankOverdueDaysAreCounted(t *testing.T) {
|
||||||
|
got := Rank([]Item{
|
||||||
|
{ID: 1, Text: "вчера", Status: StatusOpen, Due: at(2026, 7, 31), Created: now()},
|
||||||
|
{ID: 2, Text: "давно", Status: StatusOpen, Due: at(2026, 7, 20), Created: now()},
|
||||||
|
}, now())
|
||||||
|
if got[0].Text != "давно" {
|
||||||
|
t.Errorf("order = %v, want the later-overdue task first", texts(got))
|
||||||
|
}
|
||||||
|
if got[0].Reason != "просрочено на 12 дн." {
|
||||||
|
t.Errorf("reason = %q", got[0].Reason)
|
||||||
|
}
|
||||||
|
if got[1].Reason != "просрочено на день" {
|
||||||
|
t.Errorf("reason = %q", got[1].Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
|
||||||
|
ranked := Rank([]Item{
|
||||||
|
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()},
|
||||||
|
{ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now()},
|
||||||
|
{ID: 3, Text: "продлить страховку", Status: StatusCandidate, Due: at(2026, 7, 1), Created: now()},
|
||||||
|
}, now())
|
||||||
|
got := FormatRU(ranked)
|
||||||
|
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
|
||||||
|
t.Errorf("reply = %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "не подтвердил: продлить страховку") {
|
||||||
|
t.Errorf("candidate not named as unconfirmed: %q", got)
|
||||||
|
}
|
||||||
|
// A candidate's due date is Maven's reading of a mail, not his statement.
|
||||||
|
if strings.Contains(got, "продлить страховку (") {
|
||||||
|
t.Errorf("a candidate must be listed without a reason: %q", got)
|
||||||
|
}
|
||||||
|
// Persona: nothing masculine, no pet names, informal address only.
|
||||||
|
for _, bad := range []string{"рад ", "понял ", "милый", "дорогой", "вам", "ваши"} {
|
||||||
|
if strings.Contains(got, bad) {
|
||||||
|
t.Errorf("reply %q contains %q", got, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRUCapsTheSpokenList(t *testing.T) {
|
||||||
|
var items []Item
|
||||||
|
for i := 0; i < SpokenLimit+3; i++ {
|
||||||
|
items = append(items, Item{ID: int64(i), Text: "задача", Status: StatusOpen, Created: now()})
|
||||||
|
}
|
||||||
|
got := FormatRU(Rank(items, now()))
|
||||||
|
if !strings.Contains(got, "и ещё 3") {
|
||||||
|
t.Errorf("reply = %q, want the tail summarised", got)
|
||||||
|
}
|
||||||
|
if strings.Count(got, "задача") != SpokenLimit {
|
||||||
|
t.Errorf("reply = %q, want exactly %d named", got, SpokenLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRUEmpty(t *testing.T) {
|
||||||
|
if got := FormatRU(nil); got != "задач нет." {
|
||||||
|
t.Errorf("reply = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// Package zenmoney reads spending and income from ZenMoney's /v8/diff/ API
|
||||||
|
// (Vikunja #125).
|
||||||
|
//
|
||||||
|
// Trust boundary: ZenMoney, not Maven. They already hold his bank sessions —
|
||||||
|
// this package only reads back what they have, over a token that lives in the
|
||||||
|
// poller module and is never handed to core. Nothing here writes to ZenMoney;
|
||||||
|
// diff is called read-only (an empty change set in, a change set out).
|
||||||
|
//
|
||||||
|
// Two rules the code exists to enforce:
|
||||||
|
//
|
||||||
|
// - NEVER invent a number. Every figure in a Summary is a sum of amounts the
|
||||||
|
// API returned. A request that fails, or returns nothing, produces no
|
||||||
|
// summary and therefore no fact — silence, not a zero. A confidently wrong
|
||||||
|
// "ты потратил 0" is worse than no answer.
|
||||||
|
// - His money is never search input. This package holds no notes, no
|
||||||
|
// utterances and no persona text, and it has no path to the external search
|
||||||
|
// capability. The only thing that leaves the box here is the diff request
|
||||||
|
// itself, to the service that already has the data.
|
||||||
|
package zenmoney
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBaseURL — ZenMoney's API root. Overridable so the tests can point at
|
||||||
|
// an httptest server replaying a recorded response.
|
||||||
|
const DefaultBaseURL = "https://api.zenmoney.ru"
|
||||||
|
|
||||||
|
// Client is a ZenMoney diff reader. The token is held here, in the poller's
|
||||||
|
// address space; core never receives it and never learns it exists.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a client with a bounded HTTP timeout. An empty token is a
|
||||||
|
// programming error the caller must catch — the capability is off unless
|
||||||
|
// configured, so a client is only ever built when a token was supplied.
|
||||||
|
func New(token, baseURL string, timeout time.Duration) (*Client, error) {
|
||||||
|
if strings.TrimSpace(token) == "" {
|
||||||
|
return nil, fmt.Errorf("zenmoney: empty token")
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = DefaultBaseURL
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 20 * time.Second
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
Token: token,
|
||||||
|
HTTP: &http.Client{Timeout: timeout},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffRequest — the smallest body /v8/diff/ accepts. serverTimestamp is the
|
||||||
|
// incremental cursor: the server returns objects changed at or after it.
|
||||||
|
type diffRequest struct {
|
||||||
|
CurrentClientTimestamp int64 `json:"currentClientTimestamp"`
|
||||||
|
ServerTimestamp int64 `json:"serverTimestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffResponse — only the fields spending needs. ZenMoney returns a dozen more
|
||||||
|
// object types (tags, merchants, budgets, reminders); decoding them would mean
|
||||||
|
// holding more of his financial life in memory than the question needs.
|
||||||
|
type diffResponse struct {
|
||||||
|
ServerTimestamp int64 `json:"serverTimestamp"`
|
||||||
|
Instrument []instrument `json:"instrument"`
|
||||||
|
Transaction []transaction `json:"transaction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type instrument struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ShortTitle string `json:"shortTitle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type transaction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Date string `json:"date"` // "2026-07-15"
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
Income float64 `json:"income"`
|
||||||
|
Outcome float64 `json:"outcome"`
|
||||||
|
IncomeInstrument int64 `json:"incomeInstrument"`
|
||||||
|
OutcomeInstrmnt int64 `json:"outcomeInstrument"`
|
||||||
|
IncomeAccount string `json:"incomeAccount"`
|
||||||
|
OutcomeAccount string `json:"outcomeAccount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money — an amount in one currency. Kept as the currency's own short title
|
||||||
|
// ("RUB", "EUR") rather than converted: ZenMoney's rates are a snapshot, and
|
||||||
|
// converting would turn a figure he can check against his bank into one he
|
||||||
|
// cannot.
|
||||||
|
type Money struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary — what was spent and earned over a window, per currency, plus how
|
||||||
|
// many transactions it was computed from. Count is the honesty check: a
|
||||||
|
// summary built from zero transactions is not "you spent nothing", it is "there
|
||||||
|
// was nothing to read", and callers treat it as no answer.
|
||||||
|
type Summary struct {
|
||||||
|
From, To time.Time
|
||||||
|
Spent []Money `json:"spent"`
|
||||||
|
Earned []Money `json:"earned"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
// ServerTimestamp — the cursor the API returned, for the caller to log or
|
||||||
|
// carry. Not used as an incremental cursor for summaries; see Since.
|
||||||
|
ServerTimestamp int64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since returns the summary of transactions dated in [from, to).
|
||||||
|
//
|
||||||
|
// The diff cursor is set to `from` so the server only sends objects changed
|
||||||
|
// since then, which for a "this month" window is everything filed this month.
|
||||||
|
// The caveat, deliberately accepted: a transaction he EDITED this month but
|
||||||
|
// dated last month arrives too, and is then excluded by date — so editing old
|
||||||
|
// records cannot inflate this month's total. The reverse case (a transaction
|
||||||
|
// dated this month, filed and last changed before `from`) cannot exist.
|
||||||
|
func (c *Client) Since(ctx context.Context, from, to time.Time) (Summary, error) {
|
||||||
|
resp, err := c.diff(ctx, from.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
return summarize(resp, from, to), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) {
|
||||||
|
body, err := json.Marshal(diffRequest{
|
||||||
|
CurrentClientTimestamp: time.Now().Unix(),
|
||||||
|
ServerTimestamp: serverTimestamp,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return diffResponse{}, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v8/diff/", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return diffResponse{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
hc := c.HTTP
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
res, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return diffResponse{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 32<<20))
|
||||||
|
if err != nil {
|
||||||
|
return diffResponse{}, err
|
||||||
|
}
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
// The status only. The body of a failed diff can echo account data, and
|
||||||
|
// this string reaches the log.
|
||||||
|
return diffResponse{}, fmt.Errorf("zenmoney diff: %s", res.Status)
|
||||||
|
}
|
||||||
|
var out diffResponse
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return diffResponse{}, fmt.Errorf("zenmoney diff: decode: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarize sums the transactions dated inside the window.
|
||||||
|
//
|
||||||
|
// Excluded, in order: deleted rows (ZenMoney tombstones rather than removes),
|
||||||
|
// transfers and currency exchanges (income and outcome both non-zero — moving
|
||||||
|
// his own money between his own accounts is not spending), and anything dated
|
||||||
|
// outside the window.
|
||||||
|
func summarize(resp diffResponse, from, to time.Time) Summary {
|
||||||
|
cur := map[int64]string{}
|
||||||
|
for _, in := range resp.Instrument {
|
||||||
|
cur[in.ID] = in.ShortTitle
|
||||||
|
}
|
||||||
|
spent := map[string]float64{}
|
||||||
|
earned := map[string]float64{}
|
||||||
|
count := 0
|
||||||
|
for _, t := range resp.Transaction {
|
||||||
|
if t.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, err := time.ParseInLocation("2006-01-02", t.Date, from.Location())
|
||||||
|
if err != nil {
|
||||||
|
continue // an undated row is not a number we can place
|
||||||
|
}
|
||||||
|
if d.Before(from) || !d.Before(to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Income > 0 && t.Outcome > 0 {
|
||||||
|
continue // transfer / exchange
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case t.Outcome > 0:
|
||||||
|
spent[currency(cur, t.OutcomeInstrmnt)] += t.Outcome
|
||||||
|
count++
|
||||||
|
case t.Income > 0:
|
||||||
|
earned[currency(cur, t.IncomeInstrument)] += t.Income
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Summary{
|
||||||
|
From: from, To: to,
|
||||||
|
Spent: sortMoney(spent), Earned: sortMoney(earned),
|
||||||
|
Count: count, ServerTimestamp: resp.ServerTimestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currency names the instrument, or says it does not know. An unknown id keeps
|
||||||
|
// the amount rather than dropping it: a sum without a currency label is still
|
||||||
|
// his money, and silently discarding it would understate the total.
|
||||||
|
func currency(names map[int64]string, id int64) string {
|
||||||
|
if s := names[id]; s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortMoney gives the amounts a stable order (largest first) so the rendered
|
||||||
|
// string and the written fact do not churn between polls.
|
||||||
|
func sortMoney(m map[string]float64) []Money {
|
||||||
|
out := make([]Money, 0, len(m))
|
||||||
|
for c, a := range m {
|
||||||
|
out = append(out, Money{Currency: c, Amount: a})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Amount != out[j].Amount {
|
||||||
|
return out[i].Amount > out[j].Amount
|
||||||
|
}
|
||||||
|
return out[i].Currency < out[j].Currency
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty reports whether the summary rests on no transactions at all. Callers
|
||||||
|
// must treat an empty summary as "nothing to say", never as a zero: the
|
||||||
|
// difference between "he spent nothing" and "the read returned nothing" is the
|
||||||
|
// difference between an answer and an invented one.
|
||||||
|
func (s Summary) Empty() bool { return s.Count == 0 }
|
||||||
|
|
||||||
|
// MonthWindow — the first instant of now's month, and now's own day-end
|
||||||
|
// exclusive bound, in now's location. The window a "сколько я потратил в этом
|
||||||
|
// месяце?" question means.
|
||||||
|
func MonthWindow(now time.Time) (from, to time.Time) {
|
||||||
|
loc := now.Location()
|
||||||
|
from = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||||
|
to = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
|
||||||
|
return from, to
|
||||||
|
}
|
||||||
|
|
||||||
|
// DayWindow — today, in now's location.
|
||||||
|
func DayWindow(now time.Time) (from, to time.Time) {
|
||||||
|
loc := now.Location()
|
||||||
|
from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
|
return from, from.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package zenmoney
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixtureServer replays testdata/diff.json and records the request, so the
|
||||||
|
// tests can assert the wire contract (Bearer token, POST, /v8/diff/) without a
|
||||||
|
// ZenMoney account.
|
||||||
|
func fixtureServer(t *testing.T, got *diffRequest, auth *string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile("testdata/diff.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v8/diff/" {
|
||||||
|
t.Errorf("path = %s, want /v8/diff/", r.URL.Path)
|
||||||
|
}
|
||||||
|
if auth != nil {
|
||||||
|
*auth = r.Header.Get("Authorization")
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(got); err != nil {
|
||||||
|
t.Errorf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(body)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func aug(day int) time.Time { return time.Date(2026, 8, day, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func TestSinceSumsSpendingPerCurrency(t *testing.T) {
|
||||||
|
var req diffRequest
|
||||||
|
var auth string
|
||||||
|
srv := fixtureServer(t, &req, &auth)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := New("tok", srv.URL, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := c.Since(context.Background(), aug(1), aug(6))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if auth != "Bearer tok" {
|
||||||
|
t.Errorf("Authorization = %q", auth)
|
||||||
|
}
|
||||||
|
if req.ServerTimestamp != aug(1).Unix() {
|
||||||
|
t.Errorf("serverTimestamp = %d, want the window start", req.ServerTimestamp)
|
||||||
|
}
|
||||||
|
// 1500 + 249.5 RUB spent, 12 EUR spent, 3000 RUB in. The transfer (t4), the
|
||||||
|
// deleted row (t6) and July's salary (t3) are all excluded.
|
||||||
|
want := map[string]float64{"RUB": 1749.5, "EUR": 12}
|
||||||
|
if len(s.Spent) != 2 {
|
||||||
|
t.Fatalf("spent = %+v, want two currencies", s.Spent)
|
||||||
|
}
|
||||||
|
for _, m := range s.Spent {
|
||||||
|
if want[m.Currency] != m.Amount {
|
||||||
|
t.Errorf("spent %s = %v, want %v", m.Currency, m.Amount, want[m.Currency])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Earned) != 1 || s.Earned[0].Amount != 3000 || s.Earned[0].Currency != "RUB" {
|
||||||
|
t.Errorf("earned = %+v, want 3000 RUB (July's salary is outside the window)", s.Earned)
|
||||||
|
}
|
||||||
|
if s.Count != 4 {
|
||||||
|
t.Errorf("count = %d, want 4 counted transactions", s.Count)
|
||||||
|
}
|
||||||
|
// Largest first, so the fact value does not churn between polls.
|
||||||
|
if s.Spent[0].Currency != "RUB" {
|
||||||
|
t.Errorf("spent order = %+v, want the largest amount first", s.Spent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A window with nothing in it is NOT a zero. No transactions means no answer,
|
||||||
|
// and the caller must be able to tell the difference.
|
||||||
|
func TestSinceEmptyWindowIsNotAZero(t *testing.T) {
|
||||||
|
srv := fixtureServer(t, nil, nil)
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := New("tok", srv.URL, time.Second)
|
||||||
|
s, err := c.Since(context.Background(), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.Empty() {
|
||||||
|
t.Fatalf("summary = %+v, want empty", s)
|
||||||
|
}
|
||||||
|
if _, ok := s.Value(); ok {
|
||||||
|
t.Error("an empty summary must not produce a fact value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSinceReportsHTTPFailureWithoutTheBody(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, `{"account":"acc-card","secret":"leaky"}`, http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := New("tok", srv.URL, time.Second)
|
||||||
|
_, err := c.Since(context.Background(), aug(1), aug(6))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want an error on 401")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "acc-card") || strings.Contains(err.Error(), "leaky") {
|
||||||
|
t.Errorf("error %q echoes the response body — it reaches the log", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRequiresAToken(t *testing.T) {
|
||||||
|
if _, err := New(" ", "", 0); err == nil {
|
||||||
|
t.Error("want an error for an empty token — the capability is off unless configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonthAndDayWindows(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC)
|
||||||
|
from, to := MonthWindow(now)
|
||||||
|
if from != time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) {
|
||||||
|
t.Errorf("month window = %v..%v", from, to)
|
||||||
|
}
|
||||||
|
from, to = DayWindow(now)
|
||||||
|
if from != time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) {
|
||||||
|
t.Errorf("day window = %v..%v", from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactValueRoundTripAndFormat(t *testing.T) {
|
||||||
|
s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3}
|
||||||
|
raw, ok := s.Value()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("want a fact value")
|
||||||
|
}
|
||||||
|
v, err := ParseFactValue(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := v.FormatRU("в этом месяце")
|
||||||
|
if !strings.Contains(got, "1749.5 RUB") || !strings.Contains(got, "3000 RUB") {
|
||||||
|
t.Errorf("reply = %q, want the exact figures", got)
|
||||||
|
}
|
||||||
|
// Persona: informal, feminine, no commentary on his spending.
|
||||||
|
for _, bad := range []string{"вы", "ваш", "милый", "дорогой", "рад ", "слишком", "много"} {
|
||||||
|
if strings.Contains(got, bad) {
|
||||||
|
t.Errorf("reply %q contains %q", got, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "он ") {
|
||||||
|
t.Errorf("reply %q talks about him in the third person", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty fact value renders to nothing, so a caller cannot accidentally
|
||||||
|
// speak a zero.
|
||||||
|
func TestFormatRUEmptyRendersNothing(t *testing.T) {
|
||||||
|
if got := (FactValue{}).FormatRU("сегодня"); got != "" {
|
||||||
|
t.Errorf("reply = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatAmountKeepsTheTruth(t *testing.T) {
|
||||||
|
for in, want := range map[float64]string{1500: "1500", 249.5: "249.5", 0.99: "0.99", 1749.55: "1749.55"} {
|
||||||
|
if got := formatAmount(in); got != want {
|
||||||
|
t.Errorf("formatAmount(%v) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package zenmoney
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fact keys the poller writes, all under source "poll:zenmoney". Two windows,
|
||||||
|
// because they are the two questions he actually asks; a per-category
|
||||||
|
// breakdown would mean storing what he bought, and the store is not a ledger.
|
||||||
|
const (
|
||||||
|
KeySpentToday = "money_today"
|
||||||
|
KeySpentMonth = "money_month"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source — the provenance every money fact carries. The loop's rules trust
|
||||||
|
// source, and nothing in Maven has a rule on these keys: they are read when he
|
||||||
|
// asks, never a reason to speak. Maven is not a nag, least of all about money.
|
||||||
|
const Source = "poll:zenmoney"
|
||||||
|
|
||||||
|
// FactValue — the JSON stored in a money fact. A wire shape of its own rather
|
||||||
|
// than the Summary struct so From/To (which carry a timezone and a clock) stay
|
||||||
|
// out of the store; the key already says which window it is.
|
||||||
|
type FactValue struct {
|
||||||
|
Spent []Money `json:"spent"`
|
||||||
|
Earned []Money `json:"earned"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value encodes the summary for the facts table. Returns ok=false for an empty
|
||||||
|
// summary: no transactions read means no fact written, so that a failed or
|
||||||
|
// empty poll can never be recited back to him as a zero.
|
||||||
|
func (s Summary) Value() (string, bool) {
|
||||||
|
if s.Empty() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(FactValue{Spent: s.Spent, Earned: s.Earned, Count: s.Count})
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(b), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFactValue decodes a stored money fact.
|
||||||
|
func ParseFactValue(raw string) (FactValue, error) {
|
||||||
|
var v FactValue
|
||||||
|
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||||
|
return FactValue{}, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRU renders a money fact the way Maven says it — feminine, informal,
|
||||||
|
// and only about numbers that came from ZenMoney. window is the Russian phrase
|
||||||
|
// for the period ("сегодня", "в этом месяце").
|
||||||
|
//
|
||||||
|
// No commentary. She reports the figure and stops: an opinion about his
|
||||||
|
// spending is exactly the nagging Maven is not for.
|
||||||
|
func (v FactValue) FormatRU(window string) string {
|
||||||
|
if v.Count == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
if len(v.Spent) > 0 {
|
||||||
|
parts = append(parts, "потратил "+joinMoney(v.Spent))
|
||||||
|
}
|
||||||
|
if len(v.Earned) > 0 {
|
||||||
|
parts = append(parts, "получил "+joinMoney(v.Earned))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return window + " ты " + strings.Join(parts, ", ") + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinMoney(ms []Money) string {
|
||||||
|
parts := make([]string, 0, len(ms))
|
||||||
|
for _, m := range ms {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " и ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAmount — whole units when the amount is whole, two decimals otherwise.
|
||||||
|
// Never rounded to something prettier than the truth.
|
||||||
|
func formatAmount(a float64) string {
|
||||||
|
if a == float64(int64(a)) {
|
||||||
|
return fmt.Sprintf("%d", int64(a))
|
||||||
|
}
|
||||||
|
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", a), "0"), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleAfter — how old a money fact may be and still be worth reciting. The
|
||||||
|
// poller is off unless configured and can be down; answering with last week's
|
||||||
|
// total as if it were today's would be a lie by omission, so a stale fact is
|
||||||
|
// reported as stale.
|
||||||
|
const StaleAfter = 26 * time.Hour
|
||||||
Vendored
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"serverTimestamp": 1785312000,
|
||||||
|
"instrument": [
|
||||||
|
{"id": 2, "title": "Российский рубль", "shortTitle": "RUB", "symbol": "₽", "rate": 1},
|
||||||
|
{"id": 3, "title": "Евро", "shortTitle": "EUR", "symbol": "€", "rate": 100}
|
||||||
|
],
|
||||||
|
"account": [
|
||||||
|
{"id": "acc-card", "title": "карта", "instrument": 2},
|
||||||
|
{"id": "acc-cash", "title": "наличные", "instrument": 2},
|
||||||
|
{"id": "acc-eur", "title": "евро", "instrument": 3}
|
||||||
|
],
|
||||||
|
"transaction": [
|
||||||
|
{"id": "t1", "date": "2026-08-01", "changed": 1785300000, "income": 0, "outcome": 1500,
|
||||||
|
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
|
||||||
|
"payee": "пятёрочка", "deleted": false},
|
||||||
|
{"id": "t2", "date": "2026-08-01", "changed": 1785300001, "income": 0, "outcome": 249.5,
|
||||||
|
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
|
||||||
|
"payee": "метро", "deleted": false},
|
||||||
|
{"id": "t3", "date": "2026-07-20", "changed": 1785300002, "income": 120000, "outcome": 0,
|
||||||
|
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
|
||||||
|
"payee": "зарплата", "deleted": false},
|
||||||
|
{"id": "t4", "date": "2026-08-02", "changed": 1785300003, "income": 5000, "outcome": 5000,
|
||||||
|
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-cash", "outcomeAccount": "acc-card",
|
||||||
|
"payee": "", "comment": "снял наличные", "deleted": false},
|
||||||
|
{"id": "t5", "date": "2026-08-03", "changed": 1785300004, "income": 0, "outcome": 12,
|
||||||
|
"incomeInstrument": 3, "outcomeInstrument": 3, "incomeAccount": "acc-eur", "outcomeAccount": "acc-eur",
|
||||||
|
"payee": "hosting", "deleted": false},
|
||||||
|
{"id": "t6", "date": "2026-08-04", "changed": 1785300005, "income": 0, "outcome": 999,
|
||||||
|
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
|
||||||
|
"payee": "удалённая", "deleted": true},
|
||||||
|
{"id": "t7", "date": "2026-08-05", "changed": 1785300006, "income": 3000, "outcome": 0,
|
||||||
|
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
|
||||||
|
"payee": "возврат", "deleted": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user