feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint

Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.

- Nine new filter params on SearchMediaItemsUnified: story_arc,
  imprint, edition (scalar-fuzzy), age_rating, format_group,
  manga_type, reading_direction (exact), explicit, abridged
  (presence-bound bools). Comma = OR-within multi-select on every
  text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
  genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
  pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
  > empty (unknown-only stays tags-only). Type-words (Manga, Comics,
  Graphic Novel) join the demote list. Both 'adventure' and 'action &
  adventure' are shelves — provider data uses both and the verified
  traces pin them.
- Language filter UX: comma elements normalize through the ingest
  language map, so display names ('English') match rows stored as ISO
  codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
  driven value+count listing (14 static per-column queries, family-lens
  join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
  403 on the search endpoints (was a silent empty set) — JSON branch
  and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
  search JSON endpoint and the HTMX grid partial; the HTML branch
  previously dropped publisher_filter and narrator_filter silently.

Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
This commit is contained in:
John O'Keefe committed 2026-10-19 18:59:23 -04:00
1 parent 7537063cb7
commit 7e586df48a
13 files changed
+2297 -283

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
package services
// Facet registry (faceted-navigation plan, Part 1 Batch A).
//
// The single declarative source of truth for bookshelf filter facets.
// sqlc has NO dynamic WHERE — each registry entry corresponds to an
// explicit `sqlc.narg('<param>_filter')` block in
// `SearchMediaItemsUnified` (internal/database/queries/queries.sql);
// the drift-guard test (facet_registry_test.go) fails the build when an
// entry's block is missing. Adding a facet = one registry entry + one
// narg block + one handler line (+ one templ call in Batch B).
//
// Wire semantics: the query param is `<Param>_filter`; a comma-joined
// value is multi-select (OR within the facet, AND across facets) for
// every text kind — the SQL splits via string_to_array. Bools are
// presence-bound: absent → NULL (no filtering), "true"/"false" → bound.
import "strings"
// FacetKind classifies the media_items column shape a facet filters on.
type FacetKind string
const (
FacetArray FacetKind = "array" // TEXT[] column, per-element match
FacetScalar FacetKind = "scalar" // varchar column
FacetBool FacetKind = "bool" // bool column
FacetRange FacetKind = "range" // int min/max pair (year_min/year_max)
)
// FacetMatch classifies how values are compared within the kind's SQL
// shape.
type FacetMatch string
const (
MatchPerElementFuzzy FacetMatch = "per_element_fuzzy" // word_similarity per array element
MatchScalarFuzzy FacetMatch = "scalar_fuzzy" // word_similarity against the column
MatchExact FacetMatch = "exact" // = / ANY() / bool equality
)
// Facet is one registry entry.
type Facet struct {
Param string // "publisher" → ?publisher_filter= (see WireParam)
// Wire is an optional full query-param override for facets whose
// wire name predates the _filter convention (has_cover). Existing
// facets migrate in with their frozen wire names — behavior does
// not change.
Wire string
Kind FacetKind
Match FacetMatch
// Column is the media_items column(s) the facet filters (documentation).
Column string
// LibraryTypes limits the facet to those library_types.name values
// (e.g. manga_type only on "manga"). Empty/nil = every library type.
// The All-Libraries view shows the UNION of all facets — matching is
// type-partitioned by the data, so cross-type filtering is correct
// by construction (plan Batch D decision).
LibraryTypes []string
}
// facetRegistry — the inventory. Order is display order for the filter
// panel (Batch B); the existing facets come first (they shipped
// 646477af..7537063c and their wire behavior is frozen).
var facetRegistry = []Facet{
{Param: "author", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "authors"},
{Param: "publisher", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "publishers"},
{Param: "narrator", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "narrators", LibraryTypes: []string{"audiobooks"}},
{Param: "tags", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "tags"},
{Param: "series", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "series"},
{Param: "genre", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "genre"},
{Param: "language", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "language"},
{Param: "year", Kind: FacetRange, Match: MatchExact, Column: "date_published / copyright_year"},
{Param: "has_cover", Wire: "has_cover", Kind: FacetBool, Match: MatchExact, Column: "cover_image_path"},
// Batch A additions:
{Param: "story_arc", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "story_arc"},
{Param: "imprint", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "imprint"},
{Param: "edition", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "edition"},
{Param: "age_rating", Kind: FacetScalar, Match: MatchExact, Column: "age_rating"},
{Param: "format_group", Kind: FacetScalar, Match: MatchExact, Column: "format_group"},
{Param: "manga_type", Kind: FacetScalar, Match: MatchExact, Column: "manga_type", LibraryTypes: []string{"manga"}},
{Param: "reading_direction", Kind: FacetScalar, Match: MatchExact, Column: "reading_direction", LibraryTypes: []string{"manga", "comics"}},
{Param: "explicit", Kind: FacetBool, Match: MatchExact, Column: "explicit"},
{Param: "abridged", Kind: FacetBool, Match: MatchExact, Column: "abridged"},
}
// Facets returns a copy of the registry.
func Facets() []Facet {
out := make([]Facet, len(facetRegistry))
copy(out, facetRegistry)
return out
}
// WireParam is the query-param name for the facet: the Wire override
// when set, else `<Param>_filter`.
func (f Facet) WireParam() string {
if f.Wire != "" {
return f.Wire
}
return f.Param + "_filter"
}
// SplitFacetValues splits a wire value into its OR-within alternatives.
// Shared by the SQL semantics (string_to_array) and later by the
// facet-count endpoint so display and matching can never disagree.
func SplitFacetValues(wireValue string) []string {
if wireValue == "" {
return nil
}
parts := strings.Split(wireValue, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// FacetByParam finds a registry entry by its base param name (nil when
// absent).
func FacetByParam(param string) *Facet {
for i := range facetRegistry {
if facetRegistry[i].Param == param {
f := facetRegistry[i]
return &f
}
}
return nil
}
// FacetsForLibraryType returns the facets that RENDER for a view. An
// empty libraryTypeName is the All-Libraries view → the UNION of every
// registered facet (plan Batch D: no special SQL — matching is
// type-partitioned by the data). A specific type gets facets with no
// type restriction plus those listing it.
func FacetsForLibraryType(libraryTypeName string) []Facet {
if libraryTypeName == "" {
return Facets()
}
var out []Facet
for _, f := range facetRegistry {
if len(f.LibraryTypes) == 0 {
out = append(out, f)
continue
}
for _, t := range f.LibraryTypes {
if t == libraryTypeName {
out = append(out, f)
break
}
}
}
return out
}
+173
View File
@@ -0,0 +1,173 @@
package services
import (
"reflect"
"strings"
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// The generated SQL constant for the unified search query. The
// drift-guard test asserts every registry entry's narg block exists in
// it — sqlc has no dynamic WHERE, so registry ↔ SQL agreement is
// enforced here instead (plan: "one registry entry + one narg block").
func TestFacetRegistryShape(t *testing.T) {
seen := map[string]bool{}
knownLibraryTypes := map[string]bool{
"ebooks": true, "comics": true, "manga": true, "audiobooks": true,
}
for _, f := range facetRegistry {
if f.Param == "" {
t.Fatalf("facet with empty Param: %+v", f)
}
wire := f.WireParam()
if seen[wire] {
t.Errorf("duplicate wire param %q", wire)
}
seen[wire] = true
if f.Column == "" {
t.Errorf("%s: empty Column", wire)
}
// Kind ↔ Match consistency.
switch f.Kind {
case FacetArray:
if f.Match != MatchPerElementFuzzy {
t.Errorf("%s: array facets are per-element fuzzy, got %s", wire, f.Match)
}
case FacetScalar:
if f.Match != MatchScalarFuzzy && f.Match != MatchExact {
t.Errorf("%s: scalar facets are scalar-fuzzy or exact, got %s", wire, f.Match)
}
case FacetBool:
if f.Match != MatchExact {
t.Errorf("%s: bool facets are exact, got %s", wire, f.Match)
}
case FacetRange:
if f.Match != MatchExact {
t.Errorf("%s: range facets are exact, got %s", wire, f.Match)
}
default:
t.Errorf("%s: unknown kind %q", wire, f.Kind)
}
for _, lt := range f.LibraryTypes {
if !knownLibraryTypes[lt] {
t.Errorf("%s: unknown library type %q (seed names: ebooks, comics, manga, audiobooks)", wire, lt)
}
}
}
}
func TestFacetRegistrySQLDrift(t *testing.T) {
// sqlc substitutes sqlc.narg(...) with ? placeholders in the emitted
// const, so the drift guard reflects over the GENERATED params
// struct instead: a narg block exists in the query iff the struct
// has its field. This checks the artifact the binary actually runs.
paramsType := reflect.TypeOf(database.SearchMediaItemsUnifiedParams{})
dbTags := map[string]reflect.Type{}
for i := 0; i < paramsType.NumField(); i++ {
tag := strings.Split(paramsType.Field(i).Tag.Get("db"), ",")[0]
dbTags[tag] = paramsType.Field(i).Type
}
boolType := reflect.TypeOf(pgtype.Bool{})
for _, f := range facetRegistry {
wire := f.WireParam()
switch f.Kind {
case FacetRange:
// The year range binds the year_min/year_max pair.
if _, ok := dbTags["year_min"]; !ok {
t.Error("year range facets must bind year_min in SearchMediaItemsUnified")
}
if _, ok := dbTags["year_max"]; !ok {
t.Error("year range facets must bind year_max in SearchMediaItemsUnified")
}
case FacetBool:
ft, ok := dbTags[wire]
if !ok {
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
continue
}
if ft != boolType {
t.Errorf("%s: bool facet must generate pgtype.Bool (the ::bool cast), got %s", wire, ft)
}
default:
if _, ok := dbTags[wire]; !ok {
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
}
}
}
// The Batch A additions specifically (pin the param names).
for _, wire := range []string{
"story_arc_filter", "imprint_filter", "edition_filter",
"age_rating_filter", "format_group_filter", "manga_type_filter",
"reading_direction_filter", "explicit_filter", "abridged_filter",
} {
if _, ok := dbTags[wire]; !ok {
t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire)
}
}
}
func TestFacetsForLibraryType(t *testing.T) {
// Empty = All Libraries → the UNION of every facet (Batch D decision).
all := FacetsForLibraryType("")
if len(all) != len(facetRegistry) {
t.Fatalf("all-libraries union = %d facets, want %d", len(all), len(facetRegistry))
}
byWire := func(fs []Facet) map[string]bool {
m := map[string]bool{}
for _, f := range fs {
m[f.WireParam()] = true
}
return m
}
manga := byWire(FacetsForLibraryType("manga"))
if !manga["manga_type_filter"] || !manga["reading_direction_filter"] {
t.Error("manga view must include manga_type and reading_direction")
}
if manga["narrator_filter"] {
t.Error("narrator facet is audiobooks-only, must not render on manga")
}
if !manga["author_filter"] || !manga["genre_filter"] {
t.Error("unrestricted facets render on every library type")
}
audio := byWire(FacetsForLibraryType("audiobooks"))
if !audio["narrator_filter"] {
t.Error("audiobooks view must include narrator")
}
if audio["manga_type_filter"] || audio["reading_direction_filter"] {
t.Error("manga facets must not render on audiobooks")
}
comics := byWire(FacetsForLibraryType("comics"))
if !comics["reading_direction_filter"] {
t.Error("comics view must include reading_direction")
}
if comics["manga_type_filter"] {
t.Error("manga_type is manga-only, must not render on comics")
}
unknown := byWire(FacetsForLibraryType("woodcuts"))
if unknown["manga_type_filter"] || unknown["narrator_filter"] {
t.Error("restricted facets must not render for an unknown type")
}
if !unknown["author_filter"] {
t.Error("unrestricted facets still render for an unknown type")
}
}
func TestSplitFacetValues(t *testing.T) {
if got := SplitFacetValues(""); got != nil {
t.Errorf("empty → nil, got %v", got)
}
got := SplitFacetValues("Vertigo, DC Black Label ,Vertigo")
if len(got) != 3 || got[0] != "Vertigo" || got[1] != "DC Black Label" || got[2] != "Vertigo" {
t.Errorf("SplitFacetValues = %v, want [Vertigo DC Black Label Vertigo]", got)
}
}
+146 -90
View File
@@ -2,85 +2,128 @@ package services
import "strings"
// Smart genre selection for sidecar/embedded metadata (A2c plan §2.5).
// Genre model v2 (faceted-navigation plan, Batch B): the `genre` column
// holds ONLY top-level shelves; every more specific candidate is a tag
// mapped to its parent shelf. User rule: *a subgenre is not a genre, it's
// a tag*.
//
// Sources arrive as comma-blobs ("Epic, Action & Adventure, Military,
// Sword & Sorcery" — one Audible genre string), media-format noise
// ("Audiobook"), or maximally generic tags ("Fiction"). This is a
// deterministic ranked rule set — no network, no guessing service:
// ("Audiobook"), maximally generic tags ("Fiction"), or library-type
// words ("Manga", "Comics" — ComicInfo files that describe the TYPE,
// not a shelf). Deterministic ranked rule set — no network, no guessing:
//
// 1. split candidate strings on , ; /
// 2. alias-map to canonical genres
// 3. discard demoted (format/edition) tokens — they never win
// 4. most specific (longest) canonical hit wins; "Fiction" is last resort
// 5. every candidate (split parts included) still lands in tags, so
// filtering keeps full coverage even when the genre column holds one
// 2. alias-map variants to canonical forms (shelf or subgenre)
// 3. discard demoted tokens (formats/editions/type-words) — never win
// 4. pick: first DIRECT shelf (source order) > first MAPPED subgenre
// shelf > Fiction/Nonfiction floor > "" (unknown-only: tags carry it)
// 5. every candidate (split parts included) still lands in tags
//
// "adventure" and "action & adventure" are BOTH shelves — provider data
// (Audible/Calibre) uses both, and the user-verified traces pin them:
// {Pirates} → adventure (mapped), Malazan blob → action & adventure
// (direct beats mapped). Merging them is deferred aliasing work.
var genreShelves = map[string]bool{
// Fiction side
"fiction": true, "action & adventure": true, "adventure": true,
"fantasy": true, "science fiction": true, "horror": true,
"mystery": true, "thriller": true, "romance": true,
"historical fiction": true, "western": true, "war & military": true,
"young adult": true, "middle grade": true, "children's": true,
"classics": true, "humor": true, "poetry": true, "short stories": true,
// Nonfiction side
"nonfiction": true, "biography & memoir": true, "history": true,
"science": true, "true crime": true, "self-help": true,
"philosophy": true, "business": true,
}
// genreFloors are shelves that only win when NO other shelf candidate
// exists (direct or mapped) — maximally generic positions. {Fiction,
// Epic Fantasy} → fantasy, {Fiction} alone → fiction.
func isGenreFloor(lower string) bool {
return lower == "fiction" || lower == "nonfiction"
}
// genreSubgenreShelf maps subgenre candidates (lowercase) to their parent
// shelf. Anything here is a TAG by definition; its shelf wins the genre
// column only when no direct shelf hit exists.
var genreSubgenreShelf = map[string]string{
// Fantasy subgenres
"epic fantasy": "fantasy", "sword & sorcery": "fantasy",
"dark fantasy": "fantasy", "urban fantasy": "fantasy",
"paranormal": "fantasy", "high fantasy": "fantasy",
"fairy tales": "fantasy", "mythology": "fantasy",
"folklore": "fantasy", "litrpg": "fantasy",
"progression fantasy": "fantasy",
// Science fiction subgenres
"space opera": "science fiction", "cyberpunk": "science fiction",
"military science fiction": "science fiction", "dystopian": "science fiction",
"post-apocalyptic": "science fiction", "apocalyptic": "science fiction",
"time travel": "science fiction", "alternate history": "science fiction",
"first contact": "science fiction", "steampunk": "science fiction",
// Romance subgenres
"contemporary romance": "romance", "historical romance": "romance",
"paranormal romance": "romance",
// Mystery subgenres
"cozy mystery": "mystery", "crime": "mystery",
"police procedural": "mystery", "noir": "mystery", "hardboiled": "mystery",
// Thriller subgenres
"suspense": "thriller", "espionage": "thriller",
// Horror subgenres
"gothic": "horror", "supernatural": "horror",
// Fiction-side themes
"literary fiction": "fiction", "coming of age": "fiction",
"satire": "fiction", "anthology": "fiction", "alternate world": "fiction",
// War & Military subgenres
"military": "war & military", "war": "war & military",
// Adventure subgenres
"pirates": "adventure",
// Biography & Memoir subgenres
"biography": "biography & memoir", "autobiography": "biography & memoir",
"memoir": "biography & memoir",
// Remaining nonfiction topics → the nonfiction umbrella shelf
"psychology": "nonfiction", "economics": "nonfiction",
"politics": "nonfiction", "religion": "nonfiction",
"spirituality": "nonfiction", "travel": "nonfiction",
"cooking": "nonfiction", "food": "nonfiction",
"health": "nonfiction", "fitness": "nonfiction",
"sports": "nonfiction", "music": "nonfiction",
"art": "nonfiction", "photography": "nonfiction",
"education": "nonfiction", "reference": "nonfiction",
"legal": "nonfiction", "medical": "nonfiction",
}
// genreAliases maps provider variants to a canonical shelf OR subgenre
// form (which the maps above then resolve).
var genreAliases = map[string]string{
"sci-fi": "science fiction", "scifi": "science fiction", "sf": "science fiction",
"sff": "science fiction", "sci fi": "science fiction",
"sci-fi": "science fiction", "scifi": "science fiction",
"sf": "science fiction", "sff": "science fiction",
"sci fi": "science fiction", "sci_fi": "science fiction",
"speculative fiction": "science fiction",
" detectives": "mystery", "detective": "mystery",
"detectives": "mystery", "detective": "mystery",
"whodunnit": "mystery", "whodunit": "mystery",
"adventure fiction": "action & adventure",
"action and adventure": "action & adventure",
"ya": "young adult",
"mg": "middle grade",
"graphic novel": "graphic novels",
"coming of age": "coming of age",
"coming-of-age": "coming of age",
"histfiction": "historical fiction",
"epic": "epic fantasy",
"high fantasy": "epic fantasy",
"military scifi": "military science fiction",
"military sf": "military science fiction",
"space opera": "space opera",
"post apocalyptic": "post-apocalyptic",
"self help": "self-help",
"sci_fi": "science fiction",
"ya": "young adult", "mg": "middle grade",
"coming-of-age": "coming of age",
"histfiction": "historical fiction",
"epic": "epic fantasy",
"military scifi": "military science fiction",
"military sf": "military science fiction",
"post apocalyptic": "post-apocalyptic",
"self help": "self-help",
}
// canonicalGenres is the known-genre set (lowercase). A candidate that
// normalizes to one of these wins the `genre` column; longest match beats
// shorter (Sword & Sorcery > Fiction).
var canonicalGenres = map[string]bool{
"fiction": true, "nonfiction": true, "literary fiction": true,
"classics": true, "poetry": true, "short stories": true,
"anthology": true, "coming of age": true, "satire": true, "humor": true,
"fantasy": true, "epic fantasy": true, "sword & sorcery": true,
"dark fantasy": true, "urban fantasy": true, "paranormal": true,
"paranormal romance": true, "high fantasy": true, "fairy tales": true,
"mythology": true, "folklore": true,
"science fiction": true, "space opera": true, "cyberpunk": true,
"military science fiction": true, "dystopian": true,
"post-apocalyptic": true, "apocalyptic": true, "time travel": true,
"alternate history": true, "first contact": true, "steampunk": true,
"litrpg": true, "progression fantasy": true,
"historical fiction": true, "alternate world": true,
"romance": true, "contemporary romance": true, "historical romance": true,
"mystery": true, "cozy mystery": true, "crime": true, "true crime": true,
"police procedural": true, "noir": true, "hardboiled": true,
"thriller": true, "suspense": true, "espionage": true, "horror": true,
"gothic": true, "supernatural": true,
"action & adventure": true, "adventure": true, "western": true,
"military": true, "war": true,
"biography": true, "autobiography": true, "memoir": true,
"history": true, "science": true, "philosophy": true,
"psychology": true, "self-help": true, "business": true,
"economics": true, "politics": true, "religion": true,
"spirituality": true, "travel": true, "cooking": true, "food": true,
"health": true, "fitness": true, "sports": true, "music": true,
"art": true, "photography": true, "education": true, "reference": true,
"young adult": true, "middle grade": true, "children's": true,
"comics": true, "graphic novels": true, "manga": true,
"legal": true, "medical": true,
}
// genreDemoted never wins the genre column — media formats and editions,
// not genres. They still pass through to tags.
// genreDemoted never wins the genre column — media formats, editions,
// and library-TYPE words (ComicInfo genre="Manga" describes the type,
// not a shelf; shelves apply uniformly across all library types). They
// still pass through to tags.
var genreDemoted = map[string]bool{
"audiobook": true, "unabridged": true, "abridged": true,
"dramatization": true, "dramatized": true, "audio drama": true,
"manga": true, "comics": true, "graphic novel": true,
"graphic novels": true,
}
// splitGenreCandidates splits compound genre strings ("Epic, Action &
@@ -102,41 +145,54 @@ func splitGenreCandidates(genres []string) []string {
return out
}
// pickSmartGenre chooses the best genre from candidate strings. Returns the
// winner ("" when there are no candidates) and the full candidate list for
// the tags array.
// normalizeGenreCandidate lowercases, trims, and alias-resolves one
// candidate; "" means noise (empty input).
func normalizeGenreCandidate(c string) string {
lower := strings.ToLower(strings.TrimSpace(c))
if lower == "" {
return ""
}
if a, ok := genreAliases[lower]; ok {
return a
}
return lower
}
// pickSmartGenre chooses the SHELF for the genre column from candidate
// strings (genre model v2). Returns the winner ("" when no shelf is
// known — unknown-only input stays tag-only) and the full candidate
// list for the tags array.
func pickSmartGenre(genres []string) (genre string, all []string) {
all = splitGenreCandidates(genres)
best := ""
bestLen := -1
direct := "" // first non-floor shelf, source order
mapped := "" // first subgenre-resolved shelf, source order
floor := "" // fiction / nonfiction, source order
for _, c := range all {
lower := strings.ToLower(strings.TrimSpace(c))
lower := normalizeGenreCandidate(c)
if lower == "" || genreDemoted[lower] {
continue
}
if genreAliases[lower] != "" {
lower = genreAliases[lower]
}
if !canonicalGenres[lower] {
continue
}
// Specificity: the longest canonical form wins; earlier candidates
// win ties (source order is provider significance).
if len(lower) > bestLen {
best = lower
bestLen = len(lower)
switch {
case genreShelves[lower]:
if isGenreFloor(lower) {
if floor == "" {
floor = lower
}
} else if direct == "" {
direct = lower
}
case genreSubgenreShelf[lower] != "":
if mapped == "" {
mapped = genreSubgenreShelf[lower]
}
}
// Unknown candidates are tags-only — never guess a shelf.
}
if best != "" {
return best, all
if direct != "" {
return direct, all
}
// No lexicon hit: fall back to the first non-demoted candidate verbatim
// (display honesty over false taxonomy), "" when only noise existed.
for _, c := range all {
lower := strings.ToLower(strings.TrimSpace(c))
if lower != "" && !genreDemoted[lower] {
return strings.TrimSpace(c), all
}
if mapped != "" {
return mapped, all
}
return "", all
return floor, all
}
+228 -6
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
@@ -38,6 +39,18 @@ type SearchParams struct {
Sort string
Limit int
Offset int
// Facet registry Batch A additions (services/facet_registry.go).
// Empty strings / invalid Bools mean "facet not active".
StoryArcFilter string
ImprintFilter string
EditionFilter string
AgeRatingFilter string
FormatGroupFilter string
MangaTypeFilter string
ReadingDirectionFilter string
ExplicitFilter pgtype.Bool
AbridgedFilter pgtype.Bool
}
// parseSearchQuery detects quoted strings for exact match search
@@ -63,6 +76,14 @@ func (s *SearchService) parseSearchQuery(query string) (bool, string) {
func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) {
// Parse search query for exact match detection
isExact, searchQuery := s.parseSearchQuery(params.SearchQuery)
// Language filter UX: the UI sends display names ("English"); the
// column stores ISO codes ("en"). Normalize each comma element the
// SAME way ingest does (normalizeLanguageCode), so a user-typed or
// deep-linked display name matches the stored code — and unknown
// values that fit still match verbatim rows. (Facet matching and
// ingest can never disagree because both run the same map.)
params.LanguageFilter = normalizeLanguageFilter(params.LanguageFilter)
// For now implementing real exact search. If you want to reimplement wildcard patterns uncomment this and line 81 and check queries.sql line 475 and frontend.go line 211
// searchPattern := ""
// if isExact {
@@ -79,11 +100,24 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
FormatGroupFilter: pgtype.Text{
String: params.FormatGroupFilter, Valid: true,
},
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
ReadingDirectionFilter: pgtype.Text{
String: params.ReadingDirectionFilter, Valid: true,
},
ExplicitFilter: params.ExplicitFilter,
AbridgedFilter: params.AbridgedFilter,
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
Sort: pgtype.Text{String: params.Sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
@@ -103,6 +137,187 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
return results, nil
}
// normalizeLanguageFilter normalizes every comma element of a language
// filter value through the ingest-side language map (display name →
// ISO code), so filtering and ingest share one source of truth.
func normalizeLanguageFilter(wireValue string) string {
if wireValue == "" {
return ""
}
parts := strings.Split(wireValue, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, normalizeLanguageCode(t))
}
}
return strings.Join(out, ",")
}
// FacetValue is one facet value + count for the filter panel.
type FacetValue struct {
Value string `json:"value"` // the raw value (rides the filter param)
// Display is the prettified form when it differs from Value
// (language codes → names); empty → the client shows Value.
Display string `json:"display,omitempty"`
Count int64 `json:"count"`
}
// FacetValues lists one facet's top values + counts, scoped to a single
// library (v1 counts are global-per-library, not recomputed under the
// other active filters). Registry-driven: only FacetArray and
// FacetScalar facets list values — bool/range facets render as
// toggles/ranges in the panel, not value lists.
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
lim := pgtype.Int4{Int32: int32(limit), Valid: true}
switch facet.Param {
case "author":
rows, err := s.db.FacetAuthorsValues(ctx, database.FacetAuthorsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "publisher":
rows, err := s.db.FacetPublishersValues(ctx, database.FacetPublishersValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "narrator":
rows, err := s.db.FacetNarratorsValues(ctx, database.FacetNarratorsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "tags":
rows, err := s.db.FacetTagsValues(ctx, database.FacetTagsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "series":
rows, err := s.db.FacetSeriesValues(ctx, database.FacetSeriesValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "genre":
rows, err := s.db.FacetGenreValues(ctx, database.FacetGenreValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "language":
rows, err := s.db.FacetLanguageValues(ctx, database.FacetLanguageValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
if name := LanguageDisplayName(r.Value); name != "" {
out[i].Display = name
}
}
return out, nil
case "story_arc":
rows, err := s.db.FacetStoryArcValues(ctx, database.FacetStoryArcValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "imprint":
rows, err := s.db.FacetImprintValues(ctx, database.FacetImprintValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "edition":
rows, err := s.db.FacetEditionValues(ctx, database.FacetEditionValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "age_rating":
rows, err := s.db.FacetAgeRatingValues(ctx, database.FacetAgeRatingValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "format_group":
rows, err := s.db.FacetFormatGroupValues(ctx, database.FacetFormatGroupValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "manga_type":
rows, err := s.db.FacetMangaTypeValues(ctx, database.FacetMangaTypeValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
case "reading_direction":
rows, err := s.db.FacetReadingDirectionValues(ctx, database.FacetReadingDirectionValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
if err != nil {
return nil, err
}
out := make([]FacetValue, len(rows))
for i, r := range rows {
out[i] = FacetValue{Value: r.Value, Count: r.Count}
}
return out, nil
}
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
}
// FieldSearchParams contains parameters for field-specific search (autocomplete)
type FieldSearchParams struct {
UserID pgtype.UUID
@@ -223,7 +438,14 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
// Serve display names ("English") — the user clicks a name,
// the search service normalizes it back to the stored ISO
// code ("en"). Unknown codes pass through verbatim.
shown := r.Value.String
if name := LanguageDisplayName(shown); name != "" {
shown = name
}
fieldValues[i] = FieldValue{Value: shown, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
+30
View File
@@ -217,6 +217,36 @@ func normalizeLanguageCode(v string) string {
return ""
}
// languageCodeToName — canonical English display name per ISO code,
// mirroring languageNameToCode (server owns display; the column stores
// ISO 639-1).
var languageCodeToName = map[string]string{
"en": "English", "es": "Spanish", "fr": "French", "de": "German",
"it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian",
"uk": "Ukrainian", "pl": "Polish", "cs": "Czech", "sk": "Slovak",
"sv": "Swedish", "no": "Norwegian", "da": "Danish", "fi": "Finnish",
"is": "Icelandic", "hu": "Hungarian", "ro": "Romanian", "bg": "Bulgarian",
"el": "Greek", "tr": "Turkish", "ar": "Arabic", "he": "Hebrew",
"fa": "Persian", "hi": "Hindi", "bn": "Bengali", "ur": "Urdu",
"th": "Thai", "vi": "Vietnamese", "id": "Indonesian", "ms": "Malay",
"ja": "Japanese", "ko": "Korean", "zh": "Chinese", "sw": "Swahili",
"af": "Afrikaans", "ca": "Catalan", "hr": "Croatian", "sr": "Serbian",
"sl": "Slovenian", "lt": "Lithuanian", "lv": "Latvian", "et": "Estonian",
"ka": "Georgian", "hy": "Armenian", "cy": "Welsh", "ga": "Irish",
"eu": "Basque", "gl": "Galician",
}
// LanguageDisplayName renders a stored language value for DISPLAY.
// Returns "" when the value is not a known code — callers keep the raw
// value then (legacy verbatim rows like "Pig Latin" display as
// themselves).
func LanguageDisplayName(code string) string {
if name, ok := languageCodeToName[strings.ToLower(strings.TrimSpace(code))]; ok {
return name
}
return ""
}
var languageNameToCode = map[string]string{
"english": "en", "spanish": "es", "español": "es", "french": "fr",
"français": "fr", "german": "de", "deutsch": "de", "italian": "it",
+29 -7
View File
@@ -26,10 +26,10 @@ func TestSplitSeriesString(t *testing.T) {
func TestSeriesSequenceNumber(t *testing.T) {
cases := []struct {
in string
num float64
found bool
lossy bool
in string
num float64
found bool
lossy bool
}{
{"3", 3, true, false},
{"1.5", 1.5, true, false},
@@ -181,12 +181,34 @@ func TestPickSmartGenre(t *testing.T) {
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
t.Errorf("audiobook is not a genre, got %q", genre)
}
// Fiction is the last resort, not the winner.
// Fiction is the floor: alone it resolves, against a real shelf it loses.
if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" {
t.Errorf("plain Fiction should still resolve, got %q", genre)
}
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "epic fantasy" {
t.Errorf("specificity must beat generic, got %q", genre)
// Genre model v2: Epic Fantasy is a SUBGENRE — the genre column gets
// its parent shelf and the specific label lives in tags. (Intended
// change from the old longest-match behavior, which returned
// "epic fantasy" here.)
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "fantasy" {
t.Errorf("subgenre must resolve to its shelf, got %q", genre)
}
// User-verified traces (Batch B decision):
if genre, _ := pickSmartGenre([]string{"Pirates", "Adventure"}); genre != "adventure" {
t.Errorf("direct shelf beats mapped subgenre, got %q", genre)
}
if genre, _ := pickSmartGenre([]string{"Pirates"}); genre != "adventure" {
t.Errorf("mapped subgenre alone resolves to its shelf, got %q", genre)
}
if genre, _ := pickSmartGenre([]string{"Sci-Fi", "Science Fiction", "Fiction"}); genre != "science fiction" {
t.Errorf("direct shelf beats floor, got %q", genre)
}
// Type-words describe the library type, not a shelf.
if genre, _ := pickSmartGenre([]string{"Manga"}); genre != "" {
t.Errorf("manga is a type-word, got %q", genre)
}
// Unknown-only input: no shelf is known — tags carry it, never guess.
if genre, all := pickSmartGenre([]string{"Completely Made-Up Genre"}); genre != "" {
t.Errorf("unknown-only input must leave genre empty, got %q (tags=%v)", genre, all)
}
}