Files
bookhoard/internal/services/search.go
T
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

468 lines
16 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// SearchService handles all search and filter operations
type SearchService struct {
db *database.Queries
}
// NewSearchService creates a new search service instance
func NewSearchService(db *database.Queries) *SearchService {
return &SearchService{db: db}
}
// SearchParams contains parameters for unified search
type SearchParams struct {
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
PublisherFilter string
TagsFilter string
NarratorFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
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
// Returns (isExact, processedQuery)
// Examples:
//
// "asimov" → (false, "asimov")
// "\"Asimov, Isaac\"" → (true, "Asimov, Isaac")
func (s *SearchService) parseSearchQuery(query string) (bool, string) {
query = strings.TrimSpace(query)
if strings.HasPrefix(query, "\"") && strings.HasSuffix(query, "\"") && len(query) >= 2 {
return true, strings.Trim(query, "\"")
}
return false, query
}
// SearchMediaItemsUnified handles combined search + filters
// Supports:
// - Fuzzy text filters (author, series, genre, language)
// - Exact filters (year range, has_cover boolean)
// - Fuzzy search query (or exact match with quotes)
// - Combined search + filters
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 {
// searchPattern = "%" + searchQuery + "%"
// }
// Build database parameters - only set valid true if filter
dbParams := database.SearchMediaItemsUnifiedParams{
UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, 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},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
}
if params.LibraryID.Valid {
dbParams.LibraryID = params.LibraryID
}
// Execute unified search query
results, err := s.db.SearchMediaItemsUnified(ctx, dbParams)
if err != nil {
return nil, err
}
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
LibraryID pgtype.UUID
FieldType string // "author", "genre", "series", "language"
SearchQuery string
Limit int
Offset int
}
// FieldValue represents a single field value with metadata
type FieldValue struct {
Value string `json:"value"`
Count int64 `json:"count"`
Score float64 `json:"score"`
}
// SearchFieldValues handles field-specific search for autocomplete dropdowns
// Returns distinct values with counts and similarity scores
// Uses 4 separate queries (one per field type) for sqlc v1.30.0 compatibility
func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearchParams) ([]FieldValue, error) {
switch params.FieldType {
case "author":
results, err := s.db.SearchAuthorValues(ctx, database.SearchAuthorValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "genre":
results, err := s.db.SearchGenreValues(ctx, database.SearchGenreValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "tags":
results, err := s.db.SearchTagsValues(ctx, database.SearchTagsValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "narrator":
results, err := s.db.SearchNarratorValues(ctx, database.SearchNarratorValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "series":
results, err := s.db.SearchSeriesValues(ctx, database.SearchSeriesValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "language":
results, err := s.db.SearchLanguageValues(ctx, database.SearchLanguageValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
// 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
default:
return []FieldValue{}, nil
}
}
// ExecuteSearch performs search and returns results with count
// Shared between JSON endpoint and HTML rendering
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
results, err := s.SearchMediaItemsUnified(ctx, params)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
}
count := len(results)
return results, count, nil
}