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
@@ -164,27 +164,27 @@ type Querier interface {
|
||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||
// label before stats); a capture row must only reference a REAL device.
|
||||
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
||||
FacetAgeRatingValues(ctx context.Context, arg FacetAgeRatingValuesParams) ([]FacetAgeRatingValuesRow, error)
|
||||
// Facet-value listings (faceted-navigation Batch C): top values + counts
|
||||
// for the filter panel, one static query per media_items column (same
|
||||
// sqlc-compatibility constraint as the Search*Values autocomplete above).
|
||||
// Facet-value listings (faceted-navigation Batch C v2, faceted plan
|
||||
// Phase 7): ALL listable facets' top values + counts in ONE statement —
|
||||
// one round trip serves the filter drawer's panel, with classic faceted
|
||||
// semantics: each arm OMITS its own facet's filter block and carries
|
||||
// every OTHER registered facet's block (copied mechanically from
|
||||
// SearchMediaItemsUnified — keep the two in lockstep), so with filters
|
||||
// active each facet counts under the others, and with no filters the
|
||||
// blocks are neutral and the counts are the v1 global-per-library ones.
|
||||
// One query serves both cases.
|
||||
//
|
||||
// Visibility: same family-lens join as the search query — a user's
|
||||
// hidden-library rows are absent, and the handler 403s an out-of-set
|
||||
// explicit library_id. Counts are v1 global-per-library (not
|
||||
// recomputed under the other active filters).
|
||||
FacetAuthorsValues(ctx context.Context, arg FacetAuthorsValuesParams) ([]FacetAuthorsValuesRow, error)
|
||||
FacetEditionValues(ctx context.Context, arg FacetEditionValuesParams) ([]FacetEditionValuesRow, error)
|
||||
FacetFormatGroupValues(ctx context.Context, arg FacetFormatGroupValuesParams) ([]FacetFormatGroupValuesRow, error)
|
||||
FacetGenreValues(ctx context.Context, arg FacetGenreValuesParams) ([]FacetGenreValuesRow, error)
|
||||
FacetImprintValues(ctx context.Context, arg FacetImprintValuesParams) ([]FacetImprintValuesRow, error)
|
||||
FacetLanguageValues(ctx context.Context, arg FacetLanguageValuesParams) ([]FacetLanguageValuesRow, error)
|
||||
FacetMangaTypeValues(ctx context.Context, arg FacetMangaTypeValuesParams) ([]FacetMangaTypeValuesRow, error)
|
||||
FacetNarratorsValues(ctx context.Context, arg FacetNarratorsValuesParams) ([]FacetNarratorsValuesRow, error)
|
||||
FacetPublishersValues(ctx context.Context, arg FacetPublishersValuesParams) ([]FacetPublishersValuesRow, error)
|
||||
FacetReadingDirectionValues(ctx context.Context, arg FacetReadingDirectionValuesParams) ([]FacetReadingDirectionValuesRow, error)
|
||||
FacetSeriesValues(ctx context.Context, arg FacetSeriesValuesParams) ([]FacetSeriesValuesRow, error)
|
||||
FacetStoryArcValues(ctx context.Context, arg FacetStoryArcValuesParams) ([]FacetStoryArcValuesRow, error)
|
||||
FacetTagsValues(ctx context.Context, arg FacetTagsValuesParams) ([]FacetTagsValuesRow, error)
|
||||
// hidden-library rows are absent; the handler 403s an out-of-set
|
||||
// explicit library_id. library_id is nullable: NULL = the All-Libraries
|
||||
// view (no scope beyond the lens). Bool/range facets (has_cover,
|
||||
// explicit, abridged, year) filter every arm but list no values — they
|
||||
// render as toggles/inputs, not lists.
|
||||
//
|
||||
// Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
|
||||
// select bind per-arm only in parenthesized subqueries). The same
|
||||
// sqlc.narg names repeat across arms; sqlc binds them once.
|
||||
FacetCountsUnderFilters(ctx context.Context, arg FacetCountsUnderFiltersParams) ([]FacetCountsUnderFiltersRow, error)
|
||||
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||
|
||||
+1007
-595
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+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 {
|
||||
|
||||
@@ -386,6 +386,129 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Facet panel partial (faceted plan Phase 7): the bookshelf filter
|
||||
// drawer's value-list sections, server-rendered from the facet
|
||||
// registry + the under-filter counts. The drawer fetches it with the
|
||||
// CURRENT filter query string, so the counts always reflect exactly
|
||||
// what the grid shows; a value click is bookshelf.ts toggling that
|
||||
// facet's filter and re-applying the form.
|
||||
frontendProtected.GET("/media-items/facet-panel", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
// Family lens (AGENTS standing rule): the panel lists media
|
||||
// metadata, so an explicit library_id outside the visible set is
|
||||
// a 403 — never a silent fallback.
|
||||
if requested := c.QueryParam("library_id"); requested != "" {
|
||||
if requestedUUID, parseErr := uuid.Parse(requested); parseErr == nil {
|
||||
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
log.Printf("visibleLibraryIDsFor failed: %v", visErr)
|
||||
return c.HTML(http.StatusInternalServerError, "<div class=\"p-4 text-sm\">Failed to resolve library visibility</div>")
|
||||
}
|
||||
if !visibleSet[requestedUUID.String()] {
|
||||
return c.HTML(http.StatusForbidden, "<div class=\"p-4 text-sm\">Library not visible</div>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same library resolution the page/grid uses (param → cookie →
|
||||
// first visible), so panel scope and grid scope agree.
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
params := handlers.ParseMediaSearchParams(c)
|
||||
params.UserID = pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
params.LibraryID = libRes.LibUUID
|
||||
|
||||
counts, err := cfg.MediaHandler.FacetCountsForView(c.Request().Context(), params.UserID, libRes.LibUUID, params, 50)
|
||||
if err != nil {
|
||||
log.Printf("facet panel counts failed: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, "<div class=\"p-4 text-sm\">Failed to load facet values</div>")
|
||||
}
|
||||
|
||||
// Active filter values per wire param — a value is Active when
|
||||
// it appears in its facet's bound set (language normalized the
|
||||
// same way the query binds it).
|
||||
wireValues := []struct {
|
||||
wire string
|
||||
val string
|
||||
}{
|
||||
{"author_filter", params.AuthorFilter},
|
||||
{"publisher_filter", params.PublisherFilter},
|
||||
{"narrator_filter", params.NarratorFilter},
|
||||
{"tags_filter", params.TagsFilter},
|
||||
{"series_filter", params.SeriesFilter},
|
||||
{"genre_filter", params.GenreFilter},
|
||||
{"language_filter", services.NormalizeLanguageFilter(params.LanguageFilter)},
|
||||
{"story_arc_filter", params.StoryArcFilter},
|
||||
{"imprint_filter", params.ImprintFilter},
|
||||
{"edition_filter", params.EditionFilter},
|
||||
{"age_rating_filter", params.AgeRatingFilter},
|
||||
{"format_group_filter", params.FormatGroupFilter},
|
||||
{"manga_type_filter", params.MangaTypeFilter},
|
||||
{"reading_direction_filter", params.ReadingDirectionFilter},
|
||||
}
|
||||
activeValues := make(map[string][]string, len(wireValues))
|
||||
for _, wv := range wireValues {
|
||||
activeValues[wv.wire] = services.SplitFacetValues(wv.val)
|
||||
}
|
||||
|
||||
// Batch D rendering rules: the view's library type picks the
|
||||
// facets (All Libraries → the union); only value-bearing kinds
|
||||
// render sections — bool/range facets are the drawer's typed
|
||||
// inputs.
|
||||
typeName := ""
|
||||
if !libRes.IsAll {
|
||||
for _, ld := range libRes.Libraries {
|
||||
if ld.ID == libRes.LibraryID {
|
||||
typeName = ld.TypeName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections := make([]templates.FacetPanelSection, 0, len(counts))
|
||||
for _, f := range services.FacetsForLibraryType(typeName) {
|
||||
if f.Kind != services.FacetArray && f.Kind != services.FacetScalar {
|
||||
continue
|
||||
}
|
||||
sec := templates.FacetPanelSection{
|
||||
WireParam: f.WireParam(),
|
||||
Title: templates.FacetPanelTitle(f.Param),
|
||||
}
|
||||
for _, v := range counts[f.Param] {
|
||||
display := v.Display
|
||||
if display == "" {
|
||||
display = v.Value
|
||||
}
|
||||
active := false
|
||||
for _, a := range activeValues[f.WireParam()] {
|
||||
if a == v.Value {
|
||||
active = true
|
||||
break
|
||||
}
|
||||
}
|
||||
sec.Values = append(sec.Values, templates.FacetPanelValue{
|
||||
Value: v.Value,
|
||||
Display: display,
|
||||
Count: v.Count,
|
||||
Active: active,
|
||||
})
|
||||
}
|
||||
sections = append(sections, sec)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.FacetPanel(sections).Render(c.Request().Context(), &buf); err != nil {
|
||||
log.Printf("facet panel render failed: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, "<div class=\"p-4 text-sm\">Failed to render facet panel</div>")
|
||||
}
|
||||
return c.HTMLBlob(http.StatusOK, buf.Bytes())
|
||||
})
|
||||
|
||||
// Dashboard page
|
||||
frontendProtected.GET("/dashboard", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package utils
|
||||
|
||||
// Genre display rendering (faceted plan Batch B / Phase 7): the
|
||||
// canonically-lowercase genre value is title-cased at DISPLAY time
|
||||
// only — never in the DB. Canonical home: the filter panel (services),
|
||||
// templ (templates wraps this), and the TS port in bookshelf.ts all
|
||||
// share these exact rules.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// genreSmallWords stay lowercase in title-case rendering (except the
|
||||
// first word of the value).
|
||||
var genreSmallWords = map[string]bool{
|
||||
"a": true, "an": true, "and": true, "of": true, "the": true,
|
||||
"in": true, "on": true, "for": true, "to": true, "or": true,
|
||||
}
|
||||
|
||||
// PrettifyGenre renders the canonically-lowercase genre value in title
|
||||
// case at display time (Batch B transform — never in the DB). Only
|
||||
// values that are ENTIRELY lowercase get transformed; anything else is
|
||||
// returned verbatim, protecting user-entered mixed case. Small words
|
||||
// stay down except in the first position; "&" has no case and passes
|
||||
// through. "science fiction" → "Science Fiction", "sword & sorcery" →
|
||||
// "Sword & Sorcery".
|
||||
func PrettifyGenre(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if trimmed != strings.ToLower(trimmed) {
|
||||
return trimmed // mixed case = user-authored; display as written
|
||||
}
|
||||
words := strings.Fields(trimmed)
|
||||
out := make([]string, len(words))
|
||||
for i, w := range words {
|
||||
if i > 0 && genreSmallWords[w] {
|
||||
out[i] = w
|
||||
continue
|
||||
}
|
||||
runes := []rune(w)
|
||||
out[i] = string(append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
Reference in new issue
Block a user