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 }