5bd303788b
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.
195 lines
6.1 KiB
Go
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
|
|
}
|