# Unified Search and Filter Implementation Plan ## Executive Summary **Goal:** Consolidate `/api/media-items/filtered` and `/api/media-items/search` endpoints into a single unified `/api/media-items/search` endpoint that supports: - All-fuzzy filters (except years/booleans) - Google-style "exact match in quotes" for search queries - Field-specific fuzzy search for autocomplete dropdowns - Combined search + filters functionality - Backward compatibility with saved filters **Approach:** Surgical, incremental changes that reuse existing code, following PROJECT_GUIDELINES.md strictly. **Technical Note - sqlc v1.30.0 Limitation:** Autocomplete dropdowns use 4 separate simple queries (one per field type) instead of 1 complex query due to sqlc v1.30.0's inability to parse complex CASE expressions in GROUP BY clauses. This approach is simpler, works correctly with the current sqlc version, and the service layer routes to the appropriate query based on field type. --- ## 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 #### 1. `/api/media-items/search` (internal/router/search.go:11) - **Purpose:** Global fuzzy search across all libraries - **Handler:** `SearchMediaItems` (internal/handlers/media.go:1419-1493) - **Logic:** - Uses `SearchMediaItems` query (ILIKE pattern matching) - Falls back to `SearchMediaItemsFuzzy` query (pg_trgm `word_similarity()`) - Threshold: 0.3 similarity - **Parameters:** `q`, `library_id`, `limit`, `offset` - **Used by:** Search box (incorrectly - currently calls `/filtered`) #### 2. `/api/media-items/filtered` (internal/router/media.go:17) - **Purpose:** Exact match filtering - **Handler:** `ListMediaItemsFiltered` (internal/handlers/media.go:705-766) - **Logic:** - `ListMediaItemsFiltered` query (queries.sql:227-268) - Author/series: `ILIKE` (case-insensitive, NO wildcards) - Genre/language: `=` (exact match) - Years: `>=`, `<=` (exact range) - Boolean: exact match - **Parameters:** `author_filter`, `series_filter`, `genre_filter`, `language_filter`, `year_min`, `year_max`, `has_cover`, `sort`, `limit`, `offset` - **Used by:** All filter fields, search box (incorrectly) ### Current Frontend Implementation **Search box** (templates/bookshelf.templ:73-83): ```html hx-trigger="keyup changed delay:300ms" hx-include="#filter-form"> ``` **Filter fields** (templates/bookshelf.templ:90-117): ```html ``` ### Database Schema **Indexes exist** (database/schema/schema.sql): - B-tree indexes: author, genre, language, series, copyright_year (lines 365-373) - GIN indexes: tags_search, contributors_search (lines 145-146) **Missing:** GIN indexes for pg_trgm fuzzy search on text fields ### Saved Filters **Implementation:** Frontend-only (web/src/bookshelf.ts:44-173) - Stores filter config as JSON in database - Loads filter config into form via `loadFilter()` method - Makes API call to `/api/saved-filters/:id` - Populates form fields with exact values **Impact:** Should work seamlessly with new endpoint (only field names matter) --- ## Implementation Phases ### Phase 1: Database Schema - Add GIN Indexes **File:** `database/schema/schema.sql` **Add after line 146:** ```sql -- Add GIN indexes for pg_trgm fuzzy search performance CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm ON media_items USING GIN (author gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_title_trgm ON media_items USING GIN (title gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_series_trgm ON media_items USING GIN (series gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_genre_trgm ON media_items USING GIN (genre gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_language_trgm ON media_items USING GIN (language gin_trgm_ops); ``` **Verification:** ```bash podman compose down -v podman compose up -d podman exec bookhoard_db psql -U postgres -d bookhoard -c "\d+ media_items" ``` --- ### Phase 2: SQL Queries - Add Unified Search Query **File:** `internal/database/queries/queries.sql` **Add after `SearchMediaItemsFuzzy` (line 460):** ```sql -- name: SearchMediaItemsUnified :many SELECT mi.*, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.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 mi.library_id = sqlc.narg('library_id') -- Fuzzy author filter AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3) -- Fuzzy series filter AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3) -- Fuzzy genre filter 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) -- 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')) -- Boolean (exact) AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL) -- Search query (fuzzy or exact based on quotes) AND ( sqlc.narg('search_query') = '' OR -- Fuzzy search (default) sqlc.narg('is_exact_search') = false AND ( word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR EXISTS ( SELECT 1 FROM unnest(mi.tags_search) AS tag WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3 LIMIT 1 ) OR EXISTS ( SELECT 1 FROM unnest(mi.contributors_search) AS contributor WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3 LIMIT 1 ) ) OR -- Exact search (with quotes) sqlc.narg('is_exact_search') = true AND ( mi.title ILIKE sqlc.narg('search_pattern') OR mi.author ILIKE sqlc.narg('search_pattern') OR mi.series ILIKE sqlc.narg('search_pattern') OR sqlc.narg('search_pattern') = ANY(mi.tags_search) OR sqlc.narg('search_pattern') = ANY(mi.contributors_search) ) ) ORDER BY CASE WHEN sqlc.narg('search_query') != '' THEN 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, '')) ) ELSE 0 END DESC, mi.title ASC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); ``` **Add 4 separate field value search queries (for autocomplete dropdowns):** **Note:** Using 4 separate simple queries instead of 1 complex query due to sqlc v1.30.0 limitation with CASE expressions in GROUP BY clauses. This approach is simpler and works correctly with the current sqlc version. ```sql -- name: SearchAuthorValues :many SELECT mi.author as value, COUNT(*) as count, word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))::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 mi.library_id = sqlc.narg('library_id') AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 AND mi.author IS NOT NULL AND mi.author != '' GROUP BY mi.author ORDER BY score DESC, count DESC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: SearchGenreValues :many SELECT mi.genre as value, COUNT(*) as count, word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))::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 mi.library_id = sqlc.narg('library_id') AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3 AND mi.genre IS NOT NULL AND mi.genre != '' GROUP BY mi.genre ORDER BY score DESC, count DESC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: SearchSeriesValues :many SELECT mi.series as value, COUNT(*) as count, word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::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 mi.library_id = sqlc.narg('library_id') AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 AND mi.series IS NOT NULL AND mi.series != '' GROUP BY mi.series ORDER BY score DESC, count DESC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: SearchLanguageValues :many SELECT mi.language as value, COUNT(*) as count, word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))::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 mi.library_id = sqlc.narg('library_id') AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3 AND mi.language IS NOT NULL AND mi.language != '' GROUP BY mi.language ORDER BY score DESC, count DESC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); ``` **Regenerate Go code:** ```bash sqlc generate ``` **Verify:** Check `internal/database/queries.sql.go` for new functions: `SearchAuthorValues`, `SearchGenreValues`, `SearchSeriesValues`, `SearchLanguageValues` --- ### Phase 3: Create Search Service **File:** `internal/services/search.go` (NEW) ```go package services import ( "bookhoard/internal/database" "context" "strconv" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) // SearchService handles all search and filter operations type SearchService struct { db *database.Queries } // NewSearchService creates a new search service instance func NewSearchService(db *database.Queries) *SearchService { return &SearchService{db: db} } // SearchParams contains parameters for unified search type SearchParams struct { UserID pgtype.UUID LibraryID pgtype.UUID AuthorFilter string SeriesFilter string GenreFilter string LanguageFilter string YearMin int YearMax int HasCover bool SearchQuery string Limit int Offset int } // 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) searchPattern := "" if isExact { searchPattern = "%" + searchQuery + "%" } // Build database parameters dbParams := database.SearchMediaItemsUnifiedParams{ UserID: params.UserID, LibraryID: params.LibraryID, AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true}, LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, HasCover: pgtype.Bool{Bool: params.HasCover, Valid: true}, SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact}, Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, } // Execute unified search query results, err := s.db.SearchMediaItemsUnified(ctx, dbParams) if err != nil { return nil, err } return results, 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 represents a single field value with metadata type FieldValue struct { Value string Count int64 Score float64 } // 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: params.SearchQuery, UserID: params.UserID, LibraryID: params.LibraryID, Limit: int32(params.Limit), Offset: int32(params.Offset), }) 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 "genre": results, err := s.db.SearchGenreValues(ctx, database.SearchGenreValuesParams{ SearchQuery: params.SearchQuery, UserID: params.UserID, LibraryID: params.LibraryID, Limit: int32(params.Limit), Offset: int32(params.Offset), }) 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: params.SearchQuery, UserID: params.UserID, LibraryID: params.LibraryID, Limit: int32(params.Limit), Offset: int32(params.Offset), }) 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 "language": results, err := s.db.SearchLanguageValues(ctx, database.SearchLanguageValuesParams{ SearchQuery: params.SearchQuery, UserID: params.UserID, LibraryID: params.LibraryID, Limit: int32(params.Limit), Offset: int32(params.Offset), }) 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 default: return []FieldValue{}, nil } } ``` **Note:** SearchService is created inside the constructor (not in main.go), following the pattern of `FiltersService` and `CollectionService` which are created inside their respective handlers. --- ### Phase 4: Update Handler to Use Search Service **File:** `internal/handlers/media.go` #### 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` **Update global search box (around line 73-83):** ```html ``` **Update pagination buttons (lines 298, 311):** ```html hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }" hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }" ``` **Note:** Change parameter name from `search` to `q` to match backend handler expectation. #### 4.3 COMPLETE IMPLEMENTATION - All Fixes in One Section **IMPORTANT:** This section contains ALL code changes needed to preserve your sort/save/load features while adding autocomplete. Implement everything in this section only - no need to modify earlier phases. --- ### Step 1: Update SQL Query to Support Sort Parameter **File:** `internal/database/queries/queries.sql` **Find the `SearchMediaItemsUnified` query (you added this in Phase 2) and replace the ORDER BY clause:** **BEFORE:** ```sql ORDER BY CASE WHEN sqlc.narg('search_query') != '' THEN GREATEST(...) ELSE 0 END DESC, mi.title ASC ``` **AFTER:** ```sql ORDER BY -- Primary sort: relevance score when searching CASE WHEN sqlc.narg('search_query') != '' THEN 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, '')) ) ELSE 0 END DESC, -- Secondary sort: user-specified sort parameter CASE WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title ELSE '' END ASC, CASE WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title ELSE '' END DESC, CASE WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '') ELSE '' END ASC, CASE WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '') ELSE '' END DESC, CASE WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at ELSE '1970-01-01'::timestamp END ASC, CASE WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at ELSE '1970-01-01'::timestamp END DESC, CASE WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END ASC, CASE WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END DESC, -- Tertiary sort: title (default fallback) mi.title ASC ``` **Regenerate Go code:** ```bash sqlc generate ``` --- ### Step 2: Update Search Service to Support Sort **File:** `internal/services/search.go` **Add `Sort` field to `SearchParams` struct:** ```go // SearchParams contains parameters for unified search type SearchParams struct { UserID pgtype.UUID LibraryID pgtype.UUID AuthorFilter string SeriesFilter string GenreFilter string LanguageFilter string YearMin int YearMax int HasCover bool SearchQuery string Sort string // ADD THIS LINE Limit int Offset int } ``` **Update the database parameters in `SearchMediaItemsUnified` method:** ```go // Build database parameters dbParams := database.SearchMediaItemsUnifiedParams{ UserID: params.UserID, LibraryID: params.LibraryID, AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true}, LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, HasCover: pgtype.Bool{Bool: params.HasCover, Valid: true}, 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}, // ADD THIS LINE Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, } ``` --- ### Step 3: Update Handler to Extract Sort Parameter **File:** `internal/handlers/media.go` **In the `SearchMediaItems` handler (you added this in Phase 4.1), add sort parameter extraction:** **Find this section:** ```go hasCover := c.QueryParam("has_cover") == "true" // Build search params params := services.SearchParams{ ``` **Replace with:** ```go hasCover := c.QueryParam("has_cover") == "true" // Extract sort parameter sortParam := c.QueryParam("sort") if sortParam == "" { sortParam = "title ASC" // Default sort } // Build search params params := services.SearchParams{ ``` **Then add `Sort` to the params struct:** ```go params := services.SearchParams{ UserID: userID.ID, LibraryID: libUUID, SearchQuery: query, AuthorFilter: authorFilter, SeriesFilter: seriesFilter, GenreFilter: genreFilter, LanguageFilter: languageFilter, YearMin: yearMin, YearMax: yearMax, HasCover: hasCover, Sort: sortParam, // ADD THIS LINE Limit: limit, Offset: offset, } ``` --- ### Step 4: Update Template with All Filters + Autocomplete **File:** `templates/bookshelf.templ` **Replace lines 83-262 (from Search input through end of filter bar) with:** ```html