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:
John O'Keefe committed 2026-10-17 14:00:34 -04:00
1 parent de9e66636a
commit 1ce1b79489
2 files changed
+184 -50

No files matched your search

-50
View File
@@ -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"`