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:
1 parent
e9a75a968a
commit
58a29b7339
23 files changed
+314
-37
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user