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
This commit is contained in:
@@ -290,6 +290,7 @@ type Querier interface {
|
|||||||
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
|
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
|
||||||
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
|
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
|
||||||
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
||||||
|
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
|
||||||
// Library Visibility queries
|
// Library Visibility queries
|
||||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||||
// Set system config
|
// Set system config
|
||||||
|
|||||||
@@ -7254,92 +7254,99 @@ WHERE COALESCE(lv.is_visible, true) = true
|
|||||||
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
|
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
|
||||||
-- Fuzzy language filter
|
-- Fuzzy language filter
|
||||||
AND ($6 = '' OR word_similarity($6, COALESCE(mi.language, '')) > 0.3)
|
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)
|
-- 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)
|
-- 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)
|
-- Search query (fuzzy or exact based on quotes)
|
||||||
AND (
|
AND (
|
||||||
$10 = '' OR
|
$11 = '' OR
|
||||||
-- Fuzzy search (default)
|
-- Fuzzy search (default)
|
||||||
$11 = false AND (
|
$12 = false AND (
|
||||||
word_similarity($10, mi.title) > 0.3 OR
|
word_similarity($11, mi.title) > 0.3 OR
|
||||||
word_similarity($10, COALESCE(mi.author, '')) > 0.3 OR
|
word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR
|
||||||
word_similarity($10, COALESCE(mi.series, '')) > 0.3 OR
|
word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
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
|
LIMIT 1
|
||||||
) OR
|
) OR
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
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
|
LIMIT 1
|
||||||
)
|
)
|
||||||
) OR
|
) OR
|
||||||
-- Exact search (with quotes)
|
-- Exact search (with quotes)
|
||||||
$11 = true AND (
|
$12 = true AND (
|
||||||
mi.title ILIKE $12 OR
|
mi.title ILIKE $13 OR
|
||||||
mi.author ILIKE $12 OR
|
mi.author ILIKE $13 OR
|
||||||
mi.series ILIKE $12 OR
|
mi.series ILIKE $13 OR
|
||||||
$12 = ANY(mi.tags_search) OR
|
$13 = ANY(mi.tags_search) OR
|
||||||
$12 = ANY(mi.contributors_search)
|
$13 = ANY(mi.contributors_search)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
mi.id,
|
mi.id,
|
||||||
-- Primary sort: relevance score when searching
|
-- Primary sort: relevance score when searching
|
||||||
CASE
|
CASE
|
||||||
WHEN $10 != '' THEN
|
WHEN $11 != '' THEN
|
||||||
GREATEST(
|
GREATEST(
|
||||||
CASE WHEN $11 = false THEN
|
CASE WHEN $12 = false THEN
|
||||||
word_similarity($10, mi.title)
|
word_similarity($11, mi.title)
|
||||||
ELSE 0 END,
|
ELSE 0 END,
|
||||||
CASE WHEN $11 = false THEN
|
CASE WHEN $12 = false THEN
|
||||||
word_similarity($10, COALESCE(mi.author, ''))
|
word_similarity($11, COALESCE(mi.author, ''))
|
||||||
ELSE 0 END,
|
ELSE 0 END,
|
||||||
word_similarity($3, COALESCE(mi.author, '')),
|
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
|
ELSE 0
|
||||||
END DESC,
|
END DESC,
|
||||||
-- Secondary sort: user-specified sort parameter
|
-- Secondary sort: user-specified sort parameter
|
||||||
CASE
|
CASE
|
||||||
WHEN $13 = 'title ASC' THEN mi.title
|
WHEN $14 = 'title ASC' THEN mi.title
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $13 = 'title DESC' THEN mi.title
|
WHEN $14 = 'title DESC' THEN mi.title
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END DESC,
|
END DESC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '')
|
WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '')
|
WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END DESC,
|
END DESC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $13 = 'created_at ASC' THEN mi.created_at
|
WHEN $14 = 'created_at ASC' THEN mi.created_at
|
||||||
ELSE '1970-01-01'::timestamp
|
ELSE '1970-01-01'::timestamp
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $13 = 'created_at DESC' THEN mi.created_at
|
WHEN $14 = 'created_at DESC' THEN mi.created_at
|
||||||
ELSE '1970-01-01'::timestamp
|
ELSE '1970-01-01'::timestamp
|
||||||
END DESC,
|
END DESC,
|
||||||
CASE
|
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 ''
|
ELSE ''
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
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 ''
|
ELSE ''
|
||||||
END DESC,
|
END DESC,
|
||||||
-- Tertiary sort: title (default fallback)
|
-- Tertiary sort: title (default fallback)
|
||||||
mi.title ASC
|
mi.title ASC
|
||||||
LIMIT $15 OFFSET $14
|
LIMIT $16 OFFSET $15
|
||||||
`
|
`
|
||||||
|
|
||||||
type SearchMediaItemsUnifiedParams struct {
|
type SearchMediaItemsUnifiedParams struct {
|
||||||
@@ -7349,6 +7356,7 @@ type SearchMediaItemsUnifiedParams struct {
|
|||||||
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
||||||
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
||||||
LanguageFilter interface{} `db:"language_filter" json:"language_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"`
|
YearMin interface{} `db:"year_min" json:"year_min"`
|
||||||
YearMax interface{} `db:"year_max" json:"year_max"`
|
YearMax interface{} `db:"year_max" json:"year_max"`
|
||||||
HasCover interface{} `db:"has_cover" json:"has_cover"`
|
HasCover interface{} `db:"has_cover" json:"has_cover"`
|
||||||
@@ -7417,6 +7425,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
|||||||
arg.SeriesFilter,
|
arg.SeriesFilter,
|
||||||
arg.GenreFilter,
|
arg.GenreFilter,
|
||||||
arg.LanguageFilter,
|
arg.LanguageFilter,
|
||||||
|
arg.TagsFilter,
|
||||||
arg.YearMin,
|
arg.YearMin,
|
||||||
arg.YearMax,
|
arg.YearMax,
|
||||||
arg.HasCover,
|
arg.HasCover,
|
||||||
@@ -7550,6 +7559,63 @@ func (q *Queries) SearchSeriesValues(ctx context.Context, arg SearchSeriesValues
|
|||||||
return items, nil
|
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
|
const SetLibraryVisibility = `-- name: SetLibraryVisibility :one
|
||||||
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
|
|||||||
@@ -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)
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
||||||
-- Fuzzy language filter
|
-- Fuzzy language filter
|
||||||
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
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)
|
-- Year range (exact)
|
||||||
AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min'))
|
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'))
|
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, ''))
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
|
||||||
ELSE 0 END,
|
ELSE 0 END,
|
||||||
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
|
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
|
ELSE 0
|
||||||
END DESC,
|
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
|
ORDER BY score DESC, count DESC
|
||||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
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
|
-- name: SearchSeriesValues :many
|
||||||
SELECT
|
SELECT
|
||||||
mi.series as value,
|
mi.series as value,
|
||||||
|
|||||||
Reference in New Issue
Block a user