Updated the backend services and handlers to properly detect and pass
the has_cover parameter's validity state to the database layer.
Changes:
- services/search.go: Changed HasCover type from bool to pgtype.Bool
to support 3-state logic (NULL, TRUE, FALSE)
- handlers/media.go: Fixed 3-state detection by checking if has_cover
exists in query params before setting Valid flag
- router/search.go: Fixed 3-state detection to match media.go logic
- router/frontend.go: Use pgtype.Bool{Valid: false} for SSR initial
load to ensure no filtering occurs on first page load
The key fix is detecting whether the has_cover parameter was actually
sent in the request:
- Parameter not sent → pgtype.Bool{Bool: false, Valid: false}
- Parameter sent as "true" → pgtype.Bool{Bool: true, Valid: true}
- Parameter sent as "false" → pgtype.Bool{Bool: false, Valid: true}
Previously, media.go was hardcoding Valid: true, which meant it was
always filtering by has_cover=false (only books without covers) when
the parameter wasn't sent, causing searches to incorrectly return
0 results for queries like "1984".
This ensures consistency between the JSON API endpoint (media.go) and
the HTML endpoint (search.go), and fixes the critical bug where SSR
was returning 0 books on initial page load.
224 lines
7.5 KiB
Go
224 lines
7.5 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"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
|
|
TagsFilter string
|
|
LanguageFilter string
|
|
YearMin int
|
|
YearMax int
|
|
HasCover pgtype.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)
|
|
// 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},
|
|
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: 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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 && err != pgx.ErrNoRows {
|
|
return nil, 0, err
|
|
}
|
|
|
|
count := len(results)
|
|
return results, count, nil
|
|
}
|