package services import ( "bookhoard/internal/database" "context" "strings" "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 TagsFilter string LanguageFilter string YearMin int YearMax int HasCover bool SearchQuery string Sort string Limit int Offset int } // 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) 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}, TagsFilter: pgtype.Text{String: params.TagsFilter, 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: pgtype.Bool{Bool: params.HasCover, Valid: true}, 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 } // 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 Count int64 Score float64 } // 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 "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 { fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score} } return fieldValues, nil default: return []FieldValue{}, nil } }