refactor(cards): H2 foundation — cards.go, the one BookInfo construction site
BookInfo, LibraryTypeMap, and MediaBadgeKind move verbatim out of
collections.go into handlers/cards.go (the card package surface, same
package: no import-graph or templ changes), joined by the H2 builder:
- buildBookCard (unexported): the ONLY place BookInfo is constructed,
fed by cardSource; cover URL via ResolveMediaURL, badge via
MediaBadgeKind — both inside the builder, not per call site.
- Four exported adapters normalize the four real feed shapes:
database.MediaItems (sections/smart/series/tags/preview),
GetCollectionItemsForDashboardRow (collection dashboard branch),
GetCollectionItemsRow (collection-items joins — MediaItemID field),
SearchMediaItemsUnifiedRow (badge rides its LibraryTypeName column,
so no LibraryTypeMap query at all).
- Adapters return non-nil empty slices, which retires the per-site
[]BookInfo{} empty-seed assignments when call sites migrate.
No call site changes in this commit — the 20 literals and 5 seeds are
migrated one surface per commit next, each with the H0 pin suite.
Verified: build, vet (only the two pre-existing flagged saveCoverImage
warnings), and 14/14 contract pins — the key freeze is byte-identical.
This commit is contained in:
1 parent
de9e66636a
commit
1ce1b79489
2 files changed
+184
-50
No files matched your search
@@ -0,0 +1,184 @@
|
||||
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: textToString(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: textToString(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: textToString(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: textToString(r.Author),
|
||||
LibraryID: r.LibraryID,
|
||||
CoverPath: r.CoverImagePath,
|
||||
FormatGroup: r.FormatGroup,
|
||||
LibraryType: r.LibraryTypeName,
|
||||
}))
|
||||
}
|
||||
return cards
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -70,55 +69,6 @@ type UpdateDeviceMappingRequest struct {
|
||||
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
||||
}
|
||||
|
||||
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).
|
||||
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 ""
|
||||
}
|
||||
|
||||
type SectionData struct {
|
||||
ID string `json:"id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
|
||||
Reference in new issue
Block a user