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 }