docs: complete unified search implementation plan with Phase 4 specifications

This commit finalizes the implementation plan with complete code
specifications for the remaining work needed to complete the unified
search and filter feature.

**Phase 4 Specifications Added:**

1. **Backend Handler (4.1):**
   - Complete SearchMediaItems handler rewrite with autocomplete detection
   - New handleFieldValuesSearch method for dropdown suggestions
   - Fixed QueryParam bugs (Echo doesn't support default values)
   - Autocomplete query routing: author=value, genre=value, etc.
   - Service layer integration for combined search + filters

2. **Frontend Templates (4.2-4.3):**
   - Search button + Enter key triggers (no blur/immediate filtering)
   - Pure HTML5 datalist approach (no Alpine.js reactive state)
   - All filter inputs with autocomplete support
   - Clear filters button for UX
   - Updated HTMX triggers from 'change' to 'keyup[key=="Enter"]'

3. **Frontend TypeScript (4.4):**
   - fetchFieldValues() function for API calls
   - Helper functions: fetchAuthorValues, fetchGenreValues, etc.
   - Native DOM manipulation for fastest performance (~1-2ms)
   - Fixed query param names to singular (author, genre, series, language)

**Implementation Status Section Added:**
- Clear tracking of completed (Phases 1-3), partial (Phase 4), and not started work
- Implementation order with time estimates (~3 hours remaining)
- Updated timeline: ~10 hours total, ~7 hours remaining

**Bug Fixes in Plan:**
- Fixed c.QueryParam() usage examples (Echo doesn't support defaults)
- Clarified Alpine.js vs native DOM approach conflict
- Removed conflicting reactive state from template specifications

**Documentation:**
- Complete code examples ready to copy/paste
- Performance analysis showing HTML5 datalist is fastest approach
- User flow documentation for autocomplete + search button UX
This commit is contained in:
2026-03-23 21:02:27 -04:00
parent b43e47139b
commit b607cfc387
+414 -63
View File
@@ -16,6 +16,54 @@ Autocomplete dropdowns use 4 separate simple queries (one per field type) instea
---
## Implementation Status (as of March 23, 2026)
### ✅ COMPLETED
- **Phase 1:** Database Schema - GIN indexes for pg_trgm fuzzy search
- **Phase 2:** SQL Queries - SearchMediaItemsUnified + 4 field value queries
- **Phase 3:** Search Service - SearchMediaItemsUnified() + SearchFieldValues() methods
- **Phase 8:** No Changes Needed - Correctly follows established patterns
### ⚠️ PARTIAL (Ready to Implement)
- **Phase 4.1:** Backend Handler - Code specified in plan, ready to implement
- ✅ Complete handler code with autocomplete detection
- ✅ Complete handleFieldValuesSearch() method
- ✅ Fixed QueryParam bug (echo doesn't support default values)
- ⏳ Ready to paste into media.go
- **Phase 4.2-4.3:** Frontend Templates - Updated in plan with search button approach
- ✅ Search button + Enter key triggers (no blur trigger)
- ✅ Pure HTML5 datalist (no Alpine.js state)
- ✅ All filter inputs with autocomplete
- ⏳ Ready to paste into bookshelf.templ
- **Phase 4.4:** Frontend TypeScript - Specified in plan
- ✅ fetchFieldValues() function with native DOM manipulation
- ✅ fetchAuthorValues(), fetchGenreValues(), etc. helper functions
- ✅ Fixed query param names (singular: author, genre, etc.)
- ⏳ Ready to add to bookshelf.ts
### ❌ NOT STARTED
- **Phase 5:** Frontend Build Verification
- **Phase 6:** Tests - search_unified_test.go needs to be created
- **Phase 7:** Documentation Updates - docs/developer/api/media-items/search_media_items.md needs update
- **Phase 9:** Cleanup - Remove deprecated /filtered endpoint after testing
- **Phase 10:** Verification - Manual testing of all features
### 📋 IMPLEMENTATION ORDER (Recommended)
1. **Phase 4.1** - Backend Handler (media.go) - ~30 min
2. **Phase 4.2-4.3** - Frontend Templates (bookshelf.templ) - ~20 min
3. **Phase 4.4** - Frontend TypeScript (bookshelf.ts) - ~15 min
4. **Phase 5** - Build & Verify (npm run build, go build) - ~5 min
5. **Phase 6** - Manual Testing (autocomplete, filters, search) - ~15 min
6. **Phase 7** - Tests (create search_unified_test.go) - ~1 hour
7. **Phase 8** - Documentation updates - ~30 min
8. **Phase 9** - Cleanup (remove /filtered) - ~10 min
**Total remaining: ~3 hours**
---
## Current State Analysis
### Existing Endpoints
@@ -473,20 +521,182 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
**File:** `internal/handlers/media.go`
#### 4.1 Fix Search Box Endpoint
#### 4.1 Update SearchMediaItems Handler to Use SearchService
**Replace the entire `SearchMediaItems` function (lines 1421-1495) with:**
```go
// SearchMediaItems handles GET /api/media-items/search
// Supports two modes:
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
// 2. Search: q=value with optional filters → returns media items
func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
// Safely get user from context
userID, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
// Detect autocomplete queries first (author=value, genre=value, etc.)
if author := c.QueryParam("author"); author != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
}
if genre := c.QueryParam("genre"); genre != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
}
if series := c.QueryParam("series"); series != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
}
if language := c.QueryParam("language"); language != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
}
// Handle media item search with filters
query := c.QueryParam("q")
libraryID := c.QueryParam("library_id")
// Validate library_id if provided
var libUUID pgtype.UUID
if libraryID != "" {
lib, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
}
limitStr := c.QueryParam("limit")
limit, _ := strconv.Atoi(limitStr)
if limit == 0 {
limit = 50
}
offsetStr := c.QueryParam("offset")
offset, _ := strconv.Atoi(offsetStr)
// Extract filter parameters
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
languageFilter := c.QueryParam("language_filter")
yearMinStr := c.QueryParam("year_min")
yearMin, _ := strconv.Atoi(yearMinStr)
yearMaxStr := c.QueryParam("year_max")
yearMax, _ := strconv.Atoi(yearMaxStr)
hasCover := c.QueryParam("has_cover") == "true"
// Build search params
params := services.SearchParams{
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: hasCover,
Limit: limit,
Offset: offset,
}
// Call SearchService instead of DB directly
results, err := mh.searchService.SearchMediaItemsUnified(c.Request().Context(), params)
if err != nil && err != pgx.ErrNoRows {
c.Logger().Error("search error", "error", err.Error())
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if len(results) == 0 {
return c.JSON(http.StatusNotFound, map[string]interface{}{
"error": "no results found",
"query": query,
"results": []interface{}{},
})
}
return c.JSON(http.StatusOK, results)
}
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
// Returns distinct field values with counts and similarity scores for dropdown population
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
limitStr := c.QueryParam("limit")
limit, _ := strconv.Atoi(limitStr)
if limit == 0 {
limit = 50
}
offsetStr := c.QueryParam("offset")
offset, _ := strconv.Atoi(offsetStr)
params := services.FieldSearchParams{
UserID: userID,
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FieldType: fieldType,
SearchQuery: searchQuery,
Limit: limit,
Offset: offset,
}
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
if err != nil {
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
})
}
```
#### 4.2 Fix Search Box and Pagination
**File:** `templates/bookshelf.templ`
**Change line 79:**
**Update global search box (around line 73-83):**
```html
<!-- BEFORE -->
<input ... hx-get="/api/media-items/filtered" ... >
<input
name="search"
type="text"
placeholder="Search all fields..."
hx-get="/api/media-items/filtered"
hx-trigger="keyup changed delay:300ms"
hx-include="#filter-form"
/>
<!-- AFTER -->
<input ... hx-get="/api/media-items/search" ... >
<input
name="q"
type="text"
placeholder="Search all fields..."
hx-get="/api/media-items/search"
hx-trigger="keyup[key=='Enter'] from:#search-form, keyup changed delay:500ms"
hx-target="#books-grid"
hx-include="#filter-form, #library-select"
/>
```
**Also fix pagination buttons (lines 298, 311):**
**Update pagination buttons (lines 298, 311):**
```html
<!-- BEFORE -->
hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
@@ -495,56 +705,195 @@ hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
```
**Change filter field names (lines 92, 109, 126, etc.):**
```html
<!-- BEFORE -->
<input name="author_filter" ... hx-get="/api/media-items/filtered" ... >
<input name="genre_filter" ... hx-get="/api/media-items/filtered" ... >
**Note:** Change parameter name from `search` to `q` to match backend handler expectation.
<!-- AFTER -->
<input name="author_filter" ... hx-get="/api/media-items/search" ... >
<input name="genre_filter" ... hx-get="/api/media-items/search" ... >
```
#### 4.2 Add Autocomplete Dropdowns
#### 4.3 Add Autocomplete Dropdowns with Search Button
**File:** `templates/bookshelf.templ`
**Replace author filter (lines 85-101):**
**Replace filter form section (includes all filters) with:**
```html
<!-- Author Filter with Autocomplete -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Author
</label>
<div class="relative" x-data="{ authorValues: [] }">
<input
type="text"
name="author_filter"
placeholder="Filter by author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<!-- Filter Form with Autocomplete + Search Button -->
<form id="filter-form">
<div class="flex flex-wrap gap-4 mb-4">
<!-- Author Filter -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Author
</label>
<input
type="text"
name="author_filter"
placeholder="Filter by author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#filter-form"
/>
<datalist id="author-datalist"></datalist>
</div>
<!-- Genre Filter -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Genre
</label>
<input
type="text"
name="genre_filter"
placeholder="Filter by genre"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="genre-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#filter-form"
/>
<datalist id="genre-datalist"></datalist>
</div>
<!-- Series Filter -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Series
</label>
<input
type="text"
name="series_filter"
placeholder="Filter by series"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#filter-form"
/>
<datalist id="series-datalist"></datalist>
</div>
<!-- Language Filter -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Language
</label>
<input
type="text"
name="language_filter"
placeholder="Filter by language"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#filter-form"
/>
<datalist id="language-datalist"></datalist>
</div>
<!-- Year Range Filters -->
<div class="flex-1 min-w-[100px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Year From
</label>
<input
type="number"
name="year_min"
placeholder="From"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#filter-form"
/>
</div>
<div class="flex-1 min-w-[100px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Year To
</label>
<input
type="number"
name="year_max"
placeholder="To"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#filter-form"
/>
</div>
<!-- Has Cover Filter -->
<div class="flex-1 min-w-[120px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Has Cover
</label>
<select
name="has_cover"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-trigger="change from:#filter-form"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#filter-form"
>
<option value="">All</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<!-- Search Button -->
<div class="mt-4">
<button
type="button"
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
list="author-datalist"
@focus="fetchAuthorValues($el)"
@input.debounce.300ms="if($el.value === '') authorValues = []"
/>
<datalist id="author-datalist">
<template x-for="item in authorValues" :key="item.value">
<option :value="item.value" x-text="`${item.value} (${item.count})`"></option>
</template>
</datalist>
>
Search
</button>
<button
type="button"
class="ml-2 px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-include="#library-select"
onclick="document.querySelectorAll('#filter-form input').forEach(i => i.value = '')"
>
Clear Filters
</button>
</div>
</div>
</form>
```
**Similar changes for genre, series, language filters**
**Key changes:**
- ❌ No Alpine.js `x-data` wrapper (removed)
- ❌ No `hx-trigger="change"` (no immediate filtering)
- ✅ Added `@input.debounce.300ms` for autocomplete (300ms delay)
- ✅ Added `hx-trigger="keyup[key=='Enter'] from:#filter-form"` (Enter key only)
- ✅ All filter inputs wrapped in `<form id="filter-form">`
- ✅ Search button with `hx-get` to trigger filtering
- ✅ Clear filters button to reset all inputs
- ✅ Empty `<datalist>` elements (populated by JavaScript)
-`list="author-datalist"` attribute for native HTML5 autocomplete
#### 4.3 Add Frontend Functions
#### 4.4 Add Frontend Functions
**File:** `web/src/bookshelf.ts`
@@ -615,13 +964,13 @@ async function fetchFieldValues(
// Fetch author values for autocomplete
async function fetchAuthorValues(input: HTMLInputElement): Promise<void> {
const search = input.value;
await fetchFieldValues("authors", search, "author-datalist");
await fetchFieldValues("author", search, "author-datalist");
}
// Fetch genre values for autocomplete
async function fetchGenreValues(input: HTMLInputElement): Promise<void> {
const search = input.value;
await fetchFieldValues("genres", search, "genre-datalist");
await fetchFieldValues("genre", search, "genre-datalist");
}
// Fetch series values for autocomplete
@@ -633,7 +982,7 @@ async function fetchSeriesValues(input: HTMLInputElement): Promise<void> {
// Fetch language values for autocomplete
async function fetchLanguageValues(input: HTMLInputElement): Promise<void> {
const search = input.value;
await fetchFieldValues("languages", search, "language-datalist");
await fetchFieldValues("language", search, "language-datalist");
}
// Register functions globally
@@ -650,7 +999,7 @@ cd web && npm run build
---
### Phase 5: Tests
### Phase 6: Tests
**File:** `cmd/server/tests/search_unified_test.go` (NEW)
@@ -768,7 +1117,7 @@ go test ./cmd/server/tests -v -run TestUnifiedSearch
---
### Phase 6: Documentation Updates
### Phase 7: Documentation Updates
#### 6.1 Update API Documentation
@@ -1005,7 +1354,7 @@ params:
---
### Phase 7: No Changes Needed (Skip)
### Phase 8: No Changes Needed (Skip)
**No changes required to:**
- `cmd/server/main.go`
@@ -1021,7 +1370,7 @@ SearchService follows the pattern of `FiltersService` and `CollectionService` -
---
### Phase 8: Cleanup
### Phase 9: Cleanup
#### 7.1 Delete Deprecated Filtered Endpoint
@@ -1248,8 +1597,9 @@ git commit -m "chore: final verification of unified search implementation
1. **Saved filters breaking:** Frontend-only, should work seamlessly
2. **Performance degradation:** GIN indexes should prevent this
3. **Breaking mobile apps:** `/filtered` endpoint will be deleted
4. **Test coverage gaps:** Comprehensive tests in Phase 5
3. **Breaking mobile apps:** `/filtered` endpoint will be deleted in Phase 9
4. **Test coverage gaps:** Comprehensive tests in Phase 6
5. **Handler integration incomplete:** Phase 4.1 handler updates were missing from original plan (NOW ADDED)
### Rollback Plan
@@ -1267,19 +1617,20 @@ git show HEAD~1:templates/bookshelf.templ > templates/bookshelf.templ
## Timeline Estimate
- Phase 1 (Database): 30 minutes
- Phase 2 (SQL Queries): 1 hour
- Phase 3 (Search Service): 2 hours
- Phase 4 (Handler Update): 1 hour
- Phase 5 (No Changes): 0 minutes
- Phase 6 (Frontend): 2 hours
- Phase 7 (TypeScript): 1 hour
- Phase 8 (Tests): 2 hours
- Phase 9 (Documentation): 1 hour
- Phase 10 (Cleanup): 30 minutes
- Phase 11 (Verification): 1 hour
- Phase 1 (Database): 30 minutes ✅ COMPLETED
- Phase 2 (SQL Queries): 1 hour ✅ COMPLETED
- Phase 3 (Search Service): 2 hours ✅ COMPLETED
- Phase 4 (Handler + Frontend): 2 hours ⚠️ PARTIAL (code specified, ready to implement)
- Phase 5 (Frontend Build): 10 minutes ❌ NOT STARTED
- Phase 6 (Tests): 2 hours ❌ NOT STARTED
- Phase 7 (Documentation): 1 hour ❌ NOT STARTED
- Phase 8 (No Changes): 0 minutes ✅ SKIPPED
- Phase 9 (Cleanup): 30 minutes ❌ NOT STARTED
- Phase 10 (Verification): 1 hour ❌ NOT STARTED
**Total: ~11.5 hours**
**Total: ~10 hours**
**Completed: ~3.5 hours** (Phases 1-3)
**Remaining: ~3 hours** (Phases 4-10, all code specified, just implementation)
---