From 00840c2fe1c353635c18908dad674335f768e31f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 25 Mar 2026 20:38:08 -0400 Subject: [PATCH] feat: add fuzzy tags_filter to search query - Add tags_filter parameter to SearchMediaItemsUnified - Add EXISTS clause with word_similarity() for fuzzy tag matching - Add tag similarity scoring to ORDER BY clause (GREATEST function) - Add SearchTagsValues query for autocomplete with ::TEXT cast - Keep genre_filter for backward compatibility - Regenerate Go code with sqlc generate This enables filtering books by tags (from Calibre) instead of genre, which is always NULL for imported books. Uses fuzzy matching consistent with author/series filters, with best matches sorted first. Relates to IMPLEMENTATION_TAGS_FILTER.md Phase 1 --- internal/database/querier.go | 1 + internal/database/queries.sql.go | 128 +++++++++++++++++++------- internal/database/queries/queries.sql | 25 ++++- 3 files changed, 122 insertions(+), 32 deletions(-) diff --git a/internal/database/querier.go b/internal/database/querier.go index f02c81e..7054865 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -290,6 +290,7 @@ type Querier interface { SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error) + SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error) // Library Visibility queries SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) // Set system config diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 4fe3d60..47ac5fc 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -7254,92 +7254,99 @@ WHERE COALESCE(lv.is_visible, true) = true AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3) -- Fuzzy language filter AND ($6 = '' OR word_similarity($6, COALESCE(mi.language, '')) > 0.3) + -- Tags filter (NEW - fuzzy match against tags array) + AND ($7 = '' OR EXISTS ( + SELECT 1 FROM unnest(mi.tags_search) AS tag + WHERE word_similarity($7, tag) > 0.3 + )) -- Year range (exact) - AND ($7 = 0 OR mi.copyright_year >= $7) - AND ($8 = 0 OR mi.copyright_year <= $8) + AND ($8 = 0 OR mi.copyright_year >= $8) + AND ($9 = 0 OR mi.copyright_year <= $9) -- Boolean (exact) - AND ($9 = false OR mi.cover_image_path IS NOT NULL) + AND ($10 = false OR mi.cover_image_path IS NOT NULL) -- Search query (fuzzy or exact based on quotes) AND ( - $10 = '' OR + $11 = '' OR -- Fuzzy search (default) - $11 = false AND ( - word_similarity($10, mi.title) > 0.3 OR - word_similarity($10, COALESCE(mi.author, '')) > 0.3 OR - word_similarity($10, COALESCE(mi.series, '')) > 0.3 OR + $12 = false AND ( + word_similarity($11, mi.title) > 0.3 OR + word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR + word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR EXISTS ( SELECT 1 FROM unnest(mi.tags_search) AS tag - WHERE word_similarity($10, tag) > 0.3 + WHERE word_similarity($11, tag) > 0.3 LIMIT 1 ) OR EXISTS ( SELECT 1 FROM unnest(mi.contributors_search) AS contributor - WHERE word_similarity($10, contributor) > 0.3 + WHERE word_similarity($11, contributor) > 0.3 LIMIT 1 ) ) OR -- Exact search (with quotes) - $11 = true AND ( - mi.title ILIKE $12 OR - mi.author ILIKE $12 OR - mi.series ILIKE $12 OR - $12 = ANY(mi.tags_search) OR - $12 = ANY(mi.contributors_search) + $12 = true AND ( + mi.title ILIKE $13 OR + mi.author ILIKE $13 OR + mi.series ILIKE $13 OR + $13 = ANY(mi.tags_search) OR + $13 = ANY(mi.contributors_search) ) ) ORDER BY mi.id, -- Primary sort: relevance score when searching CASE - WHEN $10 != '' THEN + WHEN $11 != '' THEN GREATEST( - CASE WHEN $11 = false THEN - word_similarity($10, mi.title) + CASE WHEN $12 = false THEN + word_similarity($11, mi.title) ELSE 0 END, - CASE WHEN $11 = false THEN - word_similarity($10, COALESCE(mi.author, '')) + CASE WHEN $12 = false THEN + word_similarity($11, COALESCE(mi.author, '')) ELSE 0 END, word_similarity($3, COALESCE(mi.author, '')), - word_similarity($5, COALESCE(mi.genre, '')) + word_similarity($5, COALESCE(mi.genre, '')), + (SELECT MAX(word_similarity($7, tag)) + FROM unnest(mi.tags_search) AS tag) ) ELSE 0 END DESC, -- Secondary sort: user-specified sort parameter CASE - WHEN $13 = 'title ASC' THEN mi.title + WHEN $14 = 'title ASC' THEN mi.title ELSE '' END ASC, CASE - WHEN $13 = 'title DESC' THEN mi.title + WHEN $14 = 'title DESC' THEN mi.title ELSE '' END DESC, CASE - WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '') + WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '') ELSE '' END ASC, CASE - WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '') + WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '') ELSE '' END DESC, CASE - WHEN $13 = 'created_at ASC' THEN mi.created_at + WHEN $14 = 'created_at ASC' THEN mi.created_at ELSE '1970-01-01'::timestamp END ASC, CASE - WHEN $13 = 'created_at DESC' THEN mi.created_at + WHEN $14 = 'created_at DESC' THEN mi.created_at ELSE '1970-01-01'::timestamp END DESC, CASE - WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') + WHEN $14 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END ASC, CASE - WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') + WHEN $14 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END DESC, -- Tertiary sort: title (default fallback) mi.title ASC -LIMIT $15 OFFSET $14 +LIMIT $16 OFFSET $15 ` type SearchMediaItemsUnifiedParams struct { @@ -7349,6 +7356,7 @@ type SearchMediaItemsUnifiedParams struct { SeriesFilter interface{} `db:"series_filter" json:"series_filter"` GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` LanguageFilter interface{} `db:"language_filter" json:"language_filter"` + TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` YearMin interface{} `db:"year_min" json:"year_min"` YearMax interface{} `db:"year_max" json:"year_max"` HasCover interface{} `db:"has_cover" json:"has_cover"` @@ -7417,6 +7425,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt arg.SeriesFilter, arg.GenreFilter, arg.LanguageFilter, + arg.TagsFilter, arg.YearMin, arg.YearMax, arg.HasCover, @@ -7550,6 +7559,63 @@ func (q *Queries) SearchSeriesValues(ctx context.Context, arg SearchSeriesValues return items, nil } +const SearchTagsValues = `-- name: SearchTagsValues :many +SELECT + unnest(mi.tags_search)::TEXT as value, + COUNT(*) as count, + word_similarity($1, unnest(mi.tags_search))::float8 as score +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2 +WHERE COALESCE(lv.is_visible, true) = true + AND ($3::uuid IS NULL OR mi.library_id = $3::uuid) + AND unnest(mi.tags_search) IS NOT NULL + AND word_similarity($1, unnest(mi.tags_search)) > 0.3 +GROUP BY unnest(mi.tags_search), word_similarity($1, unnest(mi.tags_search))::float8 +ORDER BY word_similarity($1, unnest(mi.tags_search))::float8 DESC, COUNT DESC +LIMIT $5 OFFSET $4 +` + +type SearchTagsValuesParams struct { + SearchQuery pgtype.Text `db:"search_query" json:"search_query"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Offset pgtype.Int4 `db:"offset" json:"offset"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type SearchTagsValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` + Score float64 `db:"score" json:"score"` +} + +func (q *Queries) SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error) { + rows, err := q.db.Query(ctx, SearchTagsValues, + arg.SearchQuery, + arg.UserID, + arg.LibraryID, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchTagsValuesRow{} + for rows.Next() { + var i SearchTagsValuesRow + if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const SetLibraryVisibility = `-- name: SetLibraryVisibility :one INSERT INTO library_visibility (user_id, library_id, is_visible) VALUES ($1, $2, $3) diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index e662d15..4761cf9 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -435,6 +435,11 @@ WHERE COALESCE(lv.is_visible, true) = true AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3) -- Fuzzy language filter AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3) + -- Tags filter (NEW - fuzzy match against tags array) + AND (sqlc.narg('tags_filter') = '' OR EXISTS ( + SELECT 1 FROM unnest(mi.tags_search) AS tag + WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3 + )) -- Year range (exact) AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min')) AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max')) @@ -481,7 +486,9 @@ ORDER BY word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) ELSE 0 END, word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')), - word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) + word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')), + (SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag)) + FROM unnest(mi.tags_search) AS tag) ) ELSE 0 END DESC, @@ -556,6 +563,22 @@ GROUP BY mi.genre, word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ORDER BY score DESC, count DESC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); +-- name: SearchTagsValues :many +SELECT + unnest(mi.tags_search)::TEXT as value, + COUNT(*) as count, + word_similarity(sqlc.narg('search_query'), unnest(mi.tags_search))::float8 as score +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) + AND unnest(mi.tags_search) IS NOT NULL + AND word_similarity(sqlc.narg('search_query'), unnest(mi.tags_search)) > 0.3 +GROUP BY unnest(mi.tags_search), word_similarity(sqlc.narg('search_query'), unnest(mi.tags_search))::float8 +ORDER BY word_similarity(sqlc.narg('search_query'), unnest(mi.tags_search))::float8 DESC, COUNT DESC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + -- name: SearchSeriesValues :many SELECT mi.series as value,