package store import ( "context" "database/sql" "encoding/json" "errors" "strings" ) type Item struct { ID int64 `json:"id"` ListID int64 `json:"listId"` Rank int `json:"rank"` Title string `json:"title"` Subtitle string `json:"subtitle"` Year string `json:"year"` Image string `json:"image"` // cached file name under /images/ ImageSource string `json:"imageSource"` // where the image came from CustomArt bool `json:"customArt"` // chosen by the owner, not the source Provider string `json:"provider"` ProviderID string `json:"providerId"` ExternalURL string `json:"externalUrl"` Description string `json:"description"` // from the source, editable Note string `json:"note"` // the owner's "why it's here" Extra map[string]string `json:"extra"` } const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, note, extra` func scanItem(row interface{ Scan(...any) error }) (Item, error) { var it Item var extra string err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource, &it.CustomArt, &it.Provider, &it.ProviderID, &it.ExternalURL, &it.Description, &it.Note, &extra) if errors.Is(err, sql.ErrNoRows) { return it, ErrNotFound } if err != nil { return it, err } it.Extra = map[string]string{} _ = json.Unmarshal([]byte(extra), &it.Extra) return it, nil } // Items returns a page of a list's items in rank order. limit <= 0 means all. func (s *Store) Items(ctx context.Context, listID int64, offset, limit int) ([]Item, error) { if limit <= 0 { limit = -1 } rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? ORDER BY rank LIMIT ? OFFSET ?`, listID, limit, offset) if err != nil { return nil, err } defer rows.Close() out := []Item{} for rows.Next() { it, err := scanItem(rows) if err != nil { return nil, err } out = append(out, it) } return out, rows.Err() } func (s *Store) ItemByID(ctx context.Context, id int64) (Item, error) { return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE id = ?`, id)) } // FindItemByProvider returns an item in the list that came from the same // provider entry, if any. func (s *Store) FindItemByProvider(ctx context.Context, listID int64, provider, providerID string) (Item, error) { if provider == "" || providerID == "" { return Item{}, ErrNotFound } return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? AND provider = ? AND provider_id = ? LIMIT 1`, listID, provider, providerID)) } func (it *Item) normalize() error { it.Title = strings.TrimSpace(it.Title) it.Subtitle = strings.TrimSpace(it.Subtitle) it.Year = strings.TrimSpace(it.Year) it.Note = strings.TrimSpace(it.Note) it.Description = strings.TrimSpace(it.Description) it.ExternalURL = strings.TrimSpace(it.ExternalURL) if it.Title == "" { return invalid("Title is required") } if len(it.Title) > 500 || len(it.Subtitle) > 500 || len(it.Year) > 20 || len(it.Note) > 10000 || len(it.Description) > 10000 || len(it.ExternalURL) > 2000 { return invalid("One of the fields is too long") } if it.ExternalURL != "" && !strings.HasPrefix(it.ExternalURL, "https://") && !strings.HasPrefix(it.ExternalURL, "http://") { return invalid("Links must start with http:// or https://") } clean := map[string]string{} for k, v := range it.Extra { if v = strings.TrimSpace(v); v != "" && len(k) <= 50 && len(v) <= 500 { clean[k] = v } } it.Extra = clean return nil } func extraJSON(m map[string]string) string { b, _ := json.Marshal(m) return string(b) } // AddItem inserts it into the list at rank (1-based); rank <= 0 or beyond the // end appends. Items at or below the rank shift down by one. func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) { if err := it.normalize(); err != nil { return it, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return it, err } defer tx.Rollback() var maxItems, count int err = tx.QueryRowContext(ctx, `SELECT max_items, (SELECT COUNT(*) FROM items WHERE list_id = lists.id) FROM lists WHERE id = ?`, it.ListID).Scan(&maxItems, &count) if errors.Is(err, sql.ErrNoRows) { return it, ErrNotFound } if err != nil { return it, err } if count >= maxItems { return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, maxItems) } if rank <= 0 || rank > count+1 { rank = count + 1 } if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil { return it, err } res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, note, extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.Provider, it.ProviderID, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra)) if err != nil { return it, err } if err := touchList(ctx, tx, it.ListID); err != nil { return it, err } if err := tx.Commit(); err != nil { return it, err } id, _ := res.LastInsertId() return s.ItemByID(ctx, id) } // UpdateItem saves everything except rank and list. func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) { if err := it.normalize(); err != nil { return it, err } res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, image_custom=?, external_url=?, description=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra), it.ID) if err != nil { return it, err } if n, _ := res.RowsAffected(); n == 0 { return it, ErrNotFound } cur, err := s.ItemByID(ctx, it.ID) if err == nil { _, err = s.db.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, cur.ListID) } return cur, err } // MoveItem moves an item to a new 1-based rank, shifting the items between. func (s *Store) MoveItem(ctx context.Context, id int64, newRank int) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var listID int64 var oldRank, count int err = tx.QueryRowContext(ctx, `SELECT list_id, rank, (SELECT COUNT(*) FROM items x WHERE x.list_id = items.list_id) FROM items WHERE id = ?`, id).Scan(&listID, &oldRank, &count) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } if err != nil { return err } if newRank < 1 { newRank = 1 } if newRank > count { newRank = count } if newRank == oldRank { return nil } if newRank < oldRank { _, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ? AND rank < ?`, listID, newRank, oldRank) } else { _, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ? AND rank <= ?`, listID, oldRank, newRank) } if err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`, newRank, id); err != nil { return err } if err := touchList(ctx, tx, listID); err != nil { return err } return tx.Commit() } func (s *Store) DeleteItem(ctx context.Context, id int64) (Item, error) { it, err := s.ItemByID(ctx, id) if err != nil { return it, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return it, err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil { return it, err } if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ?`, it.ListID, it.Rank); err != nil { return it, err } if err := touchList(ctx, tx, it.ListID); err != nil { return it, err } return it, tx.Commit() } // ImageInUse reports whether any item or setting still references a cached // image file. func (s *Store) ImageInUse(ctx context.Context, name string) (bool, error) { var n int err := s.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM items WHERE image = ?) + (SELECT COUNT(*) FROM settings WHERE value = ?)`, name, name).Scan(&n) return n > 0, err } // ListImages returns the image file names used by a list's items. func (s *Store) ListImages(ctx context.Context, listID int64) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT image FROM items WHERE list_id = ? AND image != ''`, listID) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out = append(out, n) } return out, rows.Err() } func touchList(ctx context.Context, tx *sql.Tx, listID int64) error { _, err := tx.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, listID) return err }