- Update implementation status to reflect completed phases (1-9) - Document Section 4.3 completion (all 5 steps: SQL sort support, service sort, handler sort, template filters, TypeScript functions) - Add discovery notes about SQL duplicate ORDER BY fix and frontend.go compatibility - Note Bruno files are for API interaction, not automated testing - Document remaining work (Phase 10 manual testing) Plan provides complete roadmap for consolidating /filtered and /search endpoints into unified fuzzy search with autocomplete dropdowns.
1925 lines
64 KiB
Markdown
1925 lines
64 KiB
Markdown
# 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
|
|
<input name="search"
|
|
hx-get="/api/media-items/filtered" <!-- WRONG: Should be /search -->
|
|
hx-trigger="keyup changed delay:300ms"
|
|
hx-include="#filter-form">
|
|
```
|
|
|
|
**Filter fields** (templates/bookshelf.templ:90-117):
|
|
```html
|
|
<input name="author_filter"
|
|
hx-get="/api/media-items/filtered"
|
|
hx-include="#filter-form">
|
|
|
|
<input name="genre_filter"
|
|
hx-get="/api/media-items/filtered"
|
|
hx-include="#filter-form">
|
|
```
|
|
|
|
### 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
|
|
<!-- BEFORE -->
|
|
<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
|
|
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"
|
|
/>
|
|
```
|
|
|
|
**Update pagination buttons (lines 298, 311):**
|
|
|
|
```html
|
|
<!-- BEFORE -->
|
|
hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
|
|
|
<!-- AFTER -->
|
|
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
|
|
<!-- Search Input -->
|
|
<div class="flex-1 min-w-[200px]">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Search
|
|
</label>
|
|
<input
|
|
name="q"
|
|
type="text"
|
|
placeholder="Search all fields..."
|
|
hx-get="/api/media-items/search"
|
|
hx-trigger="keyup[key=='Enter'] from:#filter-form, keyup changed delay:500ms"
|
|
hx-target="#books-grid"
|
|
hx-include="#filter-form, #library-select"
|
|
/>
|
|
</div>
|
|
<!-- 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>
|
|
<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);"
|
|
hx-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
|
hx-include="#filter-form"
|
|
list="author-datalist"
|
|
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
|
/>
|
|
<datalist id="author-datalist"></datalist>
|
|
</div>
|
|
<!-- Genre Filter with Autocomplete -->
|
|
<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);"
|
|
hx-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
|
hx-include="#filter-form"
|
|
list="genre-datalist"
|
|
@input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
|
|
/>
|
|
<datalist id="genre-datalist"></datalist>
|
|
</div>
|
|
<!-- Series Filter with Autocomplete (NEW) -->
|
|
<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);"
|
|
hx-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
|
hx-include="#filter-form"
|
|
list="series-datalist"
|
|
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
|
/>
|
|
<datalist id="series-datalist"></datalist>
|
|
</div>
|
|
<!-- Language Filter with Autocomplete (NEW) -->
|
|
<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);"
|
|
hx-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
|
hx-include="#filter-form"
|
|
list="language-datalist"
|
|
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
|
/>
|
|
<datalist id="language-datalist"></datalist>
|
|
</div>
|
|
<!-- Year Range -->
|
|
<div class="flex-1 min-w-[200px]">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Year Range
|
|
</label>
|
|
<div class="flex gap-2">
|
|
<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-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
|
hx-include="#filter-form"
|
|
/>
|
|
<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-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
|
hx-include="#filter-form"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- Has Cover Filter -->
|
|
<div class="flex items-end">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
name="has_cover"
|
|
value="true"
|
|
class="w-4 h-4 rounded"
|
|
hx-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="change"
|
|
hx-include="#filter-form"
|
|
/>
|
|
<span class="text-sm" style="color: var(--text-primary)">Has Cover</span>
|
|
</label>
|
|
</div>
|
|
<!-- Sort By Dropdown (PRESERVED) -->
|
|
<div class="flex-1 min-w-[150px]">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Sort By
|
|
</label>
|
|
<select
|
|
name="sort"
|
|
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-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="change"
|
|
hx-include="#filter-form"
|
|
>
|
|
<option value="title ASC">Title (A-Z)</option>
|
|
<option value="title DESC">Title (Z-A)</option>
|
|
<option value="author ASC">Author (A-Z)</option>
|
|
<option value="created_at DESC">Date Added</option>
|
|
<option value="page_count DESC">Page Count</option>
|
|
</select>
|
|
</div>
|
|
<!-- Save Filter Button (PRESERVED) -->
|
|
<div class="flex items-end">
|
|
<button
|
|
@click="showSaveFilterModal()"
|
|
class="px-4 py-2 rounded-lg font-medium"
|
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
>
|
|
💾 Save Filter
|
|
</button>
|
|
</div>
|
|
<!-- Load Filter Button (PRESERVED) -->
|
|
<div class="flex items-end relative">
|
|
<button
|
|
@click="toggleFiltersDropdown()"
|
|
class="px-4 py-2 rounded-lg font-medium border"
|
|
style="border-color: var(--border); color: var(--text-primary);"
|
|
>
|
|
📂 Load Filter
|
|
</button>
|
|
<!-- Saved Filters Dropdown (PRESERVED) -->
|
|
<div
|
|
x-show="showFiltersDropdown"
|
|
@click.outside="showFiltersDropdown = false"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
class="absolute top-full mt-2 right-0 w-80 rounded-lg shadow-lg z-50"
|
|
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
|
>
|
|
<div class="p-4">
|
|
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">
|
|
Saved Filters
|
|
</h3>
|
|
<!-- Filter List -->
|
|
<div class="space-y-2" id="saved-filters-list">
|
|
for _, filter := range savedFilters {
|
|
filterUUID := string(filter.ID.Bytes[0:16])
|
|
<div class="flex items-center justify-between p-2 rounded hover:opacity-80" style="background-color: var(--bg-primary);" data-filter-id={ uuidToString(filter.ID) }>
|
|
<button data-action="load-filter" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
|
|
{ filter.Name }
|
|
</button>
|
|
<button data-action="delete-filter" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
<!-- Empty State -->
|
|
if len(savedFilters) == 0 {
|
|
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
|
|
No saved filters yet
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Clear Filters Button (PRESERVED) -->
|
|
<div class="flex items-end">
|
|
<button
|
|
@click="clearFilters()"
|
|
class="px-4 py-2 rounded-lg font-medium border"
|
|
style="border-color: var(--border); color: var(--text-primary);"
|
|
>
|
|
✕ Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Hidden form for HTMX include (PRESERVED) -->
|
|
<form id="filter-form" class="hidden">
|
|
<input type="hidden" name="limit" value="50"/>
|
|
<input type="hidden" name="offset" value="0"/>
|
|
</form>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### Step 5: Add Autocomplete Functions to TypeScript
|
|
|
|
**File:** `web/src/bookshelf.ts`
|
|
|
|
**Add these functions to the existing `Alpine.data("bookshelf", () => ({ ... }))` component:**
|
|
|
|
```typescript
|
|
Alpine.data("bookshelf", () => ({
|
|
// ... existing state and methods ...
|
|
|
|
// Autocomplete helper function
|
|
async fetchFieldValues(
|
|
field: string,
|
|
search: string,
|
|
datalistId: string
|
|
): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
console.error("Not authenticated");
|
|
return;
|
|
}
|
|
|
|
if (search.length < 2) return;
|
|
|
|
const currentLibraryId = (
|
|
document.getElementById("library-select") as HTMLSelectElement
|
|
)?.value;
|
|
if (!currentLibraryId) {
|
|
console.error("No library selected");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/media-items/search?${field}=${encodeURIComponent(
|
|
search
|
|
)}&library_id=${currentLibraryId}&limit=50`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
console.error("Failed to fetch field values");
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Update datalist via DOM manipulation
|
|
const datalist = document.getElementById(datalistId);
|
|
if (!datalist) {
|
|
console.error(`Datalist ${datalistId} not found`);
|
|
return;
|
|
}
|
|
|
|
// Clear existing options
|
|
datalist.innerHTML = "";
|
|
|
|
// Add new options
|
|
data.results.forEach((item: { value: string; count: number }) => {
|
|
const option = document.createElement("option");
|
|
option.value = item.value;
|
|
option.textContent = `${item.value} (${item.count})`;
|
|
datalist.appendChild(option);
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching field values:", error);
|
|
}
|
|
},
|
|
|
|
// Fetch author values for autocomplete
|
|
async fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
|
await this.fetchFieldValues("author", input.value, "author-datalist");
|
|
},
|
|
|
|
// Fetch genre values for autocomplete
|
|
async fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
|
await this.fetchFieldValues("genre", input.value, "genre-datalist");
|
|
},
|
|
|
|
// Fetch series values for autocomplete
|
|
async fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
|
await this.fetchFieldValues("series", input.value, "series-datalist");
|
|
},
|
|
|
|
// Fetch language values for autocomplete
|
|
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
|
await this.fetchFieldValues("language", input.value, "language-datalist");
|
|
},
|
|
|
|
// ... rest of existing methods ...
|
|
}))
|
|
```
|
|
|
|
**Note:** These are regular methods (not reactive), so they won't trigger template re-renders. They just manipulate the DOM directly to populate the `<datalist>` elements. The functions are available in the template because they're part of the Alpine component scope.
|
|
|
|
|
|
---
|
|
|
|
## Summary of Changes
|
|
|
|
**What this section does:**
|
|
1. ✅ Adds sort parameter support to SQL query (Step 1)
|
|
2. ✅ Updates service to pass sort parameter (Step 2)
|
|
3. ✅ Updates handler to extract sort parameter (Step 3)
|
|
4. ✅ Adds series and language filters with autocomplete (Step 4)
|
|
5. ✅ Preserves sort dropdown, save/load buttons, modal (Step 4)
|
|
6. ✅ Changes triggers to Enter key instead of instant (Step 4)
|
|
7. ✅ Adds TypeScript autocomplete functions using simple pattern (Step 5)
|
|
|
|
**What's preserved:**
|
|
- ✅ Your sort dropdown (all 5 options)
|
|
- ✅ Save Filter button and modal
|
|
- ✅ Load Filter button and dropdown
|
|
- ✅ Clear Filters button
|
|
- ✅ Your existing save/load filter logic
|
|
|
|
**What's new:**
|
|
- ✅ Series filter with autocomplete
|
|
- ✅ Language filter with autocomplete
|
|
- ✅ Genre filter autocomplete
|
|
- ✅ Sort parameter support in unified search
|
|
- ✅ **Simple autocomplete pattern**: Empty `<datalist>` elements populated via DOM manipulation (no Alpine.js reactive state)
|
|
|
|
#### 4.4 NOTE - All Frontend Code is in Section 4.3
|
|
|
|
All TypeScript autocomplete functions are included in **Section 4.3, Step 5** above.
|
|
|
|
No additional changes needed to `web/src/bookshelf.ts` beyond what's in Section 4.3.
|
|
|
|
---
|
|
|
|
### Phase 6: Tests
|
|
|
|
**File:** `cmd/server/tests/search_unified_test.go` (NEW)
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestUnifiedSearch(t *testing.T) {
|
|
setup := setupDeviceTest(t)
|
|
defer setup.Server.Close()
|
|
|
|
libraryID := setup.CreateLibrary(t, "Test Search Library", "ebooks")
|
|
_ = setup.CreateDevice(t, "Test Search Device", "koreader", "search-test-123")
|
|
|
|
t.Run("Fuzzy author filter", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&author_filter=asimov", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should fuzzy match author")
|
|
})
|
|
|
|
t.Run("Fuzzy genre filter", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&genre_filter=scifi", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should fuzzy match genre")
|
|
})
|
|
|
|
t.Run("Exact match with quotes", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&q=%22Foundation%20and%20Empire%22", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should exact match quoted query")
|
|
})
|
|
|
|
t.Run("Combined search + filters", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&q=foundation&author_filter=asimov", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should combine search and filters")
|
|
})
|
|
|
|
t.Run("Field-specific search for dropdown - authors", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&authors=asimov", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should return author values")
|
|
|
|
var response struct {
|
|
Results []struct {
|
|
Value string `json:"value"`
|
|
Count int64 `json:"count"`
|
|
Score float64 `json:"score"`
|
|
} `json:"results"`
|
|
Total int `json:"total"`
|
|
}
|
|
err := json.Unmarshal(rec.Body.Bytes(), &response)
|
|
require.NoError(t, err, "Should unmarshal field values response")
|
|
assert.Greater(t, len(response.Results), 0, "Should have results")
|
|
})
|
|
|
|
t.Run("Year range filter (exact)", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&year_min=2000&year_max=2020", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by year range")
|
|
})
|
|
|
|
t.Run("Boolean filter (exact)", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&has_cover=true", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
|
|
})
|
|
|
|
t.Run("Missing library_id", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code, "Should require library_id")
|
|
})
|
|
}
|
|
```
|
|
|
|
**Verify tests:**
|
|
```bash
|
|
go test ./cmd/server/tests -v -run TestUnifiedSearch
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 7: Documentation Updates
|
|
|
|
#### 6.1 Update API Documentation
|
|
|
|
**File:** `docs/developer/api/media-items/search_media_items.md`
|
|
|
|
**Replace entire content:**
|
|
|
|
```markdown
|
|
# Search Media Items (Unified)
|
|
|
|
Search and filter media items with fuzzy matching support.
|
|
|
|
**Note:** All text filters use fuzzy matching via PostgreSQL pg_trgm (threshold: 0.3 similarity). This handles typos and partial matches automatically. Use quotes for exact match.
|
|
|
|
**Endpoint**: `GET /api/media-items/search`
|
|
**Auth**: Required
|
|
|
|
## Query Parameters
|
|
|
|
### Search Parameters
|
|
|
|
| Parameter | Type | Required | Description |
|
|
| ---------- | ------- | -------- | ---------------------------------------------------- |
|
|
| q | string | No | Search query (fuzzy by default, exact in quotes) |
|
|
| library_id | string | Yes | Filter to specific library (UUID) |
|
|
| limit | integer | No | Number of results (default 50, max 200) |
|
|
| offset | integer | No | Number to skip for pagination |
|
|
|
|
### Filter Parameters (All Fuzzy Except Years/Booleans)
|
|
|
|
| Parameter | Type | Description |
|
|
| -------------- | ------- | --------------------------------------------------- |
|
|
| author_filter | string | Fuzzy match author field |
|
|
| series_filter | string | Fuzzy match series field |
|
|
| genre_filter | string | Fuzzy match genre field |
|
|
| language_filter| string | Fuzzy match language field |
|
|
| year_min | integer | Minimum copyright year (exact range) |
|
|
| year_max | integer | Maximum copyright year (exact range) |
|
|
| has_cover | boolean | Filter by cover image presence (exact boolean) |
|
|
|
|
### Autocomplete Parameters (Field-Specific Search)
|
|
|
|
| Parameter | Type | Description |
|
|
| ---------- | ------ | ---------------------------------------------- |
|
|
| authors | string | Search author values for autocomplete dropdown |
|
|
| genres | string | Search genre values for autocomplete dropdown |
|
|
| series | string | Search series values for autocomplete dropdown |
|
|
| languages | string | Search language values for autocomplete |
|
|
|
|
## Request Headers
|
|
|
|
| Header | Type | Required | Description |
|
|
| ------------- | ------ | -------- | ------------ |
|
|
| Authorization | string | Yes | Bearer token |
|
|
|
|
## Search Behavior
|
|
|
|
### Fuzzy Search (Default)
|
|
|
|
Handles typos and partial matches automatically:
|
|
|
|
- `"asimov"` → matches "Asimov, Isaac", "Asimov, Foundation"
|
|
- `"scifi"` → matches "Sci-Fi", "Science Fiction"
|
|
- `"azimov"` → matches "Asimov, Isaac" (typo tolerance)
|
|
|
|
### Exact Search (With Quotes)
|
|
|
|
Use double quotes for exact phrase matching:
|
|
|
|
- `"\"Foundation and Empire\""` → only "Foundation and Empire"
|
|
- `"\"Asimov, Isaac\""` → only "Asimov, Isaac"
|
|
|
|
### Filter Behavior
|
|
|
|
**Text filters (fuzzy):**
|
|
- `author_filter=asimov` → fuzzy matches author field
|
|
- `genre_filter=scifi` → fuzzy matches genre field
|
|
|
|
**Exact filters:**
|
|
- `year_min=2000&year_max=2010` → exact year range
|
|
- `has_cover=true` → exact boolean match
|
|
|
|
## Example Requests
|
|
|
|
### 1. Global Fuzzy Search
|
|
|
|
Search all fields for "foundation":
|
|
|
|
```http
|
|
GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q=foundation
|
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
```
|
|
|
|
### 2. Fuzzy Author Filter
|
|
|
|
Find books by "asimov" (matches "Asimov, Isaac"):
|
|
|
|
```http
|
|
GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&author_filter=asimov
|
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
```
|
|
|
|
### 3. Combined Search + Filters
|
|
|
|
Search "foundation" within books by "asimov":
|
|
|
|
```http
|
|
GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q=foundation&author_filter=asimov
|
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
```
|
|
|
|
### 4. Exact Match with Quotes
|
|
|
|
Exact phrase search:
|
|
|
|
```http
|
|
GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q="Foundation%20and%20Empire"
|
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
```
|
|
|
|
### 5. Multiple Fuzzy Filters
|
|
|
|
Fiction books from 2000-2010:
|
|
|
|
```http
|
|
GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&genre_filter=fiction&year_min=2000&year_max=2010
|
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
```
|
|
|
|
### 6. Field-Specific Search (Autocomplete)
|
|
|
|
Get author values for dropdown:
|
|
|
|
```http
|
|
GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&authors=asimov&limit=50
|
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"results": [
|
|
{"value": "Asimov, Isaac", "count": 47, "score": 0.8},
|
|
{"value": "Asimov, Isaac & Robert Silverberg", "count": 2, "score": 0.75}
|
|
],
|
|
"total": 2
|
|
}
|
|
```
|
|
|
|
## Response (200 OK)
|
|
|
|
**Media items search:**
|
|
```json
|
|
[
|
|
{
|
|
"id": "uuid",
|
|
"title": "Foundation",
|
|
"author": "Asimov, Isaac",
|
|
"library_id": "...",
|
|
"library_name": "E-Books"
|
|
}
|
|
]
|
|
```
|
|
|
|
**Field values search (autocomplete):**
|
|
```json
|
|
{
|
|
"results": [
|
|
{"value": "Asimov, Isaac", "count": 47, "score": 0.8}
|
|
],
|
|
"total": 1
|
|
}
|
|
```
|
|
|
|
## Error Responses
|
|
|
|
| Code | Description |
|
|
| ---- | ---------------------------- |
|
|
| 400 | Invalid library_id |
|
|
| 400 | Missing library_id |
|
|
| 401 | Invalid or expired token |
|
|
| 404 | No results found |
|
|
```
|
|
|
|
#### 6.2 Update Bruno Collection
|
|
|
|
**File:** `bruno/media-items/Search All Libraries.yml`
|
|
|
|
**Update params section:**
|
|
|
|
```yaml
|
|
params:
|
|
- name: q
|
|
value: "foundation"
|
|
type: query
|
|
disabled: false
|
|
- name: library_id
|
|
value: "{{library_id}}"
|
|
type: query
|
|
disabled: false
|
|
- name: author_filter
|
|
value: ""
|
|
type: query
|
|
disabled: true
|
|
- name: genre_filter
|
|
value: ""
|
|
type: query
|
|
disabled: true
|
|
- name: year_min
|
|
value: ""
|
|
type: query
|
|
disabled: true
|
|
- name: year_max
|
|
value: ""
|
|
type: query
|
|
disabled: true
|
|
```
|
|
|
|
**Add new Bruno files:**
|
|
|
|
`bruno/media-items/Fuzzy Author Filter.yml`
|
|
`bruno/media-items/Fuzzy Genre Filter.yml`
|
|
`bruno/media-items/Combined Search and Filters.yml`
|
|
`bruno/media-items/Exact Match With Quotes.yml`
|
|
`bruno/media-items/Field Values Search - Authors.yml`
|
|
`bruno/media-items/scenarios/Unified Search Scenarios.yml`
|
|
|
|
#### 6.3 Delete Deprecated Filtered Endpoint Documentation
|
|
|
|
**Files to delete:**
|
|
- `docs/developer/api/media-items/filtered_media_items.md` (if exists)
|
|
- Remove `/api/media-items/filtered` from `docs/developer/api/api-reference.md`
|
|
- Remove `/api/media-items/filtered` from `docs/developer/api-reference.md`
|
|
|
|
---
|
|
|
|
### Phase 8: No Changes Needed (Skip)
|
|
|
|
**No changes required to:**
|
|
- `cmd/server/main.go`
|
|
- `cmd/server/tests/test_helpers_test.go`
|
|
|
|
SearchService follows the pattern of `FiltersService` and `CollectionService` - it's created **inside** the handler constructor, not in main.go. This keeps handler dependencies self-contained.
|
|
|
|
**Rationale:**
|
|
- Matches established pattern (`FiltersService`, `CollectionService`)
|
|
- Handler owns its service dependencies
|
|
- Simpler initialization - no handler-specific services in main.go
|
|
- More testable
|
|
|
|
---
|
|
|
|
### Phase 9: Cleanup
|
|
|
|
#### 7.1 Delete Deprecated Filtered Endpoint
|
|
|
|
**File:** `internal/router/media.go`
|
|
|
|
**Delete line 17:**
|
|
```go
|
|
protected.GET("/media-items/filtered", cfg.MediaHandler.ListMediaItemsFiltered)
|
|
```
|
|
|
|
**File:** `internal/handlers/media.go`
|
|
|
|
**Delete handler `ListMediaItemsFiltered` (lines 705-766):**
|
|
```go
|
|
// DELETE THIS FUNCTION
|
|
func (mh *MediaHandler) ListMediaItemsFiltered(c *echo.Context) error {
|
|
...
|
|
}
|
|
```
|
|
|
|
**File:** `internal/database/queries/queries.sql`
|
|
|
|
**Delete query `ListMediaItemsFiltered` (lines 227-268):**
|
|
```sql
|
|
-- DELETE THIS QUERY
|
|
-- name: ListMediaItemsFiltered :many
|
|
...
|
|
```
|
|
|
|
**Regenerate Go code:**
|
|
```bash
|
|
go generate ./internal/database
|
|
```
|
|
|
|
#### 7.2 Delete Deprecated Tests
|
|
|
|
**File:** `cmd/server/tests/filtering_test.go`
|
|
|
|
**Delete entire file** (tests now covered by `search_unified_test.go`)
|
|
|
|
**OR** update tests to use `/api/media-items/search` endpoint if some test cases are still valuable
|
|
|
|
#### 7.3 Delete Deprecated Bruno Files
|
|
|
|
**Delete:** `bruno/media-items/scenarios/Filter Media Items.yml` (if exists)
|
|
|
|
---
|
|
|
|
### Phase 10: Verification
|
|
|
|
#### 8.1 Compile Check
|
|
|
|
```bash
|
|
go build ./...
|
|
cd web && npm run build
|
|
```
|
|
|
|
#### 8.2 Run Tests
|
|
|
|
```bash
|
|
go test ./cmd/server/tests -v -run TestUnifiedSearch
|
|
go test ./cmd/server/tests -v # All tests should pass
|
|
```
|
|
|
|
#### 8.3 Manual Testing Checklist
|
|
|
|
- [ ] Search box uses `/api/media-items/search`
|
|
- [ ] Fuzzy author filter works (asimov → Asimov, Isaac)
|
|
- [ ] Fuzzy genre filter works (scifi → Sci-Fi)
|
|
- [ ] Exact match with quotes works ("Foundation and Empire")
|
|
- [ ] Combined search + filters works
|
|
- [ ] Autocomplete dropdowns populate correctly
|
|
- [ ] Year range filter works (exact)
|
|
- [ ] Boolean filter works (exact)
|
|
- [ ] Saved filters load correctly
|
|
- [ ] Pagination works
|
|
- [ ] No errors in browser console
|
|
- [ ] No errors in server logs
|
|
|
|
#### 8.4 Documentation Verification
|
|
|
|
```bash
|
|
# Start server
|
|
podman compose up -d
|
|
|
|
# Access docs at http://localhost:8080/docs
|
|
# Verify search endpoint documentation renders correctly
|
|
# Verify search finds new documentation
|
|
```
|
|
|
|
---
|
|
|
|
**Commit 3: Add search service**
|
|
git add internal/services/search.go
|
|
git commit -m "feat: add SearchService for unified search functionality
|
|
|
|
- Create SearchService with SearchMediaItemsUnified method
|
|
- Add SearchFieldValues method for autocomplete dropdowns
|
|
- Add parseSearchQuery helper for quote detection
|
|
- Move all business logic from handler to service layer
|
|
- Follow established service pattern (FiltersService, CollectionService)"
|
|
|
|
```bash
|
|
# Commit 1: Database schema (GIN indexes)
|
|
git add database/schema/schema.sql
|
|
git commit -m "feat: add GIN indexes for pg_trgm fuzzy search performance
|
|
|
|
- Add gin_trgm_ops indexes on author, title, series, genre, language
|
|
- Improves fuzzy search performance on large libraries
|
|
- Required for unified search/filter endpoint"
|
|
|
|
# Commit 2: SQL queries
|
|
git add internal/database/queries/queries.sql
|
|
git commit -m "feat: add unified search SQL query with fuzzy filters
|
|
|
|
- Add SearchMediaItemsUnified query with all-fuzzy filters
|
|
- Add SearchFieldValues query for autocomplete dropdowns
|
|
- Support exact match with quotes detection
|
|
- Combine search + filters in single query"
|
|
|
|
# Commit 3: Add search service
|
|
git add internal/services/search.go
|
|
git commit -m "feat: add SearchService for unified search functionality
|
|
|
|
- Create SearchService with SearchMediaItemsUnified method
|
|
- Add SearchFieldValues method for autocomplete dropdowns
|
|
- Add parseSearchQuery helper for quote detection
|
|
- Move all business logic from handler to service layer
|
|
- Follow established service pattern (FiltersService, CollectionService)"
|
|
|
|
# Commit 4: Regenerate database code
|
|
git add internal/database/queries.sql.go internal/database/models.go
|
|
git commit -m "chore: regenerate database code from queries.sql"
|
|
|
|
# Commit 5: Update handler to use search service
|
|
git add internal/handlers/media.go
|
|
git commit -m "refactor: update SearchMediaItems handler to use SearchService
|
|
|
|
- Add searchService to MediaHandler struct
|
|
- Create SearchService inside NewMediaHandler constructor
|
|
- Refactor SearchMediaItems to delegate to service layer
|
|
- Add handleUnifiedSearch method (thin wrapper)
|
|
- Add handleFieldValuesSearch method (thin wrapper)
|
|
- Handler now only extracts params and calls service
|
|
- Follows pattern of FiltersService and CollectionService"
|
|
|
|
# Commit 6: Frontend templates
|
|
git add templates/bookshelf.templ
|
|
git commit -m "feat: fix search box to use /search endpoint with autocomplete
|
|
|
|
- Change search box from /filtered to /search
|
|
- Change pagination buttons to use /search
|
|
- Add datalist elements for autocomplete
|
|
- Add Alpine.js event handlers for dropdown population"
|
|
|
|
# Commit 8: Frontend TypeScript
|
|
git add web/src/bookshelf.ts
|
|
git commit -m "feat: add autocomplete dropdown support for filter fields
|
|
|
|
- Add fetchFieldValues function for API calls
|
|
- Add fetchAuthorValues, fetchGenreValues, etc.
|
|
- Register functions globally for template access
|
|
- Populate datalist elements with fuzzy search results"
|
|
|
|
# Commit 9: Tests
|
|
git add cmd/server/tests/search_unified_test.go
|
|
git commit -m "test: add comprehensive tests for unified search endpoint
|
|
|
|
- Test fuzzy author/genre filters
|
|
- Test exact match with quotes
|
|
- Test combined search + filters
|
|
- Test field-specific search for dropdowns
|
|
- Test year range and boolean filters
|
|
- Use setupDeviceTest helper following PROJECT_GUIDELINES.md"
|
|
|
|
# Commit 10: Documentation
|
|
git add docs/developer/api/media-items/search_media_items.md
|
|
git add bruno/media-items/Search\ All\ Libraries.yml
|
|
git add bruno/media-items/Fuzzy\ Author\ Filter.yml
|
|
git add bruno/media-items/Fuzzy\ Genre\ Filter.yml
|
|
git add bruno/media-items/Combined\ Search\ and\ Filters.yml
|
|
git add bruno/media-items/Exact\ Match\ With\ Quotes.yml
|
|
git add bruno/media-items/Field\ Values\ Search\ -\ Authors.yml
|
|
git commit -m "docs: update search API documentation with fuzzy filters
|
|
|
|
- Document all-fuzzy filters (except years/booleans)
|
|
- Document exact match with quotes
|
|
- Document combined search + filters
|
|
- Document field-specific search for autocomplete
|
|
- Add comprehensive examples
|
|
- Update Bruno collection with new endpoints"
|
|
|
|
# Commit 11: Delete deprecated code
|
|
git add internal/router/media.go
|
|
git add internal/handlers/media.go
|
|
git add internal/database/queries/queries.sql
|
|
git add internal/database/queries.sql.go
|
|
git add internal/database/models.go
|
|
git add cmd/server/tests/filtering_test.go
|
|
git commit -m "refactor: remove deprecated /filtered endpoint
|
|
|
|
- Delete /media-items/filtered route registration
|
|
- Delete ListMediaItemsFiltered handler
|
|
- Delete ListMediaItemsFiltered SQL query
|
|
- Delete filtering_test.go (covered by search_unified_test.go)
|
|
- Regenerate database code after query deletion"
|
|
|
|
# Commit 12: Final verification
|
|
git add .
|
|
git commit -m "chore: final verification of unified search implementation
|
|
|
|
- All tests pass
|
|
- Documentation renders correctly
|
|
- Bruno collection updated
|
|
- No compilation errors
|
|
- Manual testing complete"
|
|
```
|
|
|
|
---
|
|
|
|
## Risk Mitigation
|
|
|
|
### Potential Issues
|
|
|
|
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 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
|
|
|
|
If issues arise:
|
|
```bash
|
|
# Revert to previous commit
|
|
git revert HEAD
|
|
|
|
# Or restore specific files
|
|
git show HEAD~1:internal/handlers/media.go > internal/handlers/media.go
|
|
git show HEAD~1:templates/bookshelf.templ > templates/bookshelf.templ
|
|
```
|
|
|
|
---
|
|
|
|
## Timeline Estimate
|
|
|
|
- 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: ~10 hours**
|
|
**Completed: ~3.5 hours** (Phases 1-3)
|
|
**Remaining: ~3 hours** (Phases 4-10, all code specified, just implementation)
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
✅ All business logic in `services/search.go` (service layer pattern)
|
|
✅ SearchService created inside NewMediaHandler constructor (not main.go)
|
|
✅ Handler is thin - only extracts params and calls service
|
|
✅ All text filters use fuzzy matching (pg_trgm)
|
|
✅ Exact match with quotes works
|
|
✅ Combined search + filters work
|
|
✅ Autocomplete dropdowns populate correctly
|
|
✅ Years/booleans remain exact match
|
|
✅ Saved filters load correctly
|
|
✅ All tests pass using `setupDeviceTest` helper
|
|
✅ Documentation updated
|
|
✅ No breaking changes to saved filters
|
|
✅ Deprecated `/filtered` endpoint removed
|
|
✅ Bruno collection updated
|