- Remove GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md (superseded) - Remove SAVED_FILTERS_IMPLEMENTATION.md (superseded) - Add UNIFIED_SEARCH_IMPLEMENTATION.md with comprehensive plan for: - Consolidating /filtered and /search endpoints - All-fuzzy text filters (author, series, genre, language) - Exact match with quotes for Google-style search - Field-specific fuzzy search for autocomplete dropdowns - Combined search + filters functionality - Phase-by-phase implementation with SQL, service, handler, frontend, tests, docs
1228 lines
38 KiB
Markdown
1228 lines
38 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.
|
|
|
|
---
|
|
|
|
## 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 field value search query (for autocomplete dropdowns):**
|
|
|
|
```sql
|
|
-- name: SearchFieldValues :many
|
|
SELECT DISTINCT
|
|
CASE sqlc.narg('field_type')
|
|
WHEN 'author' THEN mi.author
|
|
WHEN 'genre' THEN mi.genre
|
|
WHEN 'series' THEN mi.series
|
|
WHEN 'language' THEN mi.language
|
|
END as value,
|
|
COUNT(*) as count,
|
|
CASE sqlc.narg('field_type')
|
|
WHEN 'author' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
|
|
WHEN 'genre' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))
|
|
WHEN 'series' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))
|
|
WHEN 'language' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))
|
|
END as score
|
|
FROM media_items mi
|
|
WHERE mi.library_id = sqlc.narg('library_id')
|
|
AND (
|
|
(sqlc.narg('field_type') = 'author' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3) OR
|
|
(sqlc.narg('field_type') = 'genre' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3) OR
|
|
(sqlc.narg('field_type') = 'series' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3) OR
|
|
(sqlc.narg('field_type') = 'language' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3)
|
|
)
|
|
GROUP BY value, score
|
|
HAVING value IS NOT NULL AND value != ''
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
```
|
|
|
|
**Regenerate Go code:**
|
|
```bash
|
|
go generate ./internal/database
|
|
```
|
|
|
|
**Verify:** Check `internal/database/queries.sql.go` for new functions
|
|
|
|
---
|
|
|
|
### 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
|
|
func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearchParams) ([]FieldValue, error) {
|
|
// Build database parameters
|
|
dbParams := database.SearchFieldValuesParams{
|
|
UserID: params.UserID,
|
|
LibraryID: params.LibraryID,
|
|
FieldType: pgtype.Text{String: params.FieldType, Valid: true},
|
|
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
|
|
Limit: pgtype.Int4{Int32: params.Limit), Valid: true},
|
|
Offset: pgtype.Int4{Int32: params.Offset), Valid: true},
|
|
}
|
|
|
|
// Execute field values query
|
|
results, err := s.db.SearchFieldValues(ctx, dbParams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert to service-level response type
|
|
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
|
|
}
|
|
```
|
|
|
|
**Verification:**
|
|
```bash
|
|
go build ./internal/handlers
|
|
```
|
|
|
|
**Note:** Router initialization will also need updating (see Phase 7)
|
|
|
|
#### 4.1 Fix Search Box Endpoint
|
|
|
|
**File:** `templates/bookshelf.templ`
|
|
|
|
**Change line 79:**
|
|
```html
|
|
<!-- BEFORE -->
|
|
<input ... hx-get="/api/media-items/filtered" ... >
|
|
|
|
<!-- AFTER -->
|
|
<input ... hx-get="/api/media-items/search" ... >
|
|
```
|
|
|
|
**Also fix 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 }"
|
|
```
|
|
|
|
**Change filter field names (lines 92, 109, 126, etc.):**
|
|
```html
|
|
<!-- BEFORE -->
|
|
<input name="author_filter" ... hx-get="/api/media-items/filtered" ... >
|
|
<input name="genre_filter" ... hx-get="/api/media-items/filtered" ... >
|
|
|
|
<!-- AFTER -->
|
|
<input name="author_filter" ... hx-get="/api/media-items/search" ... >
|
|
<input name="genre_filter" ... hx-get="/api/media-items/search" ... >
|
|
```
|
|
|
|
#### 4.2 Add Autocomplete Dropdowns
|
|
|
|
**File:** `templates/bookshelf.templ`
|
|
|
|
**Replace author filter (lines 85-101):**
|
|
|
|
```html
|
|
<!-- Author Filter with Autocomplete -->
|
|
<div class="flex-1 min-w-[150px]">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Author
|
|
</label>
|
|
<div class="relative" x-data="{ authorValues: [] }">
|
|
<input
|
|
type="text"
|
|
name="author_filter"
|
|
placeholder="Filter by author"
|
|
class="w-full px-3 py-2 border rounded-lg"
|
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
hx-get="/api/media-items/search"
|
|
hx-target="#books-grid"
|
|
hx-trigger="change"
|
|
hx-include="#filter-form"
|
|
list="author-datalist"
|
|
@focus="fetchAuthorValues($el)"
|
|
@input.debounce.300ms="if($el.value === '') authorValues = []"
|
|
/>
|
|
<datalist id="author-datalist">
|
|
<template x-for="item in authorValues" :key="item.value">
|
|
<option :value="item.value" x-text="`${item.value} (${item.count})`"></option>
|
|
</template>
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Similar changes for genre, series, language filters**
|
|
|
|
#### 4.3 Add Frontend Functions
|
|
|
|
**File:** `web/src/bookshelf.ts`
|
|
|
|
**Add at end of file:**
|
|
|
|
```typescript
|
|
// Fetch field values for autocomplete dropdowns
|
|
async function 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; // Wait for at least 2 characters
|
|
|
|
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
|
|
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 function fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
|
const search = input.value;
|
|
await fetchFieldValues("authors", search, "author-datalist");
|
|
}
|
|
|
|
// Fetch genre values for autocomplete
|
|
async function fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
|
const search = input.value;
|
|
await fetchFieldValues("genres", search, "genre-datalist");
|
|
}
|
|
|
|
// Fetch series values for autocomplete
|
|
async function fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
|
const search = input.value;
|
|
await fetchFieldValues("series", search, "series-datalist");
|
|
}
|
|
|
|
// Fetch language values for autocomplete
|
|
async function fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
|
const search = input.value;
|
|
await fetchFieldValues("languages", search, "language-datalist");
|
|
}
|
|
|
|
// Register functions globally
|
|
(window as any).fetchAuthorValues = fetchAuthorValues;
|
|
(window as any).fetchGenreValues = fetchGenreValues;
|
|
(window as any).fetchSeriesValues = fetchSeriesValues;
|
|
(window as any).fetchLanguageValues = fetchLanguageValues;
|
|
```
|
|
|
|
**Verify TypeScript compilation:**
|
|
```bash
|
|
cd web && npm run build
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 5: 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 6: 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 7: Update Handler Initialization
|
|
|
|
**Files to update:**
|
|
- `cmd/server/main.go` (line ~122-126)
|
|
- `cmd/server/tests/test_helpers_test.go` (line ~468-472)
|
|
|
|
**In both files, find the MediaHandler initialization:**
|
|
|
|
```go
|
|
// BEFORE
|
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
|
|
|
// AFTER
|
|
searchService := services.NewSearchService(queries)
|
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, searchService, worker)
|
|
```
|
|
|
|
**Note:** This follows the same pattern as `conversionService` which is created in main.go (line 118) and passed to handlers.
|
|
|
|
**Verify compilation:**
|
|
```bash
|
|
go build ./cmd/server
|
|
go test ./cmd/server/tests -run TestNonExistent # Compile test only
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 8: 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
|
|
- Update 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"
|
|
|
|
# Commit 6: Update handler initialization
|
|
git add cmd/server/main.go cmd/server/tests/test_helpers_test.go
|
|
git commit -m "refactor: add SearchService to handler initialization
|
|
|
|
- Instantiate SearchService in main.go and test_helpers_test.go
|
|
- Pass searchService to MediaHandler constructor
|
|
- Follow existing pattern (like conversionService)
|
|
- Update both production and test initialization"
|
|
|
|
# Commit 7: 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 setupTestServer 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
|
|
4. **Test coverage gaps:** Comprehensive tests in Phase 5
|
|
|
|
### 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
|
|
- Phase 2 (SQL Queries): 1 hour
|
|
- Phase 3 (Search Service): 2 hours
|
|
- Phase 4 (Handler Updates): 1 hour
|
|
- Phase 5 (Router Updates): 15 minutes
|
|
- Phase 6 (Frontend): 2 hours
|
|
- Phase 7 (TypeScript): 1 hour
|
|
- Phase 8 (Tests): 2 hours
|
|
- Phase 9 (Documentation): 1 hour
|
|
- Phase 10 (Cleanup): 30 minutes
|
|
- Phase 11 (Verification): 1 hour
|
|
|
|
**Total: ~12 hours**
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
✅ All business logic in `services/search.go` (service layer pattern)
|
|
✅ 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 `setupTestServer` helper
|
|
✅ Documentation updated
|
|
✅ No breaking changes to saved filters
|
|
✅ Deprecated `/filtered` endpoint removed
|
|
✅ Bruno collection updated
|