A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
8.6 KiB
Go
269 lines
8.6 KiB
Go
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
|
|
Provider string `json:"provider"`
|
|
ProviderID string `json:"providerId"`
|
|
ExternalURL string `json:"externalUrl"`
|
|
Note string `json:"note"`
|
|
Extra map[string]string `json:"extra"`
|
|
}
|
|
|
|
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, 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.Provider, &it.ProviderID, &it.ExternalURL, &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.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.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, provider, provider_id, external_url, note, extra)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.Provider, it.ProviderID, it.ExternalURL, 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=?, external_url=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
|
|
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, 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
|
|
}
|