Files
John O'Keefe 7e586df48a 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.
2026-10-19 18:59:23 -04:00

154 lines
6.0 KiB
Go

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
}