diff --git a/internal/store/listitems.go b/internal/store/listitems.go new file mode 100644 index 0000000..1f8cfd2 --- /dev/null +++ b/internal/store/listitems.go @@ -0,0 +1,194 @@ +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 +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 8a94f64..8a34ed2 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -219,6 +219,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 // event, and the old rows would otherwise be recited as extra meetings. // The filter is exact — it keeps any key whose summary part still has a // letter or a digit in it. + `CREATE TABLE IF NOT EXISTS list_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_ts INTEGER NOT NULL, + list TEXT NOT NULL, + item TEXT NOT NULL, + norm TEXT NOT NULL, + source TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')), + resolved_ts INTEGER + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open'; + CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`, + // #19 — standing lists (Vikunja #453). The fourth append-only shape, after + // facts, notes and tasks, and the reason it is its own table rather than a + // tag on tasks: milk on the shopping list is not work. Nothing prioritises + // it, nothing nudges about it, and the prioritiser must not start counting + // groceries as outstanding errands. + // + // The live-only unique index is the tasks one, per list: saying "молоко" + // twice before the shop keeps one row, saying it again next week after the + // last one was crossed off writes a new one. } // migrate applies every migration with a number greater than the DB's current