diff --git a/internal/services/search.go b/internal/services/search.go new file mode 100644 index 0000000..e15c220 --- /dev/null +++ b/internal/services/search.go @@ -0,0 +1,185 @@ +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 + LanguageFilter string + YearMin int + YearMax int + HasCover bool + SearchQuery 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 + dbParams := database.SearchMediaItemsUnifiedParams{ + UserID: params.UserID, + LibraryID: params.LibraryID, + AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, + SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, + GenreFilter: pgtype.Text{String: params.GenreFilter, 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}, + Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, + } + + // 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: params.SearchQuery, + 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: params.SearchQuery, + 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 "series": + results, err := s.db.SearchSeriesValues(ctx, database.SearchSeriesValuesParams{ + SearchQuery: params.SearchQuery, + 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: params.SearchQuery, + 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 + } +}