Files
canon/internal/store/lists.go
T
cdnutterandClaude Opus 5.5 58a29b7339 Add titles-only list export and Custom lists
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>
2026-09-24 17:18:24 -07:00

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
}