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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 17:18:24 -07:00
1 parent e9a75a968a
commit 58a29b7339
23 files changed
+314 -37

No files matched your search

+2
View File
@@ -112,6 +112,8 @@ func (r *Registry) For(ctx context.Context, category string) (Searcher, error) {
}
return &igdb{r: r, id: c.IGDBClientID, secret: c.IGDBClientSecret}, nil
case "theater":
return &wikipedia{r: r, stage: true}, nil
case "custom":
return &wikipedia{r: r}, nil
}
return nil, fmt.Errorf("no provider for category %q", category)
+6
View File
@@ -15,6 +15,9 @@ import (
// specific production (Broadway, a tour, …) is chosen by the owner.
type wikipedia struct {
r *Registry
// stage limits results to musicals, plays and operas. Custom lists
// search all of Wikipedia.
stage bool
}
func (w *wikipedia) Name() string { return "wikipedia" }
@@ -23,6 +26,9 @@ func (w *wikipedia) Label() string { return "Wikipedia" }
const theaterTemplates = `hastemplate:"Infobox musical|Infobox play|Infobox opera"`
func (w *wikipedia) Search(ctx context.Context, query string) ([]Result, error) {
if !w.stage {
return w.search(ctx, query)
}
res, err := w.search(ctx, query+" "+theaterTemplates)
if err == nil && len(res) == 0 {
res, err = w.search(ctx, query)
+1 -1
View File
@@ -60,7 +60,7 @@ func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) {
s.notFound(w, r)
return
}
cat, _ := store.CategoryByKey(l.Category)
cat := listCategory(l)
provider := ""
if p, err := s.providers.For(r.Context(), l.Category); err == nil {
provider = p.Label()
+1
View File
@@ -610,6 +610,7 @@ var testQueries = map[string]string{
"song": "Bohemian Rhapsody",
"game": "Zelda",
"theater": "Hamilton",
"custom": "Chicago Bulls",
}
func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) {
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
)
// exportFormats maps ?format= values to file extension and content type.
var exportFormats = map[string]struct{ ext, contentType string }{
"csv": {"csv", "text/csv; charset=utf-8"},
"txt": {"txt", "text/plain; charset=utf-8"},
}
// handleExport downloads a list's titles in rank order, one per line, with
// nothing else: no ranks, header row or metadata.
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
format := r.URL.Query().Get("format")
if format == "" {
format = "txt"
}
f, ok := exportFormats[format]
if !ok {
http.Error(w, `format must be "csv" or "txt"`, http.StatusBadRequest)
return
}
l, ok := s.visibleList(w, r)
if !ok {
return
}
items, err := s.store.Items(r.Context(), l.ID, 0, 0)
if err != nil {
s.serverError(w, r, err)
return
}
titles := make([]string, len(items))
for i, it := range items {
titles[i] = it.Title
}
w.Header().Set("Content-Type", f.contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.%s"`, l.Slug, f.ext))
w.Header().Set("Cache-Control", "no-store")
if err := writeTitles(w, format, titles); err != nil {
s.log.Warn("export failed", "list", l.Slug, "err", err)
}
}
// flatten keeps one title per line even if a title contains a newline.
var flatten = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ")
func writeTitles(w io.Writer, format string, titles []string) error {
if format == "csv" {
// The BOM makes Excel read accented titles (Les Misérables) as UTF-8.
if _, err := io.WriteString(w, "\xef\xbb\xbf"); err != nil {
return err
}
cw := csv.NewWriter(w)
cw.UseCRLF = true
for _, t := range titles {
if err := cw.Write([]string{flatten.Replace(t)}); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
for _, t := range titles {
if _, err := io.WriteString(w, flatten.Replace(t)+"\n"); err != nil {
return err
}
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package server
import (
"strings"
"testing"
)
func TestWriteTitles(t *testing.T) {
titles := []string{"Hamilton", `Les Misérables`, `Sweeney Todd: The "Demon" Barber, of Fleet Street`, "Two\nLines"}
var txt strings.Builder
if err := writeTitles(&txt, "txt", titles); err != nil {
t.Fatal(err)
}
wantTxt := "Hamilton\nLes Misérables\nSweeney Todd: The \"Demon\" Barber, of Fleet Street\nTwo Lines\n"
if txt.String() != wantTxt {
t.Errorf("txt:\n%q\nwant\n%q", txt.String(), wantTxt)
}
var csv strings.Builder
if err := writeTitles(&csv, "csv", titles); err != nil {
t.Fatal(err)
}
wantCSV := "\xef\xbb\xbfHamilton\r\nLes Misérables\r\n\"Sweeney Todd: The \"\"Demon\"\" Barber, of Fleet Street\"\r\nTwo Lines\r\n"
if csv.String() != wantCSV {
t.Errorf("csv:\n%q\nwant\n%q", csv.String(), wantCSV)
}
}
+27 -9
View File
@@ -77,6 +77,14 @@ func itemView(it store.Item, showNotes bool) ItemView {
return v
}
// listCategory is the list's category with the list's own artwork shape
// applied.
func listCategory(l store.List) store.Category {
c, _ := store.CategoryByKey(l.Category)
c.Aspect = l.Aspect()
return c
}
type listSection struct {
List store.List
Category store.Category
@@ -110,7 +118,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
return
}
sec := listSection{List: l, More: l.ItemCount - len(items)}
sec.Category, _ = store.CategoryByKey(l.Category)
sec.Category = listCategory(l)
for _, it := range items {
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
}
@@ -138,26 +146,36 @@ type listPage struct {
PageNums []int
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l, err := s.store.ListBySlug(ctx, r.PathValue("slug"))
// visibleList loads the list named in the URL, treating hidden lists as
// missing for visitors. It writes the error response when ok is false.
func (s *Server) visibleList(w http.ResponseWriter, r *http.Request) (l store.List, ok bool) {
l, err := s.store.ListBySlug(r.Context(), r.PathValue("slug"))
if errors.Is(err, store.ErrNotFound) {
s.notFound(w, r)
return
return l, false
}
if err != nil {
s.serverError(w, r, err)
return
return l, false
}
if !l.Visible {
if _, ok := s.currentUser(r); !ok {
if _, loggedIn := s.currentUser(r); !loggedIn {
s.notFound(w, r)
return
return l, false
}
}
return l, true
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l, ok := s.visibleList(w, r)
if !ok {
return
}
site := s.site(ctx)
p := listPage{List: l, Page: 1}
p.Category, _ = store.CategoryByKey(l.Category)
p.Category = listCategory(l)
p.Pages = max(1, (l.ItemCount+listPageSize-1)/listPageSize)
if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 {
p.Page = min(n, p.Pages)
+2 -1
View File
@@ -98,6 +98,7 @@ func (s *Server) Handler() http.Handler {
// Public
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /l/{slug}", s.handleList)
mux.HandleFunc("GET /l/{slug}/export", s.handleExport)
mux.HandleFunc("GET /images/{name}", s.handleImage)
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
@@ -186,7 +187,7 @@ func (s *Server) recoverer(next http.Handler) http.Handler {
func (s *Server) securityHeaders(next http.Handler) http.Handler {
// Remote https images are allowed so search results can preview art
// before it's cached locally.
const csp = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; " +
const csp = "default-src 'self'; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; " +
"object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
+3
View File
@@ -19,6 +19,9 @@ var Categories = []Category{
{Key: "song", Label: "Songs", Singular: "Song", Icon: "music", Aspect: "square", SubtitleHint: "Artist"},
{Key: "game", Label: "Video Games", Singular: "Video Game", Icon: "gamepad", Aspect: "poster", SubtitleHint: "Developer"},
{Key: "theater", Label: "Theater", Singular: "Show", Icon: "masks", Aspect: "poster", SubtitleHint: "Creators"},
// Custom lists hold anything else (sports teams, drinks…); the owner
// names the list and adds entries by hand or from Wikipedia.
{Key: "custom", Label: "Custom", Singular: "Favorite", Icon: "star", Aspect: "square", SubtitleHint: "Subtitle"},
}
func CategoryByKey(key string) (Category, bool) {
+23 -7
View File
@@ -25,16 +25,17 @@ type List struct {
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.updated_at,
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.UpdatedAt, &l.ItemCount)
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
}
@@ -99,6 +100,9 @@ func (l *List) normalize() error {
if l.Layout != "rows" {
l.Layout = "grid"
}
if l.Art != "poster" && l.Art != "square" {
l.Art = ""
}
return nil
}
@@ -140,14 +144,17 @@ func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
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)
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?)`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout)
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
}
@@ -175,8 +182,8 @@ func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
return l, err
}
}
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, updated_at=unixepoch() WHERE id=?`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.ID)
_, 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
}
@@ -229,3 +236,12 @@ func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, erro
}
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
}
+2
View File
@@ -86,6 +86,8 @@ CREATE TABLE items (
);
CREATE INDEX items_list_rank ON items(list_id, rank);
`,
// Per-list artwork shape; '' uses the category's default.
`ALTER TABLE lists ADD COLUMN art TEXT NOT NULL DEFAULT '';`,
}
func (s *Store) migrate(ctx context.Context) error {
+26
View File
@@ -151,3 +151,29 @@ func BenchmarkMoveInLargeList(b *testing.B) {
s.MoveItem(ctx, first, 5000-(i%2)*4999)
}
}
func TestCustomList(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
l, err := s.CreateList(ctx, List{Title: "Favorite Sports Teams", Category: "custom", MaxItems: 10, Art: "poster"})
if err != nil {
t.Fatal(err)
}
if l.Slug != "favorite-sports-teams" {
t.Errorf("custom slug = %q, want it from the title", l.Slug)
}
if l.Aspect() != "poster" {
t.Errorf("aspect = %q, want the list's own choice", l.Aspect())
}
l.Art = "sideways"
if l, err = s.UpdateList(ctx, l); err != nil {
t.Fatal(err)
}
if l.Art != "" || l.Aspect() != "square" {
t.Errorf("invalid art should fall back to the category default, got art=%q aspect=%q", l.Art, l.Aspect())
}
m, _ := s.CreateList(ctx, List{Title: "Top 10 Movies", Category: "movie", MaxItems: 10})
if m.Aspect() != "poster" {
t.Errorf("movie aspect = %q", m.Aspect())
}
}