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 }