feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)
The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.
- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
listable facets in a single round trip: each arm omits its OWN
facet's filter block and carries every other registered facet's
block (copied mechanically from SearchMediaItemsUnified) — classic
faceted semantics: with filters active each facet counts under the
others; with none, blocks are neutral and the counts are the v1
global-per-library ones. One query serves both cases. The 14
per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
({facets: {param: [{value, display?, count}]}}), filters bound via
the shared ParseMediaSearchParams, library_id optional (the
All-Libraries view, family lens still enforced); facet=X keeps the
pinned single-facet shape (now routed through the same query with
neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
value lists — registry order, Batch D library-type scoping
(FacetsForLibraryType: manga_type on manga, reading_direction on
manga+comics, narrator on audiobooks; All view = union), prettified
genre labels + language display names, active values marked. Family
lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
apply with the CURRENT filter query string; a value click toggles
that facet's comma-joined filter (hidden input where the drawer has
no field — same as deep links) and re-applies the form; chips and
URL stay in sync. Values are server-rendered (rendering doctrine);
bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
implementation (services' display enrichment shares it; templ wraps
it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
registry facet's param, the bools as pgtype.Bool.
Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
This commit is contained in:
1 parent
6152137156
commit
f52bb6f073
18 files changed
+3001
-911
No files matched your search
+75
-28
@@ -2446,22 +2446,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
||||
}
|
||||
|
||||
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"),
|
||||
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"),
|
||||
@@ -2490,11 +2490,21 @@ func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (
|
||||
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.
|
||||
// GetFacets handles GET /api/media-items/facets — the filter panel's
|
||||
// value+count listing (faceted-navigation Batch C, extended in Phase 7).
|
||||
//
|
||||
// Two shapes:
|
||||
// - ?facet=X&library_id=… → the PINNED single-facet shape
|
||||
// ({facet, values[]}); facet must name a FacetArray or FacetScalar
|
||||
// entry (bool/range facets render as toggles, not lists).
|
||||
// - no facet param → the all-facets shape ({"facets": {param:
|
||||
// [value,…]}}), computed under the ACTIVE FILTERS (the same query
|
||||
// params the search endpoint takes) — each facet counts under the
|
||||
// others, the SQL omitting each arm's own block. library_id is
|
||||
// optional: absent = the All-Libraries view.
|
||||
//
|
||||
// Family-lens: an explicit library_id outside the visible set is 403
|
||||
// in both shapes.
|
||||
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
@@ -2503,8 +2513,43 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||
|
||||
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"})
|
||||
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
if facetParam == "" {
|
||||
// All-facets shape: filters bind exactly like the search
|
||||
// endpoint's (ParseMediaSearchParams), so the panel's counts
|
||||
// and the grid's rows can never disagree about what's active.
|
||||
var libPg pgtype.UUID
|
||||
if libraryID != "" {
|
||||
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"})
|
||||
}
|
||||
}
|
||||
counts, err := mh.searchService.FacetCounts(c.Request().Context(), user.ID, libPg, ParseMediaSearchParams(c), limit)
|
||||
if err != nil {
|
||||
c.Logger().Error("facet counts error", "error", err.Error())
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"facets": counts,
|
||||
})
|
||||
}
|
||||
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
|
||||
}
|
||||
facet := services.FacetByParam(facetParam)
|
||||
if facet == nil {
|
||||
@@ -2527,11 +2572,6 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||
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)
|
||||
@@ -2543,6 +2583,13 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// FacetCountsForView is the SSR facet panel's data path (faceted plan
|
||||
// Phase 7): the all-facets counts under the active filters for one
|
||||
// library — or across every visible library when libraryID is NULL.
|
||||
func (mh *MediaHandler) FacetCountsForView(ctx context.Context, userID, libraryID pgtype.UUID, params services.SearchParams, limit int) (map[string][]services.FacetValue, error) {
|
||||
return mh.searchService.FacetCounts(ctx, userID, libraryID, params, limit)
|
||||
}
|
||||
|
||||
// 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