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('_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 `_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 `_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
}