Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b2b96b957 |
@@ -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).
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ var querySources = []querySource{
|
|||||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||||
// Wednesday still reaches the calendar.
|
// Wednesday still reaches the calendar.
|
||||||
{"habits", (*reactiveHandler).queryHabits},
|
{"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},
|
||||||
{"calendar", (*reactiveHandler).queryCalendar},
|
{"calendar", (*reactiveHandler).queryCalendar},
|
||||||
{"weather", (*reactiveHandler).queryWeather},
|
{"weather", (*reactiveHandler).queryWeather},
|
||||||
{"embed", (*reactiveHandler).queryEmbed},
|
{"embed", (*reactiveHandler).queryEmbed},
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
text, ok := router.ParseTaskCapture(dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||||
|
Text: text,
|
||||||
|
Source: "tap:voice",
|
||||||
|
Status: store.TaskOpen, // he stated it himself — not a candidate
|
||||||
|
Ts: h.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: capture task: %v", err)
|
||||||
|
return "не получилось записать задачу.", true
|
||||||
|
}
|
||||||
|
if !resp.Created {
|
||||||
|
return "это уже в списке.", true
|
||||||
|
}
|
||||||
|
return "записала: " + text, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||||
|
//
|
||||||
|
// Reads the live set and recites it. Newest first, which is the order the store
|
||||||
|
// returns: this source has no opinion about which task matters more, and
|
||||||
|
// pretending otherwise would be a guess. Ranking is Vikunja #129.
|
||||||
|
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !router.IsTaskListQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
tasks, err := h.api.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list tasks: %v", err)
|
||||||
|
return "не получилось посмотреть задачи.", true
|
||||||
|
}
|
||||||
|
return formatTaskListRU(tasks), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTaskListRU renders the live task list the way Maven says it. Candidates
|
||||||
|
// are named as candidates — a task she pulled out of his mail is something she
|
||||||
|
// suggests, and saying it in the same breath as work he actually stated would
|
||||||
|
// put words in his mouth.
|
||||||
|
func formatTaskListRU(tasks []ipc.Task) string {
|
||||||
|
var open, cands []string
|
||||||
|
for _, t := range tasks {
|
||||||
|
switch t.Status {
|
||||||
|
case store.TaskCandidate:
|
||||||
|
cands = append(cands, t.Text)
|
||||||
|
default:
|
||||||
|
open = append(open, t.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(open) == 0 && len(cands) == 0 {
|
||||||
|
return "задач нет."
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if len(open) > 0 {
|
||||||
|
b.WriteString("в списке: ")
|
||||||
|
b.WriteString(strings.Join(open, "; "))
|
||||||
|
b.WriteString(".")
|
||||||
|
}
|
||||||
|
if len(cands) > 0 {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteString(" ")
|
||||||
|
}
|
||||||
|
b.WriteString("ещё я нашла, но ты не подтвердил: ")
|
||||||
|
b.WriteString(strings.Join(cands, "; "))
|
||||||
|
b.WriteString(".")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,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 +100,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 +174,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 +208,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":
|
||||||
@@ -408,6 +416,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)
|
||||||
})
|
})
|
||||||
@@ -744,6 +757,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 {
|
||||||
@@ -814,6 +829,145 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
var cands, open, resolved []taskRow
|
||||||
|
for _, t := range all {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
switch t.Status {
|
||||||
|
case "candidate":
|
||||||
|
cands = append(cands, row)
|
||||||
|
case "open":
|
||||||
|
open = append(open, row)
|
||||||
|
default:
|
||||||
|
resolved = append(resolved, 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()}
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{{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)">
|
||||||
|
<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>
|
||||||
|
{{if .Open}}<div class=scroll><table>
|
||||||
|
<tr><th>task</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>{{.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,166 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ 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,
|
MethodDayPlan: true,
|
||||||
@@ -427,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,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 {
|
||||||
@@ -700,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ const (
|
|||||||
MethodMorningStatus Method = "morning_status"
|
MethodMorningStatus Method = "morning_status"
|
||||||
MethodDayPlan Method = "day_plan"
|
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,128 @@
|
|||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) (string, 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 "", 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, ".!")
|
||||||
|
if rest == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,60 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTaskCapture(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
text string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"добавь в задачи купить молоко", "купить молоко", true},
|
||||||
|
{"Добавь в список дел: позвонить в банк", "позвонить в банк", true},
|
||||||
|
{"запиши задачу починить кран.", "починить кран", true},
|
||||||
|
{"новая задача — оплатить интернет", "оплатить интернет", true},
|
||||||
|
{"add a task buy milk", "buy milk", true},
|
||||||
|
// A marker with nothing after it files nothing.
|
||||||
|
{"добавь в задачи", "", false},
|
||||||
|
{"новая задача", "", false},
|
||||||
|
// Not a capture: he is talking, not filing.
|
||||||
|
{"надо бы поспать", "", false},
|
||||||
|
{"я не добавил молоко в список", "", false},
|
||||||
|
{"какие у меня задачи?", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
text, ok := ParseTaskCapture(c.in)
|
||||||
|
if ok != c.ok || text != c.text {
|
||||||
|
t.Errorf("ParseTaskCapture(%q) = (%q, %v), want (%q, %v)", c.in, text, ok, c.text, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user