feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint
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.
This commit is contained in:
1 parent
7537063cb7
commit
7e586df48a
13 files changed
+2297
-283
No files matched your search
+169
-71
@@ -73,7 +73,7 @@ type CreateMediaItemRequest struct {
|
||||
type UpdateMediaItemRequest struct {
|
||||
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `form:"author" json:"author"`
|
||||
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
|
||||
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
|
||||
Subtitle string `form:"subtitle" json:"subtitle"`
|
||||
Explicit bool `form:"explicit" json:"explicit"`
|
||||
ISBN string `form:"isbn" json:"isbn"`
|
||||
@@ -2349,81 +2349,33 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Handle media item search with filters
|
||||
query := c.QueryParam("q")
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
// Validate library_id if provided
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
lib, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
||||
} else {
|
||||
libUUID = pgtype.UUID{Valid: false}
|
||||
}
|
||||
params := ParseMediaSearchParams(c)
|
||||
params.UserID = userID.ID
|
||||
|
||||
c.Logger().Info("Search parameters",
|
||||
"query", query,
|
||||
"library_id", libraryID,
|
||||
"libUUID_valid", libUUID.Valid,
|
||||
"libUUID_bytes", libUUID.Bytes)
|
||||
"query", params.SearchQuery,
|
||||
"library_id", libraryIDString(c),
|
||||
"libUUID_valid", params.LibraryID.Valid,
|
||||
"libUUID_bytes", params.LibraryID.Bytes)
|
||||
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||
|
||||
// Extract filter parameters
|
||||
authorFilter := c.QueryParam("author_filter")
|
||||
seriesFilter := c.QueryParam("series_filter")
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
publisherFilter := c.QueryParam("publisher_filter")
|
||||
tagsFilter := c.QueryParam("tags_filter")
|
||||
narratorFilter := c.QueryParam("narrator_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
||||
hasCover := false
|
||||
hasCoverValid := false
|
||||
if _, exists := c.QueryParams()["has_cover"]; exists {
|
||||
hasCover = c.QueryParam("has_cover") == "true"
|
||||
hasCoverValid = true
|
||||
}
|
||||
|
||||
// Extract sort parameter
|
||||
sortParam := c.QueryParam("sort")
|
||||
if sortParam == "" {
|
||||
sortParam = "title ASC" // Default sort
|
||||
}
|
||||
|
||||
// Build search params
|
||||
params := services.SearchParams{
|
||||
UserID: userID.ID,
|
||||
LibraryID: libUUID,
|
||||
SearchQuery: query,
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
PublisherFilter: publisherFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
NarratorFilter: narratorFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
||||
Sort: sortParam,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
// Family-lens gate (AGENTS.md → Library visibility): an explicit
|
||||
// library_id outside the requester's visible set is a 403, never a
|
||||
// silent empty set.
|
||||
if params.LibraryID.Valid {
|
||||
visible, err := mh.libraryVisible(c, params.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
}
|
||||
|
||||
c.Logger().Info("SearchMediaItems called",
|
||||
"query", query,
|
||||
"library_id_provided", libraryID != "",
|
||||
"library_id_uuid", libUUID.Valid,
|
||||
"library_id_bytes", libUUID.Bytes)
|
||||
"query", params.SearchQuery,
|
||||
"library_id_provided", params.LibraryID.Valid,
|
||||
"library_id_uuid", params.LibraryID.Valid,
|
||||
"library_id_bytes", params.LibraryID.Bytes)
|
||||
|
||||
// Call shared search service
|
||||
results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params)
|
||||
@@ -2434,7 +2386,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
if len(results) == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
||||
"error": "no results found",
|
||||
"query": query,
|
||||
"query": params.SearchQuery,
|
||||
"results": []interface{}{},
|
||||
})
|
||||
}
|
||||
@@ -2445,6 +2397,152 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// libraryIDString returns the raw library_id query param (logging only).
|
||||
func libraryIDString(c *echo.Context) string { return c.QueryParam("library_id") }
|
||||
|
||||
// searchParamSource abstracts the query-param surface (echo.Context)
|
||||
// so the shared parser serves both handlers and router.
|
||||
type searchParamSource interface {
|
||||
QueryParam(name string) string
|
||||
QueryParams() url.Values
|
||||
}
|
||||
|
||||
// parseMediaSearchParams parses the unified search's query params into
|
||||
// services.SearchParams — the SINGLE parse for both the JSON endpoint
|
||||
// (MediaHandler.SearchMediaItems) and the HTMX grid partial
|
||||
// (router handleSearchHTML), so the two branches can never disagree.
|
||||
// Text facet params: comma = OR-within (the SQL splits via
|
||||
// string_to_array). Bool facet params: presence-bound (absent = no
|
||||
// filter, "true"/"false" = bound). Facet names come from the facet
|
||||
// registry (services/facet_registry.go) — adding a facet there means
|
||||
// one line here, mirroring its narg block in queries.sql.
|
||||
func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||
|
||||
sortParam := c.QueryParam("sort")
|
||||
if sortParam == "" {
|
||||
sortParam = "title ASC" // Default sort
|
||||
}
|
||||
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID := c.QueryParam("library_id"); libraryID != "" {
|
||||
if lib, err := uuid.Parse(libraryID); err == nil {
|
||||
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
||||
|
||||
boolFacet := func(name string) pgtype.Bool {
|
||||
if _, exists := c.QueryParams()[name]; !exists {
|
||||
return pgtype.Bool{Valid: false}
|
||||
}
|
||||
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
|
||||
}
|
||||
|
||||
return services.SearchParams{
|
||||
SearchQuery: c.QueryParam("q"),
|
||||
LibraryID: libUUID,
|
||||
AuthorFilter: c.QueryParam("author_filter"),
|
||||
SeriesFilter: c.QueryParam("series_filter"),
|
||||
GenreFilter: c.QueryParam("genre_filter"),
|
||||
PublisherFilter: c.QueryParam("publisher_filter"),
|
||||
TagsFilter: c.QueryParam("tags_filter"),
|
||||
NarratorFilter: c.QueryParam("narrator_filter"),
|
||||
LanguageFilter: c.QueryParam("language_filter"),
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: boolFacet("has_cover"),
|
||||
StoryArcFilter: c.QueryParam("story_arc_filter"),
|
||||
ImprintFilter: c.QueryParam("imprint_filter"),
|
||||
EditionFilter: c.QueryParam("edition_filter"),
|
||||
AgeRatingFilter: c.QueryParam("age_rating_filter"),
|
||||
FormatGroupFilter: c.QueryParam("format_group_filter"),
|
||||
MangaTypeFilter: c.QueryParam("manga_type_filter"),
|
||||
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
|
||||
ExplicitFilter: boolFacet("explicit_filter"),
|
||||
AbridgedFilter: boolFacet("abridged_filter"),
|
||||
Sort: sortParam,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
// libraryVisible reports whether the requested library is inside the
|
||||
// requester's visible-library set. Slice compare on pgtype.UUID bytes —
|
||||
// immune to string-format differences between the query param and the
|
||||
// membership map keys.
|
||||
func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (bool, error) {
|
||||
_, ids, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == libraryID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetFacets handles GET /api/media-items/facets?facet=X&library_id=...
|
||||
// — the filter panel's value+count listing (faceted-navigation Batch C).
|
||||
// Registry-driven: the facet param must name a FacetArray or
|
||||
// FacetScalar entry (bool/range facets render as toggles, not lists).
|
||||
// Family-lens: an explicit library_id outside the visible set is 403.
|
||||
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||
}
|
||||
|
||||
facetParam := c.QueryParam("facet")
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if facetParam == "" || libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet and library_id are required"})
|
||||
}
|
||||
facet := services.FacetByParam(facetParam)
|
||||
if facet == nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown facet: " + facetParam})
|
||||
}
|
||||
if facet.Kind != services.FacetArray && facet.Kind != services.FacetScalar {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet does not list values: " + facetParam})
|
||||
}
|
||||
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libPg := pgtype.UUID{Bytes: libUUID, Valid: true}
|
||||
visible, err := mh.libraryVisible(c, libPg)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
|
||||
if err != nil {
|
||||
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"facet": facetParam,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
||||
// Returns distinct field values with counts and similarity scores for dropdown population
|
||||
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||
|
||||
Reference in new issue
Block a user