Capture tasks, with one intake seam mail can call later (#130)
A task is not a fact and not a note. A fact is a claim about the world that a correction supersedes; a note is something to recall by meaning. A task is work with a lifecycle, and the read that matters is "everything outstanding right now" — which over an append-only log would mean replaying history on every question. So: a tasks table, migration #14, statuses candidate/open/done/dropped that each move forward exactly once. Dedupe is on normalised text among LIVE rows only, via a partial unique index. That is the property the mail side needs: an extractor may call CaptureTask for every message it reads, as often as it likes, without growing the list — while a weekly errand is still capturable again once the last one is done. Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path (router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never "надо бы поспать"), the /tasks form, and the email reader from #246 when it exists. Mail-derived items set Source "email:<account>", Status "candidate" and Evidence to whatever makes the row reviewable; a candidate is inert until he confirms it on /tasks, and Maven names it as unconfirmed when she recites the list rather than putting words in his mouth. No new intent — the router enum is a contract with the relabelling prompt, so capture rides the note intent and the list rides a query source, both matched deterministically like the calendar and plan matchers already are. Nothing here speaks. No tick rule reads tasks; the list is answered when asked about, which is why /tasks POST is not step-up gated the way /tools and /routines are — a task write moves no boundary. Vikunja #130
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user