- Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test.
442 lines
17 KiB
Go
442 lines
17 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/utils"
|
|
"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
|
|
raw database.DBTX // the raw pgx handle — the composed filter engines' path
|
|
}
|
|
|
|
// NewSearchService creates a new search service instance. raw is the
|
|
// same pool db wraps (database.DBTX) — the composed filter engines run
|
|
// hand-built, allow-listed SQL with bound parameters, which static sqlc
|
|
// queries cannot express.
|
|
func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService {
|
|
return &SearchService{db: db, raw: raw}
|
|
}
|
|
|
|
// 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
|
|
|
|
// Filter rule builder (faceted plan Phase 9). When FilterTree is
|
|
// set the composed engine runs INSTEAD of the flat facet params
|
|
// (qf wins over flat on a hand-crafted URL — precedence pinned by
|
|
// test); FilterParseErr carries a qf parse/validation failure for
|
|
// the endpoints to answer 400.
|
|
FilterTree *FilterTree
|
|
FilterParseErr string
|
|
}
|
|
|
|
// 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, ",")
|
|
}
|
|
|
|
// NormalizeLanguageFilter is the exported form of the search-path
|
|
// normalization for callers that must compare RAW filter values
|
|
// against stored codes without issuing a query (the facet panel marks
|
|
// a value Active by matching the bound filter set).
|
|
func NormalizeLanguageFilter(wireValue string) string {
|
|
return normalizeLanguageFilter(wireValue)
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// facetCountsLimit is the per-facet top-N bound the panel renders.
|
|
const facetCountsLimit = 50
|
|
|
|
// FacetCounts computes every listable facet's top values + counts under
|
|
// the active filters (faceted plan Phase 7): ONE UNION-ALL round trip
|
|
// (FacetCountsUnderFilters) in which each facet is counted under the
|
|
// OTHER facets' filters — the SQL omits each arm's own block, classic
|
|
// faceted-search semantics. With no filters bound every block is
|
|
// neutral and the counts are the v1 global-per-library ones, so one
|
|
// query serves both cases. libraryID may be NULL (the All-Libraries
|
|
// view). Display enrichment matches the wire contract: language codes
|
|
// → display names, genre values prettified — both only when they
|
|
// differ from the raw value (Display is "the prettified form when it
|
|
// differs", per FacetValue).
|
|
func (s *SearchService) FacetCounts(ctx context.Context, userID, libraryID pgtype.UUID, params SearchParams, limit int) (map[string][]FacetValue, error) {
|
|
if limit <= 0 {
|
|
limit = facetCountsLimit
|
|
}
|
|
// Language filter UX (same normalization the search path runs —
|
|
// see SearchMediaItemsUnified): display names in, ISO codes bound.
|
|
langFilter := normalizeLanguageFilter(params.LanguageFilter)
|
|
|
|
rows, err := s.db.FacetCountsUnderFilters(ctx, database.FacetCountsUnderFiltersParams{
|
|
UserID: userID,
|
|
LibraryID: libraryID,
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
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: langFilter, 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,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make(map[string][]FacetValue, 14)
|
|
for _, r := range rows {
|
|
v := FacetValue{Value: r.Value, Count: r.Count}
|
|
switch r.Facet {
|
|
case "language":
|
|
if name := LanguageDisplayName(r.Value); name != "" && name != r.Value {
|
|
v.Display = name
|
|
}
|
|
case "genre":
|
|
if d := utils.PrettifyGenre(r.Value); d != r.Value {
|
|
v.Display = d
|
|
}
|
|
}
|
|
out[r.Facet] = append(out[r.Facet], v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FacetValues lists ONE facet's top values + counts — the pinned
|
|
// single-facet shape behind GET /api/media-items/facets?facet=X. It is
|
|
// the all-facets query with every filter neutral (v1 counts:
|
|
// global-per-library, not recomputed under other 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) {
|
|
if facet.Kind != FacetArray && facet.Kind != FacetScalar {
|
|
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
|
|
}
|
|
counts, err := s.FacetCounts(ctx, userID, libraryID, SearchParams{}, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := counts[facet.Param]
|
|
if values == nil {
|
|
return []FacetValue{}, nil
|
|
}
|
|
return values, 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 is a single field value with metadata. Display carries a
|
|
// human label when the wire value is an ID (library/collection/added_by
|
|
// suggestions) — omitted everywhere else, so the classic static shape
|
|
// {value, count, score} is unchanged.
|
|
type FieldValue struct {
|
|
Value string `json:"value"`
|
|
Count int64 `json:"count"`
|
|
Score float64 `json:"score"`
|
|
Display string `json:"display,omitempty"`
|
|
}
|
|
|
|
// 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.
|
|
// Routing: a qf filter tree → the composed engine (flat params ignored
|
|
// — qf wins); else the static unified query. A parse/validation failure
|
|
// surfaces as ErrInvalidFilterTree (→ 400 at the endpoints).
|
|
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
|
if params.FilterParseErr != "" {
|
|
return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr)
|
|
}
|
|
if params.FilterTree != nil {
|
|
rows, err := s.ComposedSearch(ctx, params)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, 0, err
|
|
}
|
|
count := len(rows)
|
|
return rows, count, nil
|
|
}
|
|
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
|
|
}
|