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
+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
|
||||
|
||||
|
||||
Reference in new issue
Block a user