Files
bookhoard/internal/router/search.go
T
John O'Keefe 4ee24e7152 feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields —
  every book column, file/lifecycle facts, the three HOLDING
  STRUCTURES (library, collections with excluded-aware membership,
  series with FULL primary+alternate_info membership), and the whole
  user-relative class (status, progress, last read, my rating, review,
  notes/highlights presence, note/highlight text, time spent, pages
  read, collections, added by) reading the VIEWER's rows. Operators
  are typed constants; negation is an operator.
- Composed engines (services/filter_query.go): one Go clause builder
  over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow
  shape), the field-aware suggestions, and a true-COUNT live count.
  Mandatory WHERE head everywhere (family lens + integrity facts).
  Values are always bound parameters — no user string reaches a
  field/operator position, ever.
- Wire: the qf JSON param (validated strictly — unknown
  fields/operators/malformed values are 400); qf wins over flat params
  on hand-crafted URLs; flat deep links keep working unchanged.
- Endpoints: GET /api/media-items/filter-count (live count);
  field-values autocomplete upgraded in place — any registry field
  key, qf binds the OTHER rules (symmetric under-filter suggestions,
  zero-match values never offered); classic six without a tree keep
  their static pinned contract.
- UI: the drawer becomes the rule builder — the ONLY filter UI (templ
  markup, Alpine tree state, sanctioned island; grouped field menus,
  operator menus with red negations, field-aware value menus, live
  count, chips per rule with red negations, URL rehydration, smart
  serialization flat-when-simple else qf).
- Tests: composed semantics (AND/OR/negation/nesting/enums/bools/
  numbers/dates/empty), every user-relative field class with the
  my-data weld (two readers, different worlds), note-text substring
  pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere,
  403s), suggestion-under-rules exactness, qf validation + precedence,
  flat-vs-tree equivalence; registry completeness unit test.
2026-10-20 14:49:50 -04:00

104 lines
3.8 KiB
Go

package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/templates"
"bytes"
"log"
"net/http"
"github.com/labstack/echo/v5"
)
func registerSearchRoutes(cfg *Config) {
e := cfg.Echo
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
// Search endpoint - returns JSON or HTML based on HX-Request header
protected.GET("/media-items/search", func(c *echo.Context) error {
// Check if HTMX request (expects HTML)
if c.Request().Header.Get("HX-Request") == "true" {
return handleSearchHTML(c, cfg)
}
// Otherwise return JSON (API clients)
return cfg.MediaHandler.SearchMediaItems(c)
})
// Facet-value listings for the filter panel (faceted-navigation
// Batch C): /api/media-items/facets?facet=X&library_id=...
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
// Rule-builder live count (faceted plan Phase 9): the composed
// tree's true match count (COUNT(*)), rules + library scope.
protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
}
// handleSearchHTML renders HTML response for HTMX requests
func handleSearchHTML(c *echo.Context, cfg *Config) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
// — the HTMX grid partial and the JSON endpoint can never disagree on
// which facet params exist or how they parse. (This branch previously
// dropped publisher_filter and narrator_filter entirely — the shared
// parser fixes that too.)
params := handlers.ParseMediaSearchParams(c)
params.UserID = user.ID
// Filter rule builder: a malformed qf is a 400, never a silently
// ignored token.
if params.FilterParseErr != "" {
return c.HTML(http.StatusBadRequest, `<div style="color: red;">Invalid filter</div>`)
}
limit := params.Limit
offset := params.Offset
libraryID := c.QueryParam("library_id")
// Family-lens gate: an explicit library_id outside the requester's
// visible set is a 403 (same rule as the JSON branch).
if params.LibraryID.Valid {
_, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
if err != nil {
log.Printf("VisibleLibrarySet failed: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
visible := false
for _, id := range ids {
if id == params.LibraryID {
visible = true
break
}
}
if !visible {
return c.HTML(http.StatusForbidden, `<div style="color: red;">Library not visible</div>`)
}
}
// Execute search using handler method
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
if err != nil {
log.Printf("Search error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
// Convert to BookInfo
bookInfoList := handlers.BookCardsFromUnified(results)
// Stamp active conflict flags so cards route the play action correctly
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer
if c.QueryParam("show_checkbox") == "true" {
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
} else {
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
if err != nil {
log.Printf("Template render error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
}
return c.HTML(http.StatusOK, buf.String())
}