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.
154 lines
6.0 KiB
Go
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
|
|
}
|