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.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
// 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
|
// The filter is exact — it keeps any key whose summary part still has a
|
||||||
// letter or a digit in it.
|
// 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
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
Reference in New Issue
Block a user