Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.
- Nine new filter params on SearchMediaItemsUnified: story_arc,
imprint, edition (scalar-fuzzy), age_rating, format_group,
manga_type, reading_direction (exact), explicit, abridged
(presence-bound bools). Comma = OR-within multi-select on every
text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
> empty (unknown-only stays tags-only). Type-words (Manga, Comics,
Graphic Novel) join the demote list. Both 'adventure' and 'action &
adventure' are shelves — provider data uses both and the verified
traces pin them.
- Language filter UX: comma elements normalize through the ingest
language map, so display names ('English') match rows stored as ISO
codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
driven value+count listing (14 static per-column queries, family-lens
join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
403 on the search endpoints (was a silent empty set) — JSON branch
and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
search JSON endpoint and the HTMX grid partial; the HTML branch
previously dropped publisher_filter and narrator_filter silently.
Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
95 lines
3.4 KiB
Go
95 lines
3.4 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)
|
|
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
|
|
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())
|
|
}
|