Export - GET /l/<list>/export?format=csv|txt downloads a list's titles in rank order, one per line, with no header or other fields (CSV gets a UTF-8 BOM for Excel) - Export menu in the list editor and, for the owner, on the public list page; hidden lists stay owner-only Custom lists - New "Custom" category for anything not built in (sports teams, drinks…): owner-named, slug from the title, entries added by hand or from an unfiltered Wikipedia search - Per-list artwork shape (square or tall), stored in a new lists.art column (migration 2) - Art can be uploaded while adding an entry, not only after Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
6.9 KiB
Go
248 lines
6.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
MaxListSize = 65535
|
|
RecommendedListSize = 100
|
|
SoftListSizeWarning = 1000
|
|
)
|
|
|
|
type List struct {
|
|
ID int64 `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
MaxItems int `json:"maxItems"`
|
|
Position int `json:"position"`
|
|
Visible bool `json:"visible"`
|
|
Layout string `json:"layout"` // "grid" or "rows"
|
|
Art string `json:"art"` // "", "poster" or "square"; "" = category default
|
|
ItemCount int `json:"itemCount"`
|
|
UpdatedAt int64 `json:"updatedAt"`
|
|
}
|
|
|
|
const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.art, l.updated_at,
|
|
(SELECT COUNT(*) FROM items i WHERE i.list_id = l.id)`
|
|
|
|
func scanList(row interface{ Scan(...any) error }) (List, error) {
|
|
var l List
|
|
err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.Art, &l.UpdatedAt, &l.ItemCount)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return l, ErrNotFound
|
|
}
|
|
return l, err
|
|
}
|
|
|
|
func (s *Store) Lists(ctx context.Context, includeHidden bool) ([]List, error) {
|
|
q := `SELECT ` + listColumns + ` FROM lists l`
|
|
if !includeHidden {
|
|
q += ` WHERE l.visible = 1`
|
|
}
|
|
q += ` ORDER BY l.position, l.id`
|
|
rows, err := s.db.QueryContext(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []List
|
|
for rows.Next() {
|
|
l, err := scanList(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ListByID(ctx context.Context, id int64) (List, error) {
|
|
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.id = ?`, id))
|
|
}
|
|
|
|
func (s *Store) ListBySlug(ctx context.Context, slug string) (List, error) {
|
|
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.slug = ?`, slug))
|
|
}
|
|
|
|
// ValidationError is returned for bad user input; its message is safe to
|
|
// show to the user.
|
|
type ValidationError struct{ Msg string }
|
|
|
|
func (e ValidationError) Error() string { return e.Msg }
|
|
|
|
func invalid(format string, args ...any) error {
|
|
return ValidationError{Msg: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
func (l *List) normalize() error {
|
|
l.Title = strings.TrimSpace(l.Title)
|
|
l.Description = strings.TrimSpace(l.Description)
|
|
if l.Title == "" {
|
|
return invalid("Title is required")
|
|
}
|
|
if len(l.Title) > 200 {
|
|
return invalid("Title is too long")
|
|
}
|
|
if _, ok := CategoryByKey(l.Category); !ok {
|
|
return invalid("Unknown category %q", l.Category)
|
|
}
|
|
if l.MaxItems < 1 || l.MaxItems > MaxListSize {
|
|
return invalid("List size must be between 1 and %d", MaxListSize)
|
|
}
|
|
if l.Layout != "rows" {
|
|
l.Layout = "grid"
|
|
}
|
|
if l.Art != "poster" && l.Art != "square" {
|
|
l.Art = ""
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
func slugify(s string) string {
|
|
s = slugStrip.ReplaceAllString(strings.ToLower(s), "-")
|
|
s = strings.Trim(s, "-")
|
|
if len(s) > 60 {
|
|
s = strings.Trim(s[:60], "-")
|
|
}
|
|
if s == "" {
|
|
s = "list"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Store) uniqueSlug(ctx context.Context, base string, exceptID int64) (string, error) {
|
|
slug := base
|
|
for i := 2; ; i++ {
|
|
var n int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lists WHERE slug = ? AND id != ?`, slug, exceptID).Scan(&n); err != nil {
|
|
return "", err
|
|
}
|
|
if n == 0 {
|
|
return slug, nil
|
|
}
|
|
slug = fmt.Sprintf("%s-%d", base, i)
|
|
}
|
|
}
|
|
|
|
func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
|
|
if err := l.normalize(); err != nil {
|
|
return l, err
|
|
}
|
|
// Default to the category name so the address stays sensible if the
|
|
// list's size (and so its "Top N" title) changes later.
|
|
base := slugify(l.Slug)
|
|
if strings.TrimSpace(l.Slug) == "" {
|
|
c, _ := CategoryByKey(l.Category)
|
|
base = slugify(c.Label)
|
|
if c.Key == "custom" {
|
|
base = slugify(l.Title)
|
|
}
|
|
}
|
|
slug, err := s.uniqueSlug(ctx, base, 0)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout, art)
|
|
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?, ?)`,
|
|
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.ListByID(ctx, id)
|
|
}
|
|
|
|
func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
|
|
if err := l.normalize(); err != nil {
|
|
return l, err
|
|
}
|
|
cur, err := s.ListByID(ctx, l.ID)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
if l.MaxItems < cur.ItemCount {
|
|
return l, invalid("This list already has %d items; remove some before shrinking it to %d", cur.ItemCount, l.MaxItems)
|
|
}
|
|
if l.Category != cur.Category && cur.ItemCount > 0 {
|
|
return l, invalid("Can't change the category of a list that has items")
|
|
}
|
|
slug := cur.Slug
|
|
if strings.TrimSpace(l.Slug) != "" && slugify(l.Slug) != cur.Slug {
|
|
if slug, err = s.uniqueSlug(ctx, slugify(l.Slug), l.ID); err != nil {
|
|
return l, err
|
|
}
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, art=?, updated_at=unixepoch() WHERE id=?`,
|
|
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art, l.ID)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
return s.ListByID(ctx, l.ID)
|
|
}
|
|
|
|
func (s *Store) DeleteList(ctx context.Context, id int64) error {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM lists WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReorderLists sets list positions to match the order of ids.
|
|
func (s *Store) ReorderLists(ctx context.Context, ids []int64) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for i, id := range ids {
|
|
if _, err := tx.ExecContext(ctx, `UPDATE lists SET position = ? WHERE id = ?`, i+1, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListCovers returns up to n image file names per list, from the top ranks.
|
|
func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT list_id, image FROM (
|
|
SELECT list_id, image, ROW_NUMBER() OVER (PARTITION BY list_id ORDER BY rank) AS rn
|
|
FROM items WHERE image != '') WHERE rn <= ? ORDER BY list_id, rn`, n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int64][]string{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var img string
|
|
if err := rows.Scan(&id, &img); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = append(out[id], "/images/"+img)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Aspect is the list's artwork shape: its own choice, or the category's.
|
|
func (l List) Aspect() string {
|
|
if l.Art != "" {
|
|
return l.Art
|
|
}
|
|
c, _ := CategoryByKey(l.Category)
|
|
return c.Aspect
|
|
}
|