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
@@ -109,6 +109,35 @@ func TestFacetRegistrySQLDrift(t *testing.T) {
|
||||
t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7: the counts query (FacetCountsUnderFilters) carries the
|
||||
// same filter blocks — copied mechanically from the search query —
|
||||
// so the panel's under-filter counts and the grid's rows can never
|
||||
// disagree about what a filter means. Reflect the generated params
|
||||
// struct the same way.
|
||||
countParamsType := reflect.TypeOf(database.FacetCountsUnderFiltersParams{})
|
||||
countTags := map[string]bool{}
|
||||
for i := 0; i < countParamsType.NumField(); i++ {
|
||||
tag := strings.Split(countParamsType.Field(i).Tag.Get("db"), ",")[0]
|
||||
countTags[tag] = true
|
||||
}
|
||||
for _, want := range []string{"user_id", "library_id", "limit"} {
|
||||
if !countTags[want] {
|
||||
t.Errorf("FacetCountsUnderFilters missing %s param", want)
|
||||
}
|
||||
}
|
||||
for _, f := range facetRegistry {
|
||||
wire := f.WireParam()
|
||||
if f.Kind == FacetRange {
|
||||
if !countTags["year_min"] || !countTags["year_max"] {
|
||||
t.Error("year range must bind year_min/year_max in FacetCountsUnderFilters")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !countTags[wire] {
|
||||
t.Errorf("registry facet %q missing from FacetCountsUnderFilters (each arm binds every OTHER facet's block, so every facet's param must exist)", wire)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacetsForLibraryType(t *testing.T) {
|
||||
|
||||
+94
-150
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -154,6 +155,14 @@ func normalizeLanguageFilter(wireValue string) string {
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// NormalizeLanguageFilter is the exported form of the search-path
|
||||
// normalization for callers that must compare RAW filter values
|
||||
// against stored codes without issuing a query (the facet panel marks
|
||||
// a value Active by matching the bound filter set).
|
||||
func NormalizeLanguageFilter(wireValue string) string {
|
||||
return normalizeLanguageFilter(wireValue)
|
||||
}
|
||||
|
||||
// FacetValue is one facet value + count for the filter panel.
|
||||
type FacetValue struct {
|
||||
Value string `json:"value"` // the raw value (rides the filter param)
|
||||
@@ -163,159 +172,94 @@ type FacetValue struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// FacetValues lists one facet's top values + counts, scoped to a single
|
||||
// library (v1 counts are global-per-library, not recomputed under the
|
||||
// other active filters). Registry-driven: only FacetArray and
|
||||
// FacetScalar facets list values — bool/range facets render as
|
||||
// toggles/ranges in the panel, not value lists.
|
||||
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
|
||||
lim := pgtype.Int4{Int32: int32(limit), Valid: true}
|
||||
switch facet.Param {
|
||||
case "author":
|
||||
rows, err := s.db.FacetAuthorsValues(ctx, database.FacetAuthorsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "publisher":
|
||||
rows, err := s.db.FacetPublishersValues(ctx, database.FacetPublishersValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "narrator":
|
||||
rows, err := s.db.FacetNarratorsValues(ctx, database.FacetNarratorsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "tags":
|
||||
rows, err := s.db.FacetTagsValues(ctx, database.FacetTagsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "series":
|
||||
rows, err := s.db.FacetSeriesValues(ctx, database.FacetSeriesValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "genre":
|
||||
rows, err := s.db.FacetGenreValues(ctx, database.FacetGenreValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "language":
|
||||
rows, err := s.db.FacetLanguageValues(ctx, database.FacetLanguageValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
if name := LanguageDisplayName(r.Value); name != "" {
|
||||
out[i].Display = name
|
||||
// facetCountsLimit is the per-facet top-N bound the panel renders.
|
||||
const facetCountsLimit = 50
|
||||
|
||||
// FacetCounts computes every listable facet's top values + counts under
|
||||
// the active filters (faceted plan Phase 7): ONE UNION-ALL round trip
|
||||
// (FacetCountsUnderFilters) in which each facet is counted under the
|
||||
// OTHER facets' filters — the SQL omits each arm's own block, classic
|
||||
// faceted-search semantics. With no filters bound every block is
|
||||
// neutral and the counts are the v1 global-per-library ones, so one
|
||||
// query serves both cases. libraryID may be NULL (the All-Libraries
|
||||
// view). Display enrichment matches the wire contract: language codes
|
||||
// → display names, genre values prettified — both only when they
|
||||
// differ from the raw value (Display is "the prettified form when it
|
||||
// differs", per FacetValue).
|
||||
func (s *SearchService) FacetCounts(ctx context.Context, userID, libraryID pgtype.UUID, params SearchParams, limit int) (map[string][]FacetValue, error) {
|
||||
if limit <= 0 {
|
||||
limit = facetCountsLimit
|
||||
}
|
||||
// Language filter UX (same normalization the search path runs —
|
||||
// see SearchMediaItemsUnified): display names in, ISO codes bound.
|
||||
langFilter := normalizeLanguageFilter(params.LanguageFilter)
|
||||
|
||||
rows, err := s.db.FacetCountsUnderFilters(ctx, database.FacetCountsUnderFiltersParams{
|
||||
UserID: userID,
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
|
||||
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: langFilter, Valid: true},
|
||||
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
|
||||
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
|
||||
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
|
||||
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
|
||||
FormatGroupFilter: pgtype.Text{String: params.FormatGroupFilter, Valid: true},
|
||||
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
|
||||
ReadingDirectionFilter: pgtype.Text{String: params.ReadingDirectionFilter, Valid: true},
|
||||
ExplicitFilter: params.ExplicitFilter,
|
||||
AbridgedFilter: params.AbridgedFilter,
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
HasCover: params.HasCover,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string][]FacetValue, 14)
|
||||
for _, r := range rows {
|
||||
v := FacetValue{Value: r.Value, Count: r.Count}
|
||||
switch r.Facet {
|
||||
case "language":
|
||||
if name := LanguageDisplayName(r.Value); name != "" && name != r.Value {
|
||||
v.Display = name
|
||||
}
|
||||
case "genre":
|
||||
if d := utils.PrettifyGenre(r.Value); d != r.Value {
|
||||
v.Display = d
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "story_arc":
|
||||
rows, err := s.db.FacetStoryArcValues(ctx, database.FacetStoryArcValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "imprint":
|
||||
rows, err := s.db.FacetImprintValues(ctx, database.FacetImprintValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "edition":
|
||||
rows, err := s.db.FacetEditionValues(ctx, database.FacetEditionValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "age_rating":
|
||||
rows, err := s.db.FacetAgeRatingValues(ctx, database.FacetAgeRatingValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "format_group":
|
||||
rows, err := s.db.FacetFormatGroupValues(ctx, database.FacetFormatGroupValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "manga_type":
|
||||
rows, err := s.db.FacetMangaTypeValues(ctx, database.FacetMangaTypeValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
case "reading_direction":
|
||||
rows, err := s.db.FacetReadingDirectionValues(ctx, database.FacetReadingDirectionValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FacetValue, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
||||
}
|
||||
return out, nil
|
||||
out[r.Facet] = append(out[r.Facet], v)
|
||||
}
|
||||
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FacetValues lists ONE facet's top values + counts — the pinned
|
||||
// single-facet shape behind GET /api/media-items/facets?facet=X. It is
|
||||
// the all-facets query with every filter neutral (v1 counts:
|
||||
// global-per-library, not recomputed under other filters).
|
||||
// Registry-driven: only FacetArray and FacetScalar facets list values —
|
||||
// bool/range facets render as toggles/ranges in the panel, not value
|
||||
// lists.
|
||||
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
|
||||
if facet.Kind != FacetArray && facet.Kind != FacetScalar {
|
||||
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
|
||||
}
|
||||
counts, err := s.FacetCounts(ctx, userID, libraryID, SearchParams{}, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := counts[facet.Param]
|
||||
if values == nil {
|
||||
return []FacetValue{}, nil
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// FieldSearchParams contains parameters for field-specific search (autocomplete)
|
||||
|
||||
Reference in new issue
Block a user