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"` // a ListStyles key, e.g. "podium" 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 _, ok := StyleByKey(l.Layout); !ok { l.Layout = DefaultStyle } 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 } // ListStyle is a way of presenting a ranked list. type ListStyle struct { Key string `json:"key"` Label string `json:"label"` Icon string `json:"icon"` Description string `json:"description"` } var ListStyles = []ListStyle{ {"podium", "Podium", "podium", "Top 3 on a medal podium, the rest behind “Show more”."}, {"spotlight", "Spotlight", "spotlight", "#1 as a big feature, the rest as a ranked list."}, {"grid", "Grid", "grid", "Every entry as artwork in a grid."}, {"rows", "List", "rows", "A compact numbered list with notes."}, } const DefaultStyle = "podium" func StyleByKey(key string) (ListStyle, bool) { for _, s := range ListStyles { if s.Key == key { return s, true } } return ListStyle{}, false }