Files
Maven/internal/store/listitems.go
T
claude 5bd303788b store: add list_items, the fourth append-only shape (V-453)
A list is a standing set of short strings under a tag. Not a task, because
milk is not work and the prioritiser must not count it as an errand; not a
fact, because it claims nothing. Nothing predicates over it, so two people
adding to the same list at once costs nothing.

Migration #19, plus AddListItem, ListItems, SetListItemStatus and ClearList.
The live-only unique index is the tasks one, per list: молоко twice before
the shop is one row, молоко again after it was crossed off is a new one.
2026-08-04 04:39:24 +04:00

195 lines
6.1 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// List items — the fourth append-only shape (Vikunja #453).
//
// A list is a standing set of short strings under a tag: покупки, аптека,
// хозяйство. It is not work and it is not a claim about the world, which is
// why it is neither a task nor a fact. Nothing here is prioritised, nothing
// nudges about it, and the digestion worker does not read it. The only two
// things a list does are grow and shrink.
//
// The consequence that made it worth a table: because no predicate touches a
// list item, several people adding to the same list at once cost nothing. There
// is no ranking to disagree about and no lifecycle beyond crossed-off.
const (
// ListItemOpen — on the list.
ListItemOpen = "open"
// ListItemDone — bought, taken, crossed off.
ListItemDone = "done"
// ListItemDropped — removed without being got.
ListItemDropped = "dropped"
)
// DefaultList — the list a capture lands on when he names none. Almost every
// spoken list item is groceries, and asking "в какой список?" for the common
// case would be a nag.
const DefaultList = "покупки"
// ListItem — one line on one list.
type ListItem struct {
ID int64
CreatedTs time.Time
List string
Item string
Source string
Status string
ResolvedTs *time.Time
}
var (
ErrListItemNotFound = errors.New("store: list item not found")
ErrListItemEmpty = errors.New("store: list item is empty")
ErrListItemStatus = errors.New("store: invalid list item status")
)
// NormalizeListName folds a list tag to its dedupe form. Lists are named out
// loud, so "Покупки" and "покупки " are the same list.
func NormalizeListName(s string) string {
n := NormalizeTaskText(s)
if n == "" {
return DefaultList
}
return n
}
// AddListItem puts an item on a list, or returns the existing row when the same
// item is already on it. Created says which happened, so the caller can say
// "уже есть" instead of pretending it wrote something.
func (s *Store) AddListItem(ctx context.Context, li ListItem) (CaptureResult, error) {
item := strings.TrimSpace(li.Item)
if item == "" {
return CaptureResult{}, ErrListItemEmpty
}
list := NormalizeListName(li.List)
norm := NormalizeTaskText(item)
created := li.CreatedTs
if created.IsZero() {
created = time.Now()
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO list_items (created_ts, list, item, norm, source, status)
VALUES (?,?,?,?,?,?)
ON CONFLICT DO NOTHING`,
created.UnixMilli(), list, item, norm, li.Source, ListItemOpen)
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: rows affected: %w", err)
}
if n > 0 {
id, err := res.LastInsertId()
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: last insert id: %w", err)
}
return CaptureResult{ID: id, Created: true}, nil
}
var id int64
err = s.db.QueryRowContext(ctx,
`SELECT id FROM list_items WHERE list = ? AND norm = ? AND status = ?`,
list, norm, ListItemOpen).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return CaptureResult{}, ErrListItemNotFound
}
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: lookup: %w", err)
}
return CaptureResult{ID: id}, nil
}
// ListItems reads one list in the order it was added. An empty status reads the
// open items, which is what reading the list aloud means.
func (s *Store) ListItems(ctx context.Context, list, status string) ([]ListItem, error) {
if status == "" {
status = ListItemOpen
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, created_ts, list, item, source, status, resolved_ts
FROM list_items WHERE list = ? AND status = ?
ORDER BY created_ts, id`,
NormalizeListName(list), status)
if err != nil {
return nil, fmt.Errorf("list items: %w", err)
}
defer rows.Close()
var out []ListItem
for rows.Next() {
var (
li ListItem
created int64
resolved sql.NullInt64
)
if err := rows.Scan(&li.ID, &created, &li.List, &li.Item, &li.Source, &li.Status, &resolved); err != nil {
return nil, fmt.Errorf("list items: scan: %w", err)
}
li.CreatedTs = time.UnixMilli(created)
if resolved.Valid {
t := time.UnixMilli(resolved.Int64)
li.ResolvedTs = &t
}
out = append(out, li)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list items: %w", err)
}
return out, nil
}
// SetListItemStatus crosses an item off, or removes it. Moving an item that is
// already resolved is not an error — crossing off twice is the same list.
func (s *Store) SetListItemStatus(ctx context.Context, id int64, status string, at time.Time) error {
if status != ListItemOpen && status != ListItemDone && status != ListItemDropped {
return fmt.Errorf("%w: %q", ErrListItemStatus, status)
}
var resolved sql.NullInt64
if status != ListItemOpen {
if at.IsZero() {
at = time.Now()
}
resolved = sql.NullInt64{Int64: at.UnixMilli(), Valid: true}
}
res, err := s.db.ExecContext(ctx,
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE id = ?`,
status, resolved, id)
if err != nil {
return fmt.Errorf("set list item status: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set list item status: rows affected: %w", err)
}
if n == 0 {
return ErrListItemNotFound
}
return nil
}
// ClearList crosses off every open item on a list and reports how many. This is
// "всё купил", which is one sentence and must not become one turn per item.
func (s *Store) ClearList(ctx context.Context, list string, at time.Time) (int, error) {
if at.IsZero() {
at = time.Now()
}
res, err := s.db.ExecContext(ctx,
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE list = ? AND status = ?`,
ListItemDone, at.UnixMilli(), NormalizeListName(list), ListItemOpen)
if err != nil {
return 0, fmt.Errorf("clear list: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("clear list: rows affected: %w", err)
}
return int(n), nil
}