diff --git a/IMPLEMENTATION_TAGS_FILTER.md b/IMPLEMENTATION_TAGS_FILTER.md index 7c1055e..4abfbdf 100644 --- a/IMPLEMENTATION_TAGS_FILTER.md +++ b/IMPLEMENTATION_TAGS_FILTER.md @@ -5,6 +5,7 @@ **Type:** Full-Stack Task **Priority:** Medium **Estimated Time:** 2-3 hours +**Note:** Tags filter uses fuzzy matching (consistent with author/series filters) --- @@ -65,11 +66,13 @@ The current genre filter expects a single `genre` text field, which is always NU ## 📋 Implementation Checklist -### Phase 1: Backend - SQL Layer (15 minutes) +### Phase 1: Backend - SQL Layer (25 minutes) - [ ] Add `tags_filter` parameter to `SearchMediaItemsUnified` query -- [ ] Update filter logic from `word_similarity()` to `= ANY()` for array matching +- [ ] Add fuzzy matching filter logic with EXISTS clause and word_similarity() +- [ ] Add tag similarity scoring to ORDER BY clause (GREATEST function) - [ ] Keep `genre_filter` for backward compatibility - [ ] Regenerate Go code with `sqlc generate` +- [ ] Test SQL query in database console (verify fuzzy matching works) ### Phase 2: Backend - Service Layer (20 minutes) - [ ] Add `TagsFilter string` to `SearchParams` struct @@ -96,10 +99,11 @@ The current genre filter expects a single `genre` text field, which is always NU - [ ] Regenerate template Go files with `templ generate` ### Phase 6: Bruno Collection Updates (15 minutes) -- [ ] Create `Fuzzy Tags Filter.yml` (rename/update genre filter test) -- [ ] Create `Field Values Search - Tags.yml` (rename genre autocomplete) +- [ ] Create `Fuzzy Tags Filter.yml` (test fuzzy matching behavior) +- [ ] Create `Field Values Search - Tags.yml` (test autocomplete) - [ ] Update `Combined Search and Filters.yml` (genre → tags_filter) -- [ ] Add documentation for tags filter behavior +- [ ] Add test cases for fuzzy matching (e.g., "Sci Fi" → "Science Fiction") +- [ ] Add documentation for fuzzy tags filter behavior ### Phase 7: Documentation (20 minutes) - [ ] Create `docs/developer/api/media-items/search_media_items.md` (or update existing) @@ -142,8 +146,11 @@ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter') -- AFTER: -- Fuzzy genre filter (kept for backward compatibility) AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3) --- Tags filter (NEW - matches against tags array) -AND (sqlc.narg('tags_filter') = '' OR sqlc.narg('tags_filter') = ANY(mi.tags_search)) +-- 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 +)) ``` **New Query:** Add `SearchTagsValues` for autocomplete @@ -165,6 +172,26 @@ ORDER BY word_similarity(sqlc.narg('search_query'), unnest(mi.tags_search))::flo LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); ``` +**ORDER BY Clause Update (line ~484):** +Add tag similarity scoring to the GREATEST() calculation: +```sql +-- In the ORDER BY clause, add to the GREATEST() function: +GREATEST( + CASE WHEN sqlc.narg('is_exact_search') = false THEN + word_similarity(sqlc.narg('search_query'), mi.title) + ELSE 0 END, + CASE WHEN sqlc.narg('is_exact_search') = false THEN + 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, '')), + (SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag)) + FROM unnest(mi.tags_search) AS tag) -- NEW: Tag similarity score +) +``` + +This ensures results with better tag matches appear first. + ### 2. Service Layer Changes **File:** `internal/services/search.go` @@ -289,19 +316,6 @@ async fetchTagValues(input: HTMLInputElement): Promise { ] ``` -**File:** `web/src/collection-rules.ts` - -**Change:** Line 45 -```typescript -// AFTER (add new field, keep old commented out): -const ruleFields = { - tags: "Tags", - // TODO: Uncomment if genre field is populated in future - // genre: "Genre", - // ... other fields -}; -``` - ### 5. Template Changes **File:** `templates/bookshelf.templ` (source file) @@ -360,7 +374,7 @@ import ( ) func TestTagsFilter(t *testing.T) { - setup := setupTestServer(t) // ✅ Using test_helpers + setup := setupDeviceTest(t) // ✅ Using test_helpers defer setup.Server.Close() libraryID := setup.CreateLibrary(t, "Test Tags Library", "ebooks") @@ -392,12 +406,20 @@ func TestTagsFilter(t *testing.T) { createBook("The Hobbit", "Tolkien", []string{"Fantasy", "Adventure"}) t.Run("Tags filter - Science Fiction", func(t *testing.T) { - req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&tags_filter=Science+Foundation", nil) + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&tags_filter=Science+Fiction", nil) req.Header.Set("Authorization", "Bearer "+setup.UserToken) // ... test implementation // Should return Foundation, Dune, Neuromancer (all have "Science Fiction" tag) }) + t.Run("Tags filter - fuzzy match", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&tags_filter=Sci+Fi", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + // ... test implementation + // Should return Foundation, Dune, Neuromancer (fuzzy matches "Science Fiction") + // Results should be ranked by similarity score + }) + t.Run("Tags filter - Fantasy", func(t *testing.T) { // Should return only The Hobbit }) @@ -405,7 +427,7 @@ func TestTagsFilter(t *testing.T) { t.Run("Tags autocomplete", func(t *testing.T) { req := httptest.NewRequest("GET", "/api/media-items/search?tags=Sci&library_id="+libraryID, nil) req.Header.Set("Authorization", "Bearer "+setup.UserToken) - // Should return "Science Fiction" in autocomplete results + // Should return "Science Fiction" in autocomplete results with fuzzy matching }) t.Run("Backward compatibility - genre_filter still works", func(t *testing.T) { @@ -413,7 +435,7 @@ func TestTagsFilter(t *testing.T) { }) t.Run("Combined filters - tags + author", func(t *testing.T) { - // tags_filter=Science+Fiction&author_filter=Asimov + // tags_filter=Sci+Fi&author_filter=Asimov // Should return only Foundation }) } @@ -422,6 +444,8 @@ func TestTagsFilter(t *testing.T) { ### Manual QA Checklist - [ ] Tags filter returns books with matching tags +- [ ] Tags filter uses fuzzy matching (e.g., "Sci Fi" matches "Science Fiction") +- [ ] Results are ranked by tag similarity score (best matches first) - [ ] Tags autocomplete shows tag values as you type - [ ] Multiple tags in book are all searchable - [ ] Combined filters work (tags + author + series) @@ -455,7 +479,7 @@ Search and filter media items with fuzzy matching and tag-based filtering. | `library_id` | string | No | Filter by library UUID | | `author_filter` | string | No | Fuzzy match author field | | `series_filter` | string | No | Fuzzy match series field | -| `tags_filter` | string | No | **Exact match tags array** (NEW) | +| `tags_filter` | string | No | **Fuzzy match tags array** (NEW) | | `language_filter` | string | No | Fuzzy match language field | | `year_min` | integer | No | Minimum copyright year | | `year_max` | integer | No | Maximum copyright year | @@ -468,21 +492,29 @@ Search and filter media items with fuzzy matching and tag-based filtering. **New in v1.x:** The `tags_filter` parameter filters books by their tags array. -- **Matching:** Exact match (not fuzzy) - the filter value must equal a tag +- **Matching:** Fuzzy match using `word_similarity()` - matches tags with similarity > 0.3 - **Calibre Integration:** Works seamlessly with Calibre tags (which include genre) +- **Result Ranking:** Best tag matches appear first in results - **Example Tags:** `["Science Fiction", "Adventure", "Dystopian"]` -**Examples:** +**Fuzzy Matching Examples:** ```bash -# Filter by "Science Fiction" tag +# Exact match: "Science Fiction" GET /api/media-items/search?tags_filter=Science+Fiction +# Returns: All books with "Science Fiction" tag + +# Fuzzy match: "Sci Fi" → "Science Fiction" +GET /api/media-items/search?tags_filter=Sci+Fi +# Returns: Books with "Science Fiction", "Sci-Fi", "Scifi" (score > 0.3) # Combine tags with author -GET /api/media-items/search?tags_filter=Science+Fiction&author_filter=Asimov +GET /api/media-items/search?tags_filter=Sci+Fi&author_filter=Asimov +# Returns: Science Fiction books by Asimov, best matches first -# Filter by multiple tags (returns books with ANY matching tag) -GET /api/media-items/search?tags_filter=Cyberpunk +# Fuzzy match with variations +GET /api/media-items/search?tags_filter=Cyber +# Returns: Books with "Cyberpunk", "Cyber", "Cyberthriller" tags ``` ### Backward Compatibility @@ -519,7 +551,6 @@ tags_filter=Science+Fiction } ] ``` -``` ### User Documentation @@ -560,8 +591,9 @@ You can combine tags with other filters: - Tags from Calibre are automatically imported - You can add custom tags when editing book metadata -- Tags are case-insensitive for searching -- Autocomplete shows existing tags as you type +- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction" +- Best matching tags appear first in results +- Autocomplete shows existing tags as you type with relevance scores ``` --- @@ -570,9 +602,11 @@ You can combine tags with other filters: ### Commit 1: Backend SQL Layer ``` -feat: add tags_filter to search query +feat: add fuzzy tags_filter to search query -- Add tags_filter parameter to SearchMediaItemsUnified +- Add fuzzy tags_filter parameter to SearchMediaItemsUnified +- Add EXISTS clause with word_similarity() for fuzzy tag matching +- Add tag similarity scoring to ORDER BY clause - Add SearchTagsValues query for autocomplete - Keep genre_filter for backward compatibility