Files
bookhoard/UNIFIED_SEARCH_IMPLEMENTATION.md
T
john-okeefe 057b595832 docs: update implementation guide with technical notes
Update UNIFIED_SEARCH_IMPLEMENTATION.md with:

1. Technical note about sqlc v1.30.0 limitation:
   - CASE expressions in GROUP BY not supported
   - Solution: Use 4 separate simple queries instead of 1 complex query
   - Simpler approach that works correctly with current sqlc version

2. Implementation approach updates:
   - Service layer route to appropriate query based on field type
   - No changes needed to main.go or test helpers
   - SearchService created inside handler constructor

3. Phase 7 changes (skip):
   - No handler initialization changes needed
   - Follows FiltersService and CollectionService pattern
   - Rationale: more testable, simpler initialization

4. Updated timeline estimates
5. Updated success criteria

These notes clarify implementation decisions and provide context
for future maintainers.
2026-03-22 20:35:13 -04:00

41 KiB

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.


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):

<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):

<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:

-- 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:

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):

-- 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.

-- 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:

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)

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 Fix Search Box Endpoint

File: templates/bookshelf.templ

Change line 79:

<!-- BEFORE -->
<input ... hx-get="/api/media-items/filtered" ... >

<!-- AFTER -->
<input ... hx-get="/api/media-items/search" ... >

Also fix pagination buttons (lines 298, 311):

<!-- 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.):

<!-- 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):

<!-- 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:

// 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:

cd web && npm run build

Phase 5: Tests

File: cmd/server/tests/search_unified_test.go (NEW)

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:

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:

# 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"):

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":

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:

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:

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:

GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&authors=asimov&limit=50
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response:

{
  "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:

[
  {
    "id": "uuid",
    "title": "Foundation",
    "author": "Asimov, Isaac",
    "library_id": "...",
    "library_name": "E-Books"
  }
]

Field values search (autocomplete):

{
  "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: 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 8: Cleanup

7.1 Delete Deprecated Filtered Endpoint

File: internal/router/media.go

Delete line 17:

protected.GET("/media-items/filtered", cfg.MediaHandler.ListMediaItemsFiltered)

File: internal/handlers/media.go

Delete handler ListMediaItemsFiltered (lines 705-766):

// DELETE THIS FUNCTION
func (mh *MediaHandler) ListMediaItemsFiltered(c *echo.Context) error {
  ...
}

File: internal/database/queries/queries.sql

Delete query ListMediaItemsFiltered (lines 227-268):

-- DELETE THIS QUERY
-- name: ListMediaItemsFiltered :many
...

Regenerate Go code:

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

go build ./...
cd web && npm run build

8.2 Run Tests

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

# 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)"
# 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
  4. Test coverage gaps: Comprehensive tests in Phase 5

Rollback Plan

If issues arise:

# 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 Update): 1 hour
  • Phase 5 (No Changes): 0 minutes
  • Phase 6 (Frontend): 2 hours
  • Phase 7 (TypeScript): 1 hour
  • Phase 8 (Tests): 2 hours
  • Phase 9 (Documentation): 1 hour
  • Phase 10 (Cleanup): 30 minutes
  • Phase 11 (Verification): 1 hour

Total: ~11.5 hours


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