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:
1 parent
7537063cb7
commit
7e586df48a
13 files changed
+2297
-283
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user