Files
bookhoard/internal/handlers/cards.go
T
John O'Keefe 188ae076ab refactor(utils): one PgTextString for the four pgtype.Text unwrappers
handlers textToString, router textToString, router getText, and utils
normText were four copies of the same NULL-to-empty unwrap (99 call
sites). One exported utils.PgTextString replaces them all; the private
normText (comparison normalizer) stays but is now the odd one out.

Harness fix found on the way: setupTestServer built a full wiring
literal for route registration and then RETURNED a second, sparser
literal that never copied AnnotationService — setup.AnnotationService
was always nil on the struct every test receives. Nobody noticed until
cross_library_move_test became the first caller (it previously used
the now-retired plain CreateMediaHighlight query); it now exercises
the real SaveHighlight path through the wired service.
2026-10-17 16:36:58 -04:00

185 lines
6.6 KiB
Go

package handlers
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"bookhoard/internal/database"
"bookhoard/internal/utils"
)
// BookInfo is THE canonical wire+template card shape: every surface that
// lists a book card as JSON (collections detail, series, preview,
// dashboard sections, search) or renders a templ card (BookCard in
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven
// keys and no others — pinned by docs/contract_pins.md.
//
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
// buildBookCard below, via the per-feed adapters. Direct BookInfo
// literals outside this file are a bug, not a style choice: before the
// builder, ten sites hand-rolled subsets of these fields, which is how
// badges vanished from the bookshelf and has_conflict stayed stamped
// false-forever on collections/series JSON.
type BookInfo struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
HasConflict bool `json:"has_conflict"`
// Media-type identifier for card badges/hover icons (A2c)
FormatGroup string `json:"format_group"`
// Resolved cover-badge glyph: "audiobook" | "comic" | "manga" | "".
// comic vs manga rides the LIBRARY type (format_group only knows
// comic_archive — manga_type is unreliable across sources).
Badge string `json:"badge"`
}
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
// (one small query per request; libraries are few).
//
// Call it ONCE per request and pass the map into the card adapters.
// Calling it inside a per-item loop was the old way — it re-ran
// ListLibraries for every card on the page.
func LibraryTypeMap(ctx context.Context, db *database.Queries) map[string]string {
m := map[string]string{}
libs, err := db.ListLibraries(ctx)
if err != nil {
return m
}
for _, l := range libs {
if l.ID.Valid {
m[uuid.UUID(l.ID.Bytes).String()] = l.TypeName
}
}
return m
}
// MediaBadgeKind resolves the badge glyph per media type from the row's
// format group and its library's type name. The library type is
// authoritative for the comic/manga split: manga volumes ship as
// fixed_layout scans (image PDFs) just as often as comic_archive, and
// manga_type is unreliable across sources (unknown/NULL on real rows),
// so anything in a manga library reads manga, anything in a comics
// library reads comic, and a stray comic_archive elsewhere still does.
func MediaBadgeKind(formatGroup, libraryTypeName string) string {
switch {
case formatGroup == "audiobook":
return "audiobook"
case libraryTypeName == "manga":
return "manga"
case formatGroup == "comic_archive" || libraryTypeName == "comics":
return "comic"
}
return ""
}
// buildBookCard is the single construction site for BookInfo. Feed
// adapters below normalize the sqlc row shapes into cardSource.
func buildBookCard(src cardSource) BookInfo {
return BookInfo{
MediaItemID: src.ID.String(),
Title: src.Title,
Author: src.Author,
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
FormatGroup: src.FormatGroup,
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
}
}
// HasConflict is deliberately absent here: conflict stamping is a
// per-user query (MarkActiveConflicts), done AFTER the cards are built
// by the surfaces that carry it. Builders stay user-free.
// cardSource is the neutral input to the builder core: whatever fields
// every card feed row provably has.
type cardSource struct {
ID uuid.UUID
Title string
Author string
LibraryID pgtype.UUID
CoverPath pgtype.Text
FormatGroup string
LibraryType string
}
// BookCardsFromMediaItems builds cards from full media rows
// (dashboard sections, smart-collection matches, series/tag books,
// preview matches). libTypes must come from ONE LibraryTypeMap call
// per request, not per row.
func BookCardsFromMediaItems(rows []database.MediaItems, libTypes map[string]string) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()],
}))
}
return cards
}
// BookCardsFromCollectionDashRows builds cards from the
// collection-items join (GetCollectionItemsForDashboard). The row's ID
// IS the media item id. The Excluded filter stays at call sites — only
// the two collection-detail surfaces exclude tombstones.
func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashboardRow, libTypes map[string]string) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()],
}))
}
return cards
}
// BookCardsFromCollectionItemRows builds cards from GetCollectionItems
// rows, whose ID is the collection-item id and whose MediaItemID is the
// book — the SSR all-libraries collection branch and the JSON
// no-library branch both feed from this shape.
func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libTypes map[string]string) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.MediaItemID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()],
}))
}
return cards
}
// BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter
// needs no LibraryTypeMap query at all.
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: r.LibraryTypeName,
}))
}
return cards
}