diff --git a/internal/handlers/cards.go b/internal/handlers/cards.go new file mode 100644 index 00000000..32d1b779 --- /dev/null +++ b/internal/handlers/cards.go @@ -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 +} diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 87c80633..6e522e8b 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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"`