package services import ( "bookhoard/internal/database" "bookhoard/internal/utils" "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 raw database.DBTX // the raw pgx handle — the composed filter engines' path } // NewSearchService creates a new search service instance. raw is the // same pool db wraps (database.DBTX) — the composed filter engines run // hand-built, allow-listed SQL with bound parameters, which static sqlc // queries cannot express. func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService { return &SearchService{db: db, raw: raw} } // 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 // Filter rule builder (faceted plan Phase 9). When FilterTree is // set the composed engine runs INSTEAD of the flat facet params // (qf wins over flat on a hand-crafted URL — precedence pinned by // test); FilterParseErr carries a qf parse/validation failure for // the endpoints to answer 400. FilterTree *FilterTree FilterParseErr string } // 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, ",") } // NormalizeLanguageFilter is the exported form of the search-path // normalization for callers that must compare RAW filter values // against stored codes without issuing a query (the facet panel marks // a value Active by matching the bound filter set). func NormalizeLanguageFilter(wireValue string) string { return normalizeLanguageFilter(wireValue) } // 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"` } // facetCountsLimit is the per-facet top-N bound the panel renders. const facetCountsLimit = 50 // FacetCounts computes every listable facet's top values + counts under // the active filters (faceted plan Phase 7): ONE UNION-ALL round trip // (FacetCountsUnderFilters) in which each facet is counted under the // OTHER facets' filters — the SQL omits each arm's own block, classic // faceted-search semantics. With no filters bound every block is // neutral and the counts are the v1 global-per-library ones, so one // query serves both cases. libraryID may be NULL (the All-Libraries // view). Display enrichment matches the wire contract: language codes // → display names, genre values prettified — both only when they // differ from the raw value (Display is "the prettified form when it // differs", per FacetValue). func (s *SearchService) FacetCounts(ctx context.Context, userID, libraryID pgtype.UUID, params SearchParams, limit int) (map[string][]FacetValue, error) { if limit <= 0 { limit = facetCountsLimit } // Language filter UX (same normalization the search path runs — // see SearchMediaItemsUnified): display names in, ISO codes bound. langFilter := normalizeLanguageFilter(params.LanguageFilter) rows, err := s.db.FacetCountsUnderFilters(ctx, database.FacetCountsUnderFiltersParams{ UserID: userID, LibraryID: libraryID, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, 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: langFilter, 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, }) if err != nil { return nil, err } out := make(map[string][]FacetValue, 14) for _, r := range rows { v := FacetValue{Value: r.Value, Count: r.Count} switch r.Facet { case "language": if name := LanguageDisplayName(r.Value); name != "" && name != r.Value { v.Display = name } case "genre": if d := utils.PrettifyGenre(r.Value); d != r.Value { v.Display = d } } out[r.Facet] = append(out[r.Facet], v) } return out, nil } // FacetValues lists ONE facet's top values + counts — the pinned // single-facet shape behind GET /api/media-items/facets?facet=X. It is // the all-facets query with every filter neutral (v1 counts: // global-per-library, not recomputed under other 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) { if facet.Kind != FacetArray && facet.Kind != FacetScalar { return nil, fmt.Errorf("facet %q does not list values", facet.Param) } counts, err := s.FacetCounts(ctx, userID, libraryID, SearchParams{}, limit) if err != nil { return nil, err } values := counts[facet.Param] if values == nil { return []FacetValue{}, nil } return values, nil } // 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 is a single field value with metadata. Display carries a // human label when the wire value is an ID (library/collection/added_by // suggestions) — omitted everywhere else, so the classic static shape // {value, count, score} is unchanged. type FieldValue struct { Value string `json:"value"` Count int64 `json:"count"` Score float64 `json:"score"` Display string `json:"display,omitempty"` } // 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. // Routing: a qf filter tree → the composed engine (flat params ignored // — qf wins); else the static unified query. A parse/validation failure // surfaces as ErrInvalidFilterTree (→ 400 at the endpoints). func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) { if params.FilterParseErr != "" { return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr) } if params.FilterTree != nil { rows, err := s.ComposedSearch(ctx, params) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, 0, err } count := len(rows) return rows, count, nil } 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 }