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:" 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. // ExternalID is the identity of the thing this task was derived FROM — a // message id plus the extracted span, for a source that re-reads the same // immutable text forever. Empty for anything he stated himself. // // ResolvedBy names the caller that moved the task to its terminal state, in the // source vocabulary. Empty while the task is live. type Task struct { ID int64 CreatedTs time.Time Text string Source string Evidence string ExternalID string Status string Due *time.Time Weight int ResolvedTs *time.Time ResolvedBy string } // CaptureResult — what CaptureTask did. Created is a new row. Promoted is an // existing candidate this capture turned into open work: he stated out loud a // task Maven had only proposed, which is a confirmation, and the caller says so // instead of "уже в списке". type CaptureResult struct { ID int64 Created bool Promoted bool } 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} // derivedSourcePrefixes — provenance that means "Maven read this somewhere", // as opposed to "he said it". A task from one of these is a candidate and // nothing else; see CaptureTask. var derivedSourcePrefixes = []string{"email:"} // IsDerivedSource reports whether a task source means Maven inferred the task // from something she read rather than being told it. func IsDerivedSource(source string) bool { for _, p := range derivedSourcePrefixes { if strings.HasPrefix(source, p) { return true } } return false } // CaptureTask inserts a task, or returns the existing one when the same work is // already there. The result says which happened, so a caller can tell the owner // "уже в списке" instead of pretending it wrote something. // // Two dedupe keys, because voice and mail have different intake semantics: // // - ExternalID, unique over EVERY row whatever its status. A source that // re-reads the same immutable text forever must never resurrect work he has // already finished. 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. // - The normalised text among LIVE rows only (the partial unique index in // migration #14), for anything with no external identity. A weekly errand // captured again once the last one is done must produce a new row, because // him saying it again IS the recurrence signal. // // A capture with Status open over an existing candidate PROMOTES it. Stating // the work out loud is a confirmation, and leaving it a candidate would have // Maven read it back as something he never confirmed. // // A derived source may only ever capture a candidate. The doc on the intake // seam said "must NOT set Status open"; this is where that stops being an // honour system, so a compromised reader cannot file work he never reviewed. func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error) { text := strings.TrimSpace(t.Text) if text == "" { return CaptureResult{}, 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 CaptureResult{}, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status) } if status == TaskOpen && IsDerivedSource(t.Source) { return CaptureResult{}, fmt.Errorf("%w: derived source %q may only capture a candidate", ErrTaskStatus, t.Source) } 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} } var ext sql.NullString if e := strings.TrimSpace(t.ExternalID); e != "" { ext = sql.NullString{String: e, Valid: true} } // Untargeted DO NOTHING: either unique index may be the one that fires, and // the lookup below sorts out which. res, err := s.db.ExecContext(ctx, `INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight) if err != nil { return CaptureResult{}, fmt.Errorf("capture task: %w", err) } if n, err := res.RowsAffected(); err != nil { return CaptureResult{}, fmt.Errorf("capture task: rows affected: %w", err) } else if n > 0 { id, err := res.LastInsertId() if err != nil { return CaptureResult{}, fmt.Errorf("capture task: last insert id: %w", err) } return CaptureResult{ID: id, Created: true}, nil } // Something already holds one of the keys. External identity first: that // row may be resolved, in which case the answer is "already handled", not a // new task. var existing Task if ext.Valid { existing, err = s.lookupTaskByExternalID(ctx, ext.String) if err != nil && !errors.Is(err, ErrTaskNotFound) { return CaptureResult{}, err } } if existing.ID == 0 { existing, err = s.lookupLiveTaskByNorm(ctx, norm) if err != nil { return CaptureResult{}, err } } if status == TaskOpen && existing.Status == TaskCandidate { if err := s.SetTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source); err != nil { return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err) } return CaptureResult{ID: existing.ID, Promoted: true}, nil } return CaptureResult{ID: existing.ID}, nil } // lookupTaskByExternalID finds a task by the identity of what it was derived // from, in ANY status. Resolved rows count: the whole point of the key is that // re-reading the mail that produced a finished task produces nothing. func (s *Store) lookupTaskByExternalID(ctx context.Context, ext string) (Task, error) { row := s.db.QueryRowContext(ctx, taskSelect+` WHERE ext_id = ?`, ext) t, err := scanTask(row) if errors.Is(err, sql.ErrNoRows) { return Task{}, ErrTaskNotFound } if err != nil { return Task{}, fmt.Errorf("lookup task by external id: %w", err) } return t, 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, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by 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 } // MaxTaskRows — the hard bound on one ListTasks read. The live set is a list a // person keeps by hand and never approaches this; the resolved set grows for as // long as the box runs, and an unbounded read of it is a page that gets slower // every month. Newest first, so the bound drops the oldest finished work. const MaxTaskRows = 500 // ListTasks returns tasks in one status, newest first, at most MaxTaskRows of // them. 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 += fmt.Sprintf(` ORDER BY created_ts DESC, id DESC LIMIT %d`, MaxTaskRows) 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 NORM dedupe key, which is the point: the work can recur // when he says it again. It does not free an external identity — see // CaptureTask for why a re-read mailbox must not resurrect finished work. // // by names the caller making the move, in the source vocabulary ("tap:web", // "tap:voice"). It is recorded on the row, so a task that turns up resolved // says what resolved it. func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) 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 = ?, resolved_by = ? WHERE id = ? AND status IN (?` + strings.Repeat(",?", len(from)-1) + `)` args := []any{status, resolved, by, 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.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy); err != nil { return Task{}, err } t.CreatedTs = time.UnixMilli(created).UTC() t.Due = millisToTime(due) t.ResolvedTs = millisToTime(resolved) return t, nil }