FieldValue struct had no json tags, so Go marshaled fields as uppercase (Value, Count, Score) but frontend expected lowercase (value, count). This caused all autocomplete dropdowns (tags, author, series, language) to silently fail — tagSuggestions[].value was undefined, crashing toLowerCase() calls and producing empty dropdowns.
225 lines
7.6 KiB
Go
225 lines
7.6 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"errors"
|
|
"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 `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 "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 && !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, 0, err
|
|
}
|
|
|
|
count := len(results)
|
|
return results, count, nil
|
|
}
|