From 7e586df48a8c3b66d42e062365248e3b3a459335 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 19 Oct 2026 18:59:23 -0400 Subject: [PATCH] feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/facets_integration_test.go | 312 ++++++++ docs/contract_pins.md | 19 + internal/database/querier.go | 21 + internal/database/queries.sql.go | 809 ++++++++++++++++++-- internal/database/queries/queries.sql | 233 ++++++ internal/handlers/media.go | 240 ++++-- internal/router/search.go | 84 +- internal/services/facet_registry.go | 153 ++++ internal/services/facet_registry_test.go | 173 +++++ internal/services/genre_lexicon.go | 236 +++--- internal/services/search.go | 234 +++++- internal/services/series_parsing.go | 30 + internal/services/series_parsing_test.go | 36 +- 13 files changed, 2297 insertions(+), 283 deletions(-) create mode 100644 cmd/server/tests/facets_integration_test.go create mode 100644 internal/services/facet_registry.go create mode 100644 internal/services/facet_registry_test.go diff --git a/cmd/server/tests/facets_integration_test.go b/cmd/server/tests/facets_integration_test.go new file mode 100644 index 00000000..19b9c2c7 --- /dev/null +++ b/cmd/server/tests/facets_integration_test.go @@ -0,0 +1,312 @@ +package main + +// Facet registry Batch A integration tests (faceted-navigation plan, +// Part 1 Phase 1): the nine new SearchMediaItemsUnified narg blocks — +// scalar-fuzzy (story_arc, imprint, edition), exact enums (age_rating, +// format_group, manga_type, reading_direction), and presence-bound +// bools (explicit, abridged) — filter correctly against a real DB. +// The registry↔SQL drift guard (unit level) lives in +// internal/services/facet_registry_test.go. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + "bookhoard/internal/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// facetSearch runs the unified search endpoint with arbitrary query +// params and returns (status, decoded array-or-object). +func facetSearch(t *testing.T, setup *TestServerSetup, params url.Values) (int, []map[string]interface{}) { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound { + // The endpoint's no-results contract: 404 + error envelope. + return resp.StatusCode, nil + } + require.Equal(t, http.StatusOK, resp.StatusCode) + var rows []map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows)) + return resp.StatusCode, rows +} + +func facetTitles(t *testing.T, rows []map[string]interface{}) []string { + t.Helper() + var out []string + for _, r := range rows { + title, _ := r["title"].(string) + out = append(out, title) + } + return out +} + +func TestFacetRegistryParamsFilter(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + ctx := context.Background() + + libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") + require.NoError(t, err) + libName := fmt.Sprintf("Test Facet Params Library %d", time.Now().UnixNano()) + library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: libName, + Description: pgtype.Text{String: "facet params test library", Valid: true}, + LibraryTypeID: libType.ID, + CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + }) + require.NoError(t, err) + libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) }) + + createItem := func(title string, mutate func(*database.CreateMediaItemParams)) { + t.Helper() + p := database.CreateMediaItemParams{ + LibraryID: library.ID, + Title: title, + Authors: []string{"Facet Param Author"}, + FilePath: fmt.Sprintf("/tmp/facet-params/%s-%d.epub", title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + } + if mutate != nil { + mutate(&p) + } + _, err := setup.DB.CreateMediaItem(ctx, p) + require.NoError(t, err, "create %s", title) + } + + createItem("Facet Alpha", func(p *database.CreateMediaItemParams) { + p.StoryArc = pgtype.Text{String: "civil war", Valid: true} + p.Imprint = pgtype.Text{String: "Vertigo", Valid: true} + p.Edition = pgtype.Text{String: "First Edition", Valid: true} + p.AgeRating = pgtype.Text{String: "Teen", Valid: true} + p.MangaType = pgtype.Text{String: "yes", Valid: true} + p.Explicit = true + }) + createItem("Facet Beta", func(p *database.CreateMediaItemParams) { + p.StoryArc = pgtype.Text{String: "infinity gauntlet", Valid: true} + p.AgeRating = pgtype.Text{String: "Mature", Valid: true} + p.ReadingDirection = pgtype.Text{String: "rtl", Valid: true} + p.Abridged = true + }) + + base := url.Values{"library_id": []string{libUUID.String()}} + titles := func(params url.Values) []string { + status, rows := facetSearch(t, setup, mergeParams(base, params)) + require.Equal(t, http.StatusOK, status) + return facetTitles(t, rows) + } + + // Unfiltered: both items, the additive params are default-neutral. + got := titles(url.Values{}) + assert.Len(t, got, 2, "unfiltered search returns both items") + + // Scalar-fuzzy: single value... + got = titles(url.Values{"story_arc_filter": {"civil war"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "story_arc scalar-fuzzy") + got = titles(url.Values{"imprint_filter": {"Vertigo"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "imprint scalar-fuzzy") + got = titles(url.Values{"edition_filter": {"First Edition"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "edition scalar-fuzzy") + // ...and comma = OR-within (multi-select). + got = titles(url.Values{"story_arc_filter": {"civil war,infinity gauntlet"}}) + assert.Len(t, got, 2, "comma is OR-within the facet") + + // Exact enums: single + multi. + got = titles(url.Values{"age_rating_filter": {"Teen"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "age_rating exact") + got = titles(url.Values{"age_rating_filter": {"Teen,Mature"}}) + assert.Len(t, got, 2, "age_rating multi-select") + got = titles(url.Values{"format_group_filter": {"reflowable"}}) + assert.Len(t, got, 2, "format_group exact (create default)") + status, _ := facetSearch(t, setup, mergeParams(base, url.Values{"format_group_filter": {"audiobook"}})) + assert.Equal(t, http.StatusNotFound, status, "wrong format_group matches nothing") + got = titles(url.Values{"reading_direction_filter": {"rtl"}}) + assert.Equal(t, []string{"Facet Beta"}, got, "reading_direction exact") + got = titles(url.Values{"manga_type_filter": {"yes"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "manga_type exact (works cross-type by data)") + + // Presence-bound bools: absent = no filter, value = exact. + got = titles(url.Values{"explicit_filter": {"true"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "explicit=true") + got = titles(url.Values{"explicit_filter": {"false"}}) + assert.Equal(t, []string{"Facet Beta"}, got, "explicit=false") + got = titles(url.Values{"abridged_filter": {"true"}}) + assert.Equal(t, []string{"Facet Beta"}, got, "abridged=true") + + // AND-across-facets: story_arc + age_rating intersect to Alpha. + got = titles(url.Values{"story_arc_filter": {"civil war"}, "age_rating_filter": {"Teen"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "AND-across-facets") + + // Language UX (Batch B): a display name normalizes through the + // ingest map — "English" matches the row stored as "en". + createItem("Facet Gamma", func(p *database.CreateMediaItemParams) { + p.Language = pgtype.Text{String: "en", Valid: true} + }) + got = titles(url.Values{"language_filter": {"English"}}) + assert.Equal(t, []string{"Facet Gamma"}, got, "display name matches ISO-stored rows") + got = titles(url.Values{"language_filter": {"en"}}) + assert.Equal(t, []string{"Facet Gamma"}, got, "raw code still matches") +} + +// TestFacetsEndpoint covers GET /api/media-items/facets (Batch C): the +// registry-driven value+count listing, language display names, and the +// family-lens gates (hidden library → 403 on BOTH the facets and search +// endpoints; bool/range facets → 400 because they render as toggles). +func TestFacetsEndpoint(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + ctx := context.Background() + + libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") + require.NoError(t, err) + libName := fmt.Sprintf("Test Facet Listing Library %d", time.Now().UnixNano()) + library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: libName, + Description: pgtype.Text{String: "facet listing test library", Valid: true}, + LibraryTypeID: libType.ID, + CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + }) + require.NoError(t, err) + libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) }) + + // Two items share the genre; one carries the language + narrator. + for _, title := range []string{"Facet List One", "Facet List Two"} { + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: library.ID, + Title: title, + Authors: []string{"Facet Listing Author"}, + Genre: pgtype.Text{String: "fantasy", Valid: true}, + Language: pgtype.Text{String: "en", Valid: true}, + FilePath: fmt.Sprintf("/tmp/facet-listing/%s-%d.epub", title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err) + } + _, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: library.ID, + Title: "Facet List Three", + Authors: []string{"Facet Listing Author"}, + Genre: pgtype.Text{String: "horror", Valid: true}, + Narrators: []string{"Facet Listing Narrator"}, + FilePath: fmt.Sprintf("/tmp/facet-listing/three-%d.epub", time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err) + + getFacets := func(params url.Values) (int, map[string]interface{}) { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+params.Encode(), nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 400 { + return resp.StatusCode, nil + } + var body map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + return resp.StatusCode, body + } + + // Counts correct + ordered by count desc. + status, body := getFacets(url.Values{ + "facet": {"genre"}, + "library_id": {libUUID.String()}, + }) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, "genre", body["facet"]) + values, _ := body["values"].([]interface{}) + require.Len(t, values, 2) + first, _ := values[0].(map[string]interface{}) + second, _ := values[1].(map[string]interface{}) + assert.Equal(t, "fantasy", first["value"]) + assert.Equal(t, float64(2), first["count"], "two items share the genre") + assert.Equal(t, "horror", second["value"]) + assert.Equal(t, float64(1), second["count"]) + + // Language values carry display names (server owns display). + status, body = getFacets(url.Values{ + "facet": {"language"}, + "library_id": {libUUID.String()}, + }) + require.Equal(t, http.StatusOK, status) + values, _ = body["values"].([]interface{}) + require.Len(t, values, 1) + lang, _ := values[0].(map[string]interface{}) + assert.Equal(t, "en", lang["value"], "the raw code rides the param") + assert.Equal(t, "English", lang["display"], "the name is for display") + + // Bool facets render as toggles, not lists → 400. + status, _ = getFacets(url.Values{ + "facet": {"explicit"}, + "library_id": {libUUID.String()}, + }) + assert.Equal(t, http.StatusBadRequest, status) + + // Unknown facet → 400. + status, _ = getFacets(url.Values{ + "facet": {"woodcut"}, + "library_id": {libUUID.String()}, + }) + assert.Equal(t, http.StatusBadRequest, status) + + // Family-lens: a library explicitly hidden from the regular user is + // a 403 on the facets endpoint AND on the search endpoint (an + // explicit library_id outside the visible set is never silently + // empty — AGENTS.md → Library visibility). + _, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{ + UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + LibraryID: library.ID, + IsVisible: false, + }) + require.NoError(t, err) + status, _ = getFacets(url.Values{ + "facet": {"genre"}, + "library_id": {libUUID.String()}, + }) + assert.Equal(t, http.StatusForbidden, status, "hidden library → 403") + + searchReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?library_id="+libUUID.String(), nil) + searchReq.Header.Set("Authorization", "Bearer "+setup.RegularToken) + searchResp, err := http.DefaultClient.Do(searchReq) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, searchResp.Body) + _ = searchResp.Body.Close() + assert.Equal(t, http.StatusForbidden, searchResp.StatusCode, "search with hidden library_id → 403") +} + +func mergeParams(base, extra url.Values) url.Values { + out := url.Values{} + for k, v := range base { + out[k] = v + } + for k, v := range extra { + out[k] = v + } + return out +} diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 7d9ad509..3ea1487b 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -161,3 +161,22 @@ cover_image_path, has_conflict, format_group, badge`. - `author_filter` / `publisher_filter` are now per-element against the arrays; a comma-joined value is OR-within-facet multi-select. The param NAMES and single-value behavior are unchanged. + +## 2026-10-19 — Faceted navigation Batch A: new filter facets + +- `GET /api/media-items/search` accepts ADDITIVE REQUEST params (query + string, response shapes unchanged): `story_arc_filter`, + `imprint_filter`, `edition_filter` (scalar-fuzzy), + `age_rating_filter`, `format_group_filter`, `manga_type_filter`, + `reading_direction_filter` (exact), `explicit_filter`, + `abridged_filter` (bool: absent = unset, "true"/"false" = bound). + A comma-joined value is OR-within-facet multi-select on every new + TEXT facet (the SQL splits via string_to_array). `services/facet_registry.go` + is the Go-side source of truth; the drift-guard unit test + (`TestFacetRegistrySQLDrift`) pins registry ↔ SQL agreement. +- Genre model v2 changes SCANNER OUTPUT (not a wire contract): sidecar + genre selection now stores only top-level shelves in `genre` + (subgenres become tags mapped to their parent shelf; unknown-only + input leaves `genre` empty). Existing rows are unaffected until a + rescan/re-extract. + diff --git a/internal/database/querier.go b/internal/database/querier.go index 8a68c140..0aef411b 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -164,6 +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). + // 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) // Find content-duplicate groups (same library + SHA-256, more than one row) FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error) GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 69b4034b..c92e546e 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2347,6 +2347,662 @@ func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUs return has_device, err } +const FacetAgeRatingValues = `-- name: FacetAgeRatingValues :many +SELECT mi.age_rating::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.age_rating IS NOT NULL AND mi.age_rating != '' +GROUP BY mi.age_rating +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetAgeRatingValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetAgeRatingValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetAgeRatingValues(ctx context.Context, arg FacetAgeRatingValuesParams) ([]FacetAgeRatingValuesRow, error) { + rows, err := q.db.Query(ctx, FacetAgeRatingValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetAgeRatingValuesRow{} + for rows.Next() { + var i FacetAgeRatingValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetAuthorsValues = `-- name: FacetAuthorsValues :many + +SELECT a::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +CROSS JOIN LATERAL unnest(mi.authors) AS a +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND a IS NOT NULL AND a != '' +GROUP BY a +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetAuthorsValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetAuthorsValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +// 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). +// 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). +func (q *Queries) FacetAuthorsValues(ctx context.Context, arg FacetAuthorsValuesParams) ([]FacetAuthorsValuesRow, error) { + rows, err := q.db.Query(ctx, FacetAuthorsValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetAuthorsValuesRow{} + for rows.Next() { + var i FacetAuthorsValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetEditionValues = `-- name: FacetEditionValues :many +SELECT mi.edition::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.edition IS NOT NULL AND mi.edition != '' +GROUP BY mi.edition +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetEditionValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetEditionValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetEditionValues(ctx context.Context, arg FacetEditionValuesParams) ([]FacetEditionValuesRow, error) { + rows, err := q.db.Query(ctx, FacetEditionValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetEditionValuesRow{} + for rows.Next() { + var i FacetEditionValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetFormatGroupValues = `-- name: FacetFormatGroupValues :many +SELECT mi.format_group::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.format_group IS NOT NULL AND mi.format_group != '' +GROUP BY mi.format_group +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetFormatGroupValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetFormatGroupValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetFormatGroupValues(ctx context.Context, arg FacetFormatGroupValuesParams) ([]FacetFormatGroupValuesRow, error) { + rows, err := q.db.Query(ctx, FacetFormatGroupValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetFormatGroupValuesRow{} + for rows.Next() { + var i FacetFormatGroupValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetGenreValues = `-- name: FacetGenreValues :many +SELECT mi.genre::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.genre IS NOT NULL AND mi.genre != '' +GROUP BY mi.genre +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetGenreValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetGenreValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetGenreValues(ctx context.Context, arg FacetGenreValuesParams) ([]FacetGenreValuesRow, error) { + rows, err := q.db.Query(ctx, FacetGenreValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetGenreValuesRow{} + for rows.Next() { + var i FacetGenreValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetImprintValues = `-- name: FacetImprintValues :many +SELECT mi.imprint::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.imprint IS NOT NULL AND mi.imprint != '' +GROUP BY mi.imprint +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetImprintValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetImprintValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetImprintValues(ctx context.Context, arg FacetImprintValuesParams) ([]FacetImprintValuesRow, error) { + rows, err := q.db.Query(ctx, FacetImprintValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetImprintValuesRow{} + for rows.Next() { + var i FacetImprintValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetLanguageValues = `-- name: FacetLanguageValues :many +SELECT mi.language::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.language IS NOT NULL AND mi.language != '' +GROUP BY mi.language +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetLanguageValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetLanguageValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetLanguageValues(ctx context.Context, arg FacetLanguageValuesParams) ([]FacetLanguageValuesRow, error) { + rows, err := q.db.Query(ctx, FacetLanguageValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetLanguageValuesRow{} + for rows.Next() { + var i FacetLanguageValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetMangaTypeValues = `-- name: FacetMangaTypeValues :many +SELECT mi.manga_type::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.manga_type IS NOT NULL AND mi.manga_type != '' AND mi.manga_type != 'unknown' +GROUP BY mi.manga_type +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetMangaTypeValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetMangaTypeValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetMangaTypeValues(ctx context.Context, arg FacetMangaTypeValuesParams) ([]FacetMangaTypeValuesRow, error) { + rows, err := q.db.Query(ctx, FacetMangaTypeValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetMangaTypeValuesRow{} + for rows.Next() { + var i FacetMangaTypeValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetNarratorsValues = `-- name: FacetNarratorsValues :many +SELECT n::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +CROSS JOIN LATERAL unnest(mi.narrators) AS n +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND n IS NOT NULL AND n != '' +GROUP BY n +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetNarratorsValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetNarratorsValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetNarratorsValues(ctx context.Context, arg FacetNarratorsValuesParams) ([]FacetNarratorsValuesRow, error) { + rows, err := q.db.Query(ctx, FacetNarratorsValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetNarratorsValuesRow{} + for rows.Next() { + var i FacetNarratorsValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetPublishersValues = `-- name: FacetPublishersValues :many +SELECT p::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +CROSS JOIN LATERAL unnest(mi.publishers) AS p +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND p IS NOT NULL AND p != '' +GROUP BY p +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetPublishersValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetPublishersValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetPublishersValues(ctx context.Context, arg FacetPublishersValuesParams) ([]FacetPublishersValuesRow, error) { + rows, err := q.db.Query(ctx, FacetPublishersValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetPublishersValuesRow{} + for rows.Next() { + var i FacetPublishersValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetReadingDirectionValues = `-- name: FacetReadingDirectionValues :many +SELECT mi.reading_direction::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto' +GROUP BY mi.reading_direction +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetReadingDirectionValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetReadingDirectionValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetReadingDirectionValues(ctx context.Context, arg FacetReadingDirectionValuesParams) ([]FacetReadingDirectionValuesRow, error) { + rows, err := q.db.Query(ctx, FacetReadingDirectionValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetReadingDirectionValuesRow{} + for rows.Next() { + var i FacetReadingDirectionValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetSeriesValues = `-- name: FacetSeriesValues :many +SELECT mi.series::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.series IS NOT NULL AND mi.series != '' +GROUP BY mi.series +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetSeriesValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetSeriesValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetSeriesValues(ctx context.Context, arg FacetSeriesValuesParams) ([]FacetSeriesValuesRow, error) { + rows, err := q.db.Query(ctx, FacetSeriesValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetSeriesValuesRow{} + for rows.Next() { + var i FacetSeriesValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetStoryArcValues = `-- name: FacetStoryArcValues :many +SELECT mi.story_arc::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.story_arc IS NOT NULL AND mi.story_arc != '' +GROUP BY mi.story_arc +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetStoryArcValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetStoryArcValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetStoryArcValues(ctx context.Context, arg FacetStoryArcValuesParams) ([]FacetStoryArcValuesRow, error) { + rows, err := q.db.Query(ctx, FacetStoryArcValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetStoryArcValuesRow{} + for rows.Next() { + var i FacetStoryArcValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetTagsValues = `-- name: FacetTagsValues :many +SELECT t::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +CROSS JOIN LATERAL unnest(mi.tags) AS t +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND t IS NOT NULL AND t != '' +GROUP BY t +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetTagsValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetTagsValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetTagsValues(ctx context.Context, arg FacetTagsValuesParams) ([]FacetTagsValuesRow, error) { + rows, err := q.db.Query(ctx, FacetTagsValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetTagsValuesRow{} + for rows.Next() { + var i FacetTagsValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const FindHashConflictGroups = `-- name: FindHashConflictGroups :many SELECT library_id, file_sha256, COUNT(*) AS dup_count FROM media_items @@ -13257,44 +13913,69 @@ WHERE COALESCE(lv.is_visible, true) = true SELECT 1 FROM unnest(mi.narrators_search) AS narrator WHERE word_similarity($9, narrator) > 0.3 )) + -- Facet registry Batch A additions (services/facet_registry.go is the + -- Go-side source of truth for these params). Comma = OR-within for + -- every text facet; scalar-fuzzy uses word_similarity per value, + -- exact enums use ANY(), bools are presence-bound (NULL = unset). + AND ($10 = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array($10, ',')) AS f + WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3 + )) + AND ($11 = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array($11, ',')) AS f + WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3 + )) + AND ($12 = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array($12, ',')) AS f + WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3 + )) + AND ($13 = '' OR mi.age_rating = ANY(string_to_array($13, ','))) + AND ($14 = '' OR mi.format_group = ANY(string_to_array($14, ','))) + AND ($15 = '' OR mi.manga_type = ANY(string_to_array($15, ','))) + AND ($16 = '' OR mi.reading_direction = ANY(string_to_array($16, ','))) + AND ($17::bool IS NULL OR mi.explicit = $17::bool) + AND ($18::bool IS NULL OR mi.abridged = $18::bool) -- Year range (exact) - prioritize date_published, fallback to copyright_year AND ( - $10 = 0 OR - EXTRACT(YEAR FROM mi.date_published) >= $10 OR - (mi.date_published IS NULL AND mi.copyright_year >= $10) + $19 = 0 OR + EXTRACT(YEAR FROM mi.date_published) >= $19 OR + (mi.date_published IS NULL AND mi.copyright_year >= $19) ) AND ( - $11 = 0 OR - EXTRACT(YEAR FROM mi.date_published) <= $11 OR - (mi.date_published IS NULL AND mi.copyright_year <= $11) + $20 = 0 OR + EXTRACT(YEAR FROM mi.date_published) <= $20 OR + (mi.date_published IS NULL AND mi.copyright_year <= $20) ) -- Boolean (exact) AND ( - $12::bool IS NULL OR -- Not specified = show all - ($12::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR - ($12::bool IS FALSE AND mi.cover_image_path IS NULL) + $21::bool IS NULL OR -- Not specified = show all + ($21::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR + ($21::bool IS FALSE AND mi.cover_image_path IS NULL) ) -- Search query (fuzzy or exact based on quotes) AND ( - $13 = '' OR + $22 = '' OR -- Fuzzy search (default) - $14 = false AND ( - word_similarity($13, mi.title) > 0.3 OR - word_similarity($13, COALESCE(mi.author, '')) > 0.3 OR - word_similarity($13, COALESCE(mi.series, '')) > 0.3 OR + $23 = false AND ( + word_similarity($22, mi.title) > 0.3 OR + word_similarity($22, COALESCE(mi.author, '')) > 0.3 OR + word_similarity($22, COALESCE(mi.series, '')) > 0.3 OR EXISTS ( SELECT 1 FROM unnest(mi.tags_search) AS tag - WHERE word_similarity($13, tag) > 0.3 + WHERE word_similarity($22, tag) > 0.3 LIMIT 1 ) OR EXISTS ( SELECT 1 FROM unnest(mi.contributors_search) AS contributor - WHERE word_similarity($13, contributor) > 0.3 + WHERE word_similarity($22, contributor) > 0.3 LIMIT 1 ) OR EXISTS ( SELECT 1 FROM unnest(mi.narrators_search) AS narrator - WHERE word_similarity($13, narrator) > 0.3 + WHERE word_similarity($22, narrator) > 0.3 LIMIT 1 ) ) OR @@ -13308,25 +13989,25 @@ WHERE COALESCE(lv.is_visible, true) = true -- sqlc.narg('search_pattern') = ANY(mi.contributors_search) -- ) -- Exact search (with quotes) - true exact match, not substring - $14 = true AND ( - mi.title = $13 OR - COALESCE(mi.author, '') = $13 OR - COALESCE(mi.series, '') = $13 OR - $13 = ANY(mi.tags_search) OR - $13 = ANY(mi.contributors_search) OR - $13 = ANY(mi.narrators_search) + $23 = true AND ( + mi.title = $22 OR + COALESCE(mi.author, '') = $22 OR + COALESCE(mi.series, '') = $22 OR + $22 = ANY(mi.tags_search) OR + $22 = ANY(mi.contributors_search) OR + $22 = ANY(mi.narrators_search) ) ) ORDER BY -- Primary sort: relevance score when searching CASE - WHEN $13 != '' THEN + WHEN $22 != '' THEN GREATEST( - CASE WHEN $14 = false THEN - word_similarity($13, mi.title) + CASE WHEN $23 = false THEN + word_similarity($22, mi.title) ELSE 0 END, - CASE WHEN $14 = false THEN - word_similarity($13, COALESCE(mi.author, '')) + CASE WHEN $23 = false THEN + word_similarity($22, COALESCE(mi.author, '')) ELSE 0 END, word_similarity($3, COALESCE(mi.author, '')), word_similarity($5, COALESCE(mi.genre, '')), @@ -13338,60 +14019,69 @@ ORDER BY END DESC, -- Secondary sort: user-specified sort parameter CASE - WHEN $15 = 'title ASC' THEN mi.title + WHEN $24 = 'title ASC' THEN mi.title ELSE '' END ASC, CASE - WHEN $15 = 'title DESC' THEN mi.title + WHEN $24 = 'title DESC' THEN mi.title ELSE '' END DESC, CASE - WHEN $15 = 'author ASC' THEN COALESCE(mi.author, '') + WHEN $24 = 'author ASC' THEN COALESCE(mi.author, '') ELSE '' END ASC, CASE - WHEN $15 = 'author DESC' THEN COALESCE(mi.author, '') + WHEN $24 = 'author DESC' THEN COALESCE(mi.author, '') ELSE '' END DESC, CASE - WHEN $15 = 'created_at ASC' THEN mi.created_at + WHEN $24 = 'created_at ASC' THEN mi.created_at ELSE '1970-01-01'::timestamp END ASC, CASE - WHEN $15 = 'created_at DESC' THEN mi.created_at + WHEN $24 = 'created_at DESC' THEN mi.created_at ELSE '1970-01-01'::timestamp END DESC, CASE - WHEN $15 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') + WHEN $24 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END ASC, CASE - WHEN $15 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') + WHEN $24 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END DESC, -- Tertiary sort: title (default fallback) mi.title ASC -LIMIT $17 OFFSET $16 +LIMIT $26 OFFSET $25 ` type SearchMediaItemsUnifiedParams struct { - UserID pgtype.UUID `db:"user_id" json:"user_id"` - LibraryID pgtype.UUID `db:"library_id" json:"library_id"` - AuthorFilter interface{} `db:"author_filter" json:"author_filter"` - SeriesFilter interface{} `db:"series_filter" json:"series_filter"` - GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` - PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"` - LanguageFilter interface{} `db:"language_filter" json:"language_filter"` - TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` - NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"` - YearMin interface{} `db:"year_min" json:"year_min"` - YearMax interface{} `db:"year_max" json:"year_max"` - HasCover pgtype.Bool `db:"has_cover" json:"has_cover"` - SearchQuery interface{} `db:"search_query" json:"search_query"` - IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` - Sort interface{} `db:"sort" json:"sort"` - Offset pgtype.Int4 `db:"offset" json:"offset"` - Limit pgtype.Int4 `db:"limit" json:"limit"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + AuthorFilter interface{} `db:"author_filter" json:"author_filter"` + SeriesFilter interface{} `db:"series_filter" json:"series_filter"` + GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` + PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"` + LanguageFilter interface{} `db:"language_filter" json:"language_filter"` + TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` + NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"` + StoryArcFilter interface{} `db:"story_arc_filter" json:"story_arc_filter"` + ImprintFilter interface{} `db:"imprint_filter" json:"imprint_filter"` + EditionFilter interface{} `db:"edition_filter" json:"edition_filter"` + AgeRatingFilter interface{} `db:"age_rating_filter" json:"age_rating_filter"` + FormatGroupFilter interface{} `db:"format_group_filter" json:"format_group_filter"` + MangaTypeFilter interface{} `db:"manga_type_filter" json:"manga_type_filter"` + ReadingDirectionFilter interface{} `db:"reading_direction_filter" json:"reading_direction_filter"` + ExplicitFilter pgtype.Bool `db:"explicit_filter" json:"explicit_filter"` + AbridgedFilter pgtype.Bool `db:"abridged_filter" json:"abridged_filter"` + YearMin interface{} `db:"year_min" json:"year_min"` + YearMax interface{} `db:"year_max" json:"year_max"` + HasCover pgtype.Bool `db:"has_cover" json:"has_cover"` + SearchQuery interface{} `db:"search_query" json:"search_query"` + IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` + Sort interface{} `db:"sort" json:"sort"` + Offset pgtype.Int4 `db:"offset" json:"offset"` + Limit pgtype.Int4 `db:"limit" json:"limit"` } type SearchMediaItemsUnifiedRow struct { @@ -13464,6 +14154,15 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt arg.LanguageFilter, arg.TagsFilter, arg.NarratorFilter, + arg.StoryArcFilter, + arg.ImprintFilter, + arg.EditionFilter, + arg.AgeRatingFilter, + arg.FormatGroupFilter, + arg.MangaTypeFilter, + arg.ReadingDirectionFilter, + arg.ExplicitFilter, + arg.AbridgedFilter, arg.YearMin, arg.YearMax, arg.HasCover, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 9fa8d7b8..d30ed23f 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -718,6 +718,31 @@ WHERE COALESCE(lv.is_visible, true) = true SELECT 1 FROM unnest(mi.narrators_search) AS narrator WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3 )) + -- Facet registry Batch A additions (services/facet_registry.go is the + -- Go-side source of truth for these params). Comma = OR-within for + -- every text facet; scalar-fuzzy uses word_similarity per value, + -- exact enums use ANY(), bools are presence-bound (NULL = unset). + AND (sqlc.narg('story_arc_filter') = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f + WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3 + )) + AND (sqlc.narg('imprint_filter') = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f + WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3 + )) + AND (sqlc.narg('edition_filter') = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f + WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3 + )) + AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ','))) + AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ','))) + AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ','))) + AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ','))) + AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool) + AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool) -- Year range (exact) - prioritize date_published, fallback to copyright_year AND ( sqlc.narg('year_min') = 0 OR @@ -944,6 +969,214 @@ GROUP BY mi.language, word_similarity(sqlc.narg('search_query'), COALESCE(mi.lan ORDER BY score DESC, count DESC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); +-- 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). +-- 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). + +-- name: FacetAuthorsValues :many +SELECT a::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +CROSS JOIN LATERAL unnest(mi.authors) AS a +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND a IS NOT NULL AND a != '' +GROUP BY a +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetPublishersValues :many +SELECT p::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +CROSS JOIN LATERAL unnest(mi.publishers) AS p +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND p IS NOT NULL AND p != '' +GROUP BY p +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetNarratorsValues :many +SELECT n::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +CROSS JOIN LATERAL unnest(mi.narrators) AS n +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND n IS NOT NULL AND n != '' +GROUP BY n +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetTagsValues :many +SELECT t::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +CROSS JOIN LATERAL unnest(mi.tags) AS t +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND t IS NOT NULL AND t != '' +GROUP BY t +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetSeriesValues :many +SELECT mi.series::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.series IS NOT NULL AND mi.series != '' +GROUP BY mi.series +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetGenreValues :many +SELECT mi.genre::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.genre IS NOT NULL AND mi.genre != '' +GROUP BY mi.genre +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetLanguageValues :many +SELECT mi.language::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.language IS NOT NULL AND mi.language != '' +GROUP BY mi.language +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetStoryArcValues :many +SELECT mi.story_arc::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.story_arc IS NOT NULL AND mi.story_arc != '' +GROUP BY mi.story_arc +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetImprintValues :many +SELECT mi.imprint::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.imprint IS NOT NULL AND mi.imprint != '' +GROUP BY mi.imprint +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetEditionValues :many +SELECT mi.edition::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.edition IS NOT NULL AND mi.edition != '' +GROUP BY mi.edition +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetAgeRatingValues :many +SELECT mi.age_rating::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.age_rating IS NOT NULL AND mi.age_rating != '' +GROUP BY mi.age_rating +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetFormatGroupValues :many +SELECT mi.format_group::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.format_group IS NOT NULL AND mi.format_group != '' +GROUP BY mi.format_group +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetMangaTypeValues :many +SELECT mi.manga_type::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.manga_type IS NOT NULL AND mi.manga_type != '' AND mi.manga_type != 'unknown' +GROUP BY mi.manga_type +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + +-- name: FacetReadingDirectionValues :many +SELECT mi.reading_direction::text AS value, COUNT(*) AS count +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = sqlc.narg('library_id') + AND mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto' +GROUP BY mi.reading_direction +ORDER BY count DESC, value ASC +LIMIT sqlc.narg('limit'); + -- Media Notes queries -- name: GetMediaNote :one SELECT * FROM media_notes WHERE id = $1; diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 5c72fba4..910f56d5 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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 { diff --git a/internal/router/search.go b/internal/router/search.go index 5a54a78c..50d4518e 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -3,15 +3,11 @@ package router import ( "bookhoard/internal/database" "bookhoard/internal/handlers" - "bookhoard/internal/services" "bookhoard/templates" "bytes" "log" "net/http" - "strconv" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) @@ -30,6 +26,9 @@ func registerSearchRoutes(cfg *Config) { // 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) } @@ -39,60 +38,37 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { if !ok { return c.String(http.StatusUnauthorized, "Unauthorized") } - // Extract query parameters - query := c.QueryParam("q") + // 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") - limit, _ := strconv.Atoi(c.QueryParam("limit")) - if limit == 0 { - limit = 50 - } - offset, _ := strconv.Atoi(c.QueryParam("offset")) - // Validate library_id - var libUUID pgtype.UUID - if libraryID != "" { - lib, err := uuid.Parse(libraryID) + + // 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 { - return c.String(http.StatusBadRequest, "invalid library_id") + log.Printf("VisibleLibrarySet failed: %v", err) + return c.HTML(http.StatusInternalServerError, `
Search error
`) + } + visible := false + for _, id := range ids { + if id == params.LibraryID { + visible = true + break + } + } + if !visible { + return c.HTML(http.StatusForbidden, `
Library not visible
`) } - libUUID = pgtype.UUID{Bytes: lib, Valid: true} - } else { - libUUID = pgtype.UUID{Valid: false} - } - // Extract filters - authorFilter := c.QueryParam("author_filter") - seriesFilter := c.QueryParam("series_filter") - genreFilter := c.QueryParam("genre_filter") - tagsFilter := c.QueryParam("tags_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 - } - sortParam := c.QueryParam("sort") - if sortParam == "" { - sortParam = "title ASC" - } - // Build search params - params := services.SearchParams{ - UserID: user.ID, - LibraryID: libUUID, - SearchQuery: query, - AuthorFilter: authorFilter, - SeriesFilter: seriesFilter, - GenreFilter: genreFilter, - TagsFilter: tagsFilter, - LanguageFilter: languageFilter, - YearMin: yearMin, - YearMax: yearMax, - HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid}, - Sort: sortParam, - Limit: limit, - Offset: offset, } + // Execute search using handler method results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params) if err != nil { diff --git a/internal/services/facet_registry.go b/internal/services/facet_registry.go new file mode 100644 index 00000000..dca494c6 --- /dev/null +++ b/internal/services/facet_registry.go @@ -0,0 +1,153 @@ +package services + +// Facet registry (faceted-navigation plan, Part 1 Batch A). +// +// The single declarative source of truth for bookshelf filter facets. +// sqlc has NO dynamic WHERE — each registry entry corresponds to an +// explicit `sqlc.narg('_filter')` block in +// `SearchMediaItemsUnified` (internal/database/queries/queries.sql); +// the drift-guard test (facet_registry_test.go) fails the build when an +// entry's block is missing. Adding a facet = one registry entry + one +// narg block + one handler line (+ one templ call in Batch B). +// +// Wire semantics: the query param is `_filter`; a comma-joined +// value is multi-select (OR within the facet, AND across facets) for +// every text kind — the SQL splits via string_to_array. Bools are +// presence-bound: absent → NULL (no filtering), "true"/"false" → bound. + +import "strings" + +// FacetKind classifies the media_items column shape a facet filters on. +type FacetKind string + +const ( + FacetArray FacetKind = "array" // TEXT[] column, per-element match + FacetScalar FacetKind = "scalar" // varchar column + FacetBool FacetKind = "bool" // bool column + FacetRange FacetKind = "range" // int min/max pair (year_min/year_max) +) + +// FacetMatch classifies how values are compared within the kind's SQL +// shape. +type FacetMatch string + +const ( + MatchPerElementFuzzy FacetMatch = "per_element_fuzzy" // word_similarity per array element + MatchScalarFuzzy FacetMatch = "scalar_fuzzy" // word_similarity against the column + MatchExact FacetMatch = "exact" // = / ANY() / bool equality +) + +// Facet is one registry entry. +type Facet struct { + Param string // "publisher" → ?publisher_filter= (see WireParam) + // Wire is an optional full query-param override for facets whose + // wire name predates the _filter convention (has_cover). Existing + // facets migrate in with their frozen wire names — behavior does + // not change. + Wire string + Kind FacetKind + Match FacetMatch + // Column is the media_items column(s) the facet filters (documentation). + Column string + // LibraryTypes limits the facet to those library_types.name values + // (e.g. manga_type only on "manga"). Empty/nil = every library type. + // The All-Libraries view shows the UNION of all facets — matching is + // type-partitioned by the data, so cross-type filtering is correct + // by construction (plan Batch D decision). + LibraryTypes []string +} + +// facetRegistry — the inventory. Order is display order for the filter +// panel (Batch B); the existing facets come first (they shipped +// 646477af..7537063c and their wire behavior is frozen). +var facetRegistry = []Facet{ + {Param: "author", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "authors"}, + {Param: "publisher", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "publishers"}, + {Param: "narrator", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "narrators", LibraryTypes: []string{"audiobooks"}}, + {Param: "tags", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "tags"}, + {Param: "series", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "series"}, + {Param: "genre", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "genre"}, + {Param: "language", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "language"}, + {Param: "year", Kind: FacetRange, Match: MatchExact, Column: "date_published / copyright_year"}, + {Param: "has_cover", Wire: "has_cover", Kind: FacetBool, Match: MatchExact, Column: "cover_image_path"}, + // Batch A additions: + {Param: "story_arc", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "story_arc"}, + {Param: "imprint", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "imprint"}, + {Param: "edition", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "edition"}, + {Param: "age_rating", Kind: FacetScalar, Match: MatchExact, Column: "age_rating"}, + {Param: "format_group", Kind: FacetScalar, Match: MatchExact, Column: "format_group"}, + {Param: "manga_type", Kind: FacetScalar, Match: MatchExact, Column: "manga_type", LibraryTypes: []string{"manga"}}, + {Param: "reading_direction", Kind: FacetScalar, Match: MatchExact, Column: "reading_direction", LibraryTypes: []string{"manga", "comics"}}, + {Param: "explicit", Kind: FacetBool, Match: MatchExact, Column: "explicit"}, + {Param: "abridged", Kind: FacetBool, Match: MatchExact, Column: "abridged"}, +} + +// Facets returns a copy of the registry. +func Facets() []Facet { + out := make([]Facet, len(facetRegistry)) + copy(out, facetRegistry) + return out +} + +// WireParam is the query-param name for the facet: the Wire override +// when set, else `_filter`. +func (f Facet) WireParam() string { + if f.Wire != "" { + return f.Wire + } + return f.Param + "_filter" +} + +// SplitFacetValues splits a wire value into its OR-within alternatives. +// Shared by the SQL semantics (string_to_array) and later by the +// facet-count endpoint so display and matching can never disagree. +func SplitFacetValues(wireValue string) []string { + if wireValue == "" { + return nil + } + parts := strings.Split(wireValue, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} + +// FacetByParam finds a registry entry by its base param name (nil when +// absent). +func FacetByParam(param string) *Facet { + for i := range facetRegistry { + if facetRegistry[i].Param == param { + f := facetRegistry[i] + return &f + } + } + return nil +} + +// FacetsForLibraryType returns the facets that RENDER for a view. An +// empty libraryTypeName is the All-Libraries view → the UNION of every +// registered facet (plan Batch D: no special SQL — matching is +// type-partitioned by the data). A specific type gets facets with no +// type restriction plus those listing it. +func FacetsForLibraryType(libraryTypeName string) []Facet { + if libraryTypeName == "" { + return Facets() + } + var out []Facet + for _, f := range facetRegistry { + if len(f.LibraryTypes) == 0 { + out = append(out, f) + continue + } + for _, t := range f.LibraryTypes { + if t == libraryTypeName { + out = append(out, f) + break + } + } + } + return out +} diff --git a/internal/services/facet_registry_test.go b/internal/services/facet_registry_test.go new file mode 100644 index 00000000..2b0ef3b1 --- /dev/null +++ b/internal/services/facet_registry_test.go @@ -0,0 +1,173 @@ +package services + +import ( + "reflect" + "strings" + "testing" + + "bookhoard/internal/database" + + "github.com/jackc/pgx/v5/pgtype" +) + +// The generated SQL constant for the unified search query. The +// drift-guard test asserts every registry entry's narg block exists in +// it — sqlc has no dynamic WHERE, so registry ↔ SQL agreement is +// enforced here instead (plan: "one registry entry + one narg block"). + +func TestFacetRegistryShape(t *testing.T) { + seen := map[string]bool{} + knownLibraryTypes := map[string]bool{ + "ebooks": true, "comics": true, "manga": true, "audiobooks": true, + } + for _, f := range facetRegistry { + if f.Param == "" { + t.Fatalf("facet with empty Param: %+v", f) + } + wire := f.WireParam() + if seen[wire] { + t.Errorf("duplicate wire param %q", wire) + } + seen[wire] = true + if f.Column == "" { + t.Errorf("%s: empty Column", wire) + } + // Kind ↔ Match consistency. + switch f.Kind { + case FacetArray: + if f.Match != MatchPerElementFuzzy { + t.Errorf("%s: array facets are per-element fuzzy, got %s", wire, f.Match) + } + case FacetScalar: + if f.Match != MatchScalarFuzzy && f.Match != MatchExact { + t.Errorf("%s: scalar facets are scalar-fuzzy or exact, got %s", wire, f.Match) + } + case FacetBool: + if f.Match != MatchExact { + t.Errorf("%s: bool facets are exact, got %s", wire, f.Match) + } + case FacetRange: + if f.Match != MatchExact { + t.Errorf("%s: range facets are exact, got %s", wire, f.Match) + } + default: + t.Errorf("%s: unknown kind %q", wire, f.Kind) + } + for _, lt := range f.LibraryTypes { + if !knownLibraryTypes[lt] { + t.Errorf("%s: unknown library type %q (seed names: ebooks, comics, manga, audiobooks)", wire, lt) + } + } + } +} + +func TestFacetRegistrySQLDrift(t *testing.T) { + // sqlc substitutes sqlc.narg(...) with ? placeholders in the emitted + // const, so the drift guard reflects over the GENERATED params + // struct instead: a narg block exists in the query iff the struct + // has its field. This checks the artifact the binary actually runs. + paramsType := reflect.TypeOf(database.SearchMediaItemsUnifiedParams{}) + dbTags := map[string]reflect.Type{} + for i := 0; i < paramsType.NumField(); i++ { + tag := strings.Split(paramsType.Field(i).Tag.Get("db"), ",")[0] + dbTags[tag] = paramsType.Field(i).Type + } + boolType := reflect.TypeOf(pgtype.Bool{}) + for _, f := range facetRegistry { + wire := f.WireParam() + switch f.Kind { + case FacetRange: + // The year range binds the year_min/year_max pair. + if _, ok := dbTags["year_min"]; !ok { + t.Error("year range facets must bind year_min in SearchMediaItemsUnified") + } + if _, ok := dbTags["year_max"]; !ok { + t.Error("year range facets must bind year_max in SearchMediaItemsUnified") + } + case FacetBool: + ft, ok := dbTags[wire] + if !ok { + t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire) + continue + } + if ft != boolType { + t.Errorf("%s: bool facet must generate pgtype.Bool (the ::bool cast), got %s", wire, ft) + } + default: + if _, ok := dbTags[wire]; !ok { + t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire) + } + } + } + // The Batch A additions specifically (pin the param names). + for _, wire := range []string{ + "story_arc_filter", "imprint_filter", "edition_filter", + "age_rating_filter", "format_group_filter", "manga_type_filter", + "reading_direction_filter", "explicit_filter", "abridged_filter", + } { + if _, ok := dbTags[wire]; !ok { + t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire) + } + } +} + +func TestFacetsForLibraryType(t *testing.T) { + // Empty = All Libraries → the UNION of every facet (Batch D decision). + all := FacetsForLibraryType("") + if len(all) != len(facetRegistry) { + t.Fatalf("all-libraries union = %d facets, want %d", len(all), len(facetRegistry)) + } + + byWire := func(fs []Facet) map[string]bool { + m := map[string]bool{} + for _, f := range fs { + m[f.WireParam()] = true + } + return m + } + + manga := byWire(FacetsForLibraryType("manga")) + if !manga["manga_type_filter"] || !manga["reading_direction_filter"] { + t.Error("manga view must include manga_type and reading_direction") + } + if manga["narrator_filter"] { + t.Error("narrator facet is audiobooks-only, must not render on manga") + } + if !manga["author_filter"] || !manga["genre_filter"] { + t.Error("unrestricted facets render on every library type") + } + + audio := byWire(FacetsForLibraryType("audiobooks")) + if !audio["narrator_filter"] { + t.Error("audiobooks view must include narrator") + } + if audio["manga_type_filter"] || audio["reading_direction_filter"] { + t.Error("manga facets must not render on audiobooks") + } + + comics := byWire(FacetsForLibraryType("comics")) + if !comics["reading_direction_filter"] { + t.Error("comics view must include reading_direction") + } + if comics["manga_type_filter"] { + t.Error("manga_type is manga-only, must not render on comics") + } + + unknown := byWire(FacetsForLibraryType("woodcuts")) + if unknown["manga_type_filter"] || unknown["narrator_filter"] { + t.Error("restricted facets must not render for an unknown type") + } + if !unknown["author_filter"] { + t.Error("unrestricted facets still render for an unknown type") + } +} + +func TestSplitFacetValues(t *testing.T) { + if got := SplitFacetValues(""); got != nil { + t.Errorf("empty → nil, got %v", got) + } + got := SplitFacetValues("Vertigo, DC Black Label ,Vertigo") + if len(got) != 3 || got[0] != "Vertigo" || got[1] != "DC Black Label" || got[2] != "Vertigo" { + t.Errorf("SplitFacetValues = %v, want [Vertigo DC Black Label Vertigo]", got) + } +} diff --git a/internal/services/genre_lexicon.go b/internal/services/genre_lexicon.go index e905c5d3..499e3ee1 100644 --- a/internal/services/genre_lexicon.go +++ b/internal/services/genre_lexicon.go @@ -2,85 +2,128 @@ package services import "strings" -// Smart genre selection for sidecar/embedded metadata (A2c plan §2.5). +// Genre model v2 (faceted-navigation plan, Batch B): the `genre` column +// holds ONLY top-level shelves; every more specific candidate is a tag +// mapped to its parent shelf. User rule: *a subgenre is not a genre, it's +// a tag*. // // Sources arrive as comma-blobs ("Epic, Action & Adventure, Military, // Sword & Sorcery" — one Audible genre string), media-format noise -// ("Audiobook"), or maximally generic tags ("Fiction"). This is a -// deterministic ranked rule set — no network, no guessing service: +// ("Audiobook"), maximally generic tags ("Fiction"), or library-type +// words ("Manga", "Comics" — ComicInfo files that describe the TYPE, +// not a shelf). Deterministic ranked rule set — no network, no guessing: // // 1. split candidate strings on , ; / -// 2. alias-map to canonical genres -// 3. discard demoted (format/edition) tokens — they never win -// 4. most specific (longest) canonical hit wins; "Fiction" is last resort -// 5. every candidate (split parts included) still lands in tags, so -// filtering keeps full coverage even when the genre column holds one +// 2. alias-map variants to canonical forms (shelf or subgenre) +// 3. discard demoted tokens (formats/editions/type-words) — never win +// 4. pick: first DIRECT shelf (source order) > first MAPPED subgenre +// shelf > Fiction/Nonfiction floor > "" (unknown-only: tags carry it) +// 5. every candidate (split parts included) still lands in tags +// +// "adventure" and "action & adventure" are BOTH shelves — provider data +// (Audible/Calibre) uses both, and the user-verified traces pin them: +// {Pirates} → adventure (mapped), Malazan blob → action & adventure +// (direct beats mapped). Merging them is deferred aliasing work. +var genreShelves = map[string]bool{ + // Fiction side + "fiction": true, "action & adventure": true, "adventure": true, + "fantasy": true, "science fiction": true, "horror": true, + "mystery": true, "thriller": true, "romance": true, + "historical fiction": true, "western": true, "war & military": true, + "young adult": true, "middle grade": true, "children's": true, + "classics": true, "humor": true, "poetry": true, "short stories": true, + // Nonfiction side + "nonfiction": true, "biography & memoir": true, "history": true, + "science": true, "true crime": true, "self-help": true, + "philosophy": true, "business": true, +} +// genreFloors are shelves that only win when NO other shelf candidate +// exists (direct or mapped) — maximally generic positions. {Fiction, +// Epic Fantasy} → fantasy, {Fiction} alone → fiction. +func isGenreFloor(lower string) bool { + return lower == "fiction" || lower == "nonfiction" +} + +// genreSubgenreShelf maps subgenre candidates (lowercase) to their parent +// shelf. Anything here is a TAG by definition; its shelf wins the genre +// column only when no direct shelf hit exists. +var genreSubgenreShelf = map[string]string{ + // Fantasy subgenres + "epic fantasy": "fantasy", "sword & sorcery": "fantasy", + "dark fantasy": "fantasy", "urban fantasy": "fantasy", + "paranormal": "fantasy", "high fantasy": "fantasy", + "fairy tales": "fantasy", "mythology": "fantasy", + "folklore": "fantasy", "litrpg": "fantasy", + "progression fantasy": "fantasy", + // Science fiction subgenres + "space opera": "science fiction", "cyberpunk": "science fiction", + "military science fiction": "science fiction", "dystopian": "science fiction", + "post-apocalyptic": "science fiction", "apocalyptic": "science fiction", + "time travel": "science fiction", "alternate history": "science fiction", + "first contact": "science fiction", "steampunk": "science fiction", + // Romance subgenres + "contemporary romance": "romance", "historical romance": "romance", + "paranormal romance": "romance", + // Mystery subgenres + "cozy mystery": "mystery", "crime": "mystery", + "police procedural": "mystery", "noir": "mystery", "hardboiled": "mystery", + // Thriller subgenres + "suspense": "thriller", "espionage": "thriller", + // Horror subgenres + "gothic": "horror", "supernatural": "horror", + // Fiction-side themes + "literary fiction": "fiction", "coming of age": "fiction", + "satire": "fiction", "anthology": "fiction", "alternate world": "fiction", + // War & Military subgenres + "military": "war & military", "war": "war & military", + // Adventure subgenres + "pirates": "adventure", + // Biography & Memoir subgenres + "biography": "biography & memoir", "autobiography": "biography & memoir", + "memoir": "biography & memoir", + // Remaining nonfiction topics → the nonfiction umbrella shelf + "psychology": "nonfiction", "economics": "nonfiction", + "politics": "nonfiction", "religion": "nonfiction", + "spirituality": "nonfiction", "travel": "nonfiction", + "cooking": "nonfiction", "food": "nonfiction", + "health": "nonfiction", "fitness": "nonfiction", + "sports": "nonfiction", "music": "nonfiction", + "art": "nonfiction", "photography": "nonfiction", + "education": "nonfiction", "reference": "nonfiction", + "legal": "nonfiction", "medical": "nonfiction", +} + +// genreAliases maps provider variants to a canonical shelf OR subgenre +// form (which the maps above then resolve). var genreAliases = map[string]string{ - "sci-fi": "science fiction", "scifi": "science fiction", "sf": "science fiction", - "sff": "science fiction", "sci fi": "science fiction", + "sci-fi": "science fiction", "scifi": "science fiction", + "sf": "science fiction", "sff": "science fiction", + "sci fi": "science fiction", "sci_fi": "science fiction", "speculative fiction": "science fiction", - " detectives": "mystery", "detective": "mystery", + "detectives": "mystery", "detective": "mystery", "whodunnit": "mystery", "whodunit": "mystery", "adventure fiction": "action & adventure", "action and adventure": "action & adventure", - "ya": "young adult", - "mg": "middle grade", - "graphic novel": "graphic novels", - "coming of age": "coming of age", - "coming-of-age": "coming of age", - "histfiction": "historical fiction", - "epic": "epic fantasy", - "high fantasy": "epic fantasy", - "military scifi": "military science fiction", - "military sf": "military science fiction", - "space opera": "space opera", - "post apocalyptic": "post-apocalyptic", - "self help": "self-help", - "sci_fi": "science fiction", + "ya": "young adult", "mg": "middle grade", + "coming-of-age": "coming of age", + "histfiction": "historical fiction", + "epic": "epic fantasy", + "military scifi": "military science fiction", + "military sf": "military science fiction", + "post apocalyptic": "post-apocalyptic", + "self help": "self-help", } -// canonicalGenres is the known-genre set (lowercase). A candidate that -// normalizes to one of these wins the `genre` column; longest match beats -// shorter (Sword & Sorcery > Fiction). -var canonicalGenres = map[string]bool{ - "fiction": true, "nonfiction": true, "literary fiction": true, - "classics": true, "poetry": true, "short stories": true, - "anthology": true, "coming of age": true, "satire": true, "humor": true, - "fantasy": true, "epic fantasy": true, "sword & sorcery": true, - "dark fantasy": true, "urban fantasy": true, "paranormal": true, - "paranormal romance": true, "high fantasy": true, "fairy tales": true, - "mythology": true, "folklore": true, - "science fiction": true, "space opera": true, "cyberpunk": true, - "military science fiction": true, "dystopian": true, - "post-apocalyptic": true, "apocalyptic": true, "time travel": true, - "alternate history": true, "first contact": true, "steampunk": true, - "litrpg": true, "progression fantasy": true, - "historical fiction": true, "alternate world": true, - "romance": true, "contemporary romance": true, "historical romance": true, - "mystery": true, "cozy mystery": true, "crime": true, "true crime": true, - "police procedural": true, "noir": true, "hardboiled": true, - "thriller": true, "suspense": true, "espionage": true, "horror": true, - "gothic": true, "supernatural": true, - "action & adventure": true, "adventure": true, "western": true, - "military": true, "war": true, - "biography": true, "autobiography": true, "memoir": true, - "history": true, "science": true, "philosophy": true, - "psychology": true, "self-help": true, "business": true, - "economics": true, "politics": true, "religion": true, - "spirituality": true, "travel": true, "cooking": true, "food": true, - "health": true, "fitness": true, "sports": true, "music": true, - "art": true, "photography": true, "education": true, "reference": true, - "young adult": true, "middle grade": true, "children's": true, - "comics": true, "graphic novels": true, "manga": true, - "legal": true, "medical": true, -} - -// genreDemoted never wins the genre column — media formats and editions, -// not genres. They still pass through to tags. +// genreDemoted never wins the genre column — media formats, editions, +// and library-TYPE words (ComicInfo genre="Manga" describes the type, +// not a shelf; shelves apply uniformly across all library types). They +// still pass through to tags. var genreDemoted = map[string]bool{ "audiobook": true, "unabridged": true, "abridged": true, "dramatization": true, "dramatized": true, "audio drama": true, + "manga": true, "comics": true, "graphic novel": true, + "graphic novels": true, } // splitGenreCandidates splits compound genre strings ("Epic, Action & @@ -102,41 +145,54 @@ func splitGenreCandidates(genres []string) []string { return out } -// pickSmartGenre chooses the best genre from candidate strings. Returns the -// winner ("" when there are no candidates) and the full candidate list for -// the tags array. +// normalizeGenreCandidate lowercases, trims, and alias-resolves one +// candidate; "" means noise (empty input). +func normalizeGenreCandidate(c string) string { + lower := strings.ToLower(strings.TrimSpace(c)) + if lower == "" { + return "" + } + if a, ok := genreAliases[lower]; ok { + return a + } + return lower +} + +// pickSmartGenre chooses the SHELF for the genre column from candidate +// strings (genre model v2). Returns the winner ("" when no shelf is +// known — unknown-only input stays tag-only) and the full candidate +// list for the tags array. func pickSmartGenre(genres []string) (genre string, all []string) { all = splitGenreCandidates(genres) - best := "" - bestLen := -1 + direct := "" // first non-floor shelf, source order + mapped := "" // first subgenre-resolved shelf, source order + floor := "" // fiction / nonfiction, source order for _, c := range all { - lower := strings.ToLower(strings.TrimSpace(c)) + lower := normalizeGenreCandidate(c) if lower == "" || genreDemoted[lower] { continue } - if genreAliases[lower] != "" { - lower = genreAliases[lower] - } - if !canonicalGenres[lower] { - continue - } - // Specificity: the longest canonical form wins; earlier candidates - // win ties (source order is provider significance). - if len(lower) > bestLen { - best = lower - bestLen = len(lower) + switch { + case genreShelves[lower]: + if isGenreFloor(lower) { + if floor == "" { + floor = lower + } + } else if direct == "" { + direct = lower + } + case genreSubgenreShelf[lower] != "": + if mapped == "" { + mapped = genreSubgenreShelf[lower] + } } + // Unknown candidates are tags-only — never guess a shelf. } - if best != "" { - return best, all + if direct != "" { + return direct, all } - // No lexicon hit: fall back to the first non-demoted candidate verbatim - // (display honesty over false taxonomy), "" when only noise existed. - for _, c := range all { - lower := strings.ToLower(strings.TrimSpace(c)) - if lower != "" && !genreDemoted[lower] { - return strings.TrimSpace(c), all - } + if mapped != "" { + return mapped, all } - return "", all + return floor, all } diff --git a/internal/services/search.go b/internal/services/search.go index a6277fd7..f136ecbe 100644 --- a/internal/services/search.go +++ b/internal/services/search.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" "context" "errors" + "fmt" "strings" "github.com/jackc/pgx/v5" @@ -38,6 +39,18 @@ type SearchParams struct { Sort string Limit int Offset int + + // Facet registry Batch A additions (services/facet_registry.go). + // Empty strings / invalid Bools mean "facet not active". + StoryArcFilter string + ImprintFilter string + EditionFilter string + AgeRatingFilter string + FormatGroupFilter string + MangaTypeFilter string + ReadingDirectionFilter string + ExplicitFilter pgtype.Bool + AbridgedFilter pgtype.Bool } // parseSearchQuery detects quoted strings for exact match search @@ -63,6 +76,14 @@ func (s *SearchService) parseSearchQuery(query string) (bool, string) { func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) { // Parse search query for exact match detection isExact, searchQuery := s.parseSearchQuery(params.SearchQuery) + + // Language filter UX: the UI sends display names ("English"); the + // column stores ISO codes ("en"). Normalize each comma element the + // SAME way ingest does (normalizeLanguageCode), so a user-typed or + // deep-linked display name matches the stored code — and unknown + // values that fit still match verbatim rows. (Facet matching and + // ingest can never disagree because both run the same map.) + params.LanguageFilter = normalizeLanguageFilter(params.LanguageFilter) // For now implementing real exact search. If you want to reimplement wildcard patterns uncomment this and line 81 and check queries.sql line 475 and frontend.go line 211 // searchPattern := "" // if isExact { @@ -79,11 +100,24 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true}, NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true}, LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, - YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, - YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, - HasCover: params.HasCover, - SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, - IsExactSearch: pgtype.Bool{Bool: isExact, 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, + SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, + IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, // SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact}, Sort: pgtype.Text{String: params.Sort, Valid: true}, Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, @@ -103,6 +137,187 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear return results, nil } +// normalizeLanguageFilter normalizes every comma element of a language +// filter value through the ingest-side language map (display name → +// ISO code), so filtering and ingest share one source of truth. +func normalizeLanguageFilter(wireValue string) string { + if wireValue == "" { + return "" + } + parts := strings.Split(wireValue, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, normalizeLanguageCode(t)) + } + } + return strings.Join(out, ",") +} + +// FacetValue is one facet value + count for the filter panel. +type FacetValue struct { + Value string `json:"value"` // the raw value (rides the filter param) + // Display is the prettified form when it differs from Value + // (language codes → names); empty → the client shows Value. + Display string `json:"display,omitempty"` + 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 + } + } + 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 + } + return nil, fmt.Errorf("facet %q does not list values", facet.Param) +} + // FieldSearchParams contains parameters for field-specific search (autocomplete) type FieldSearchParams struct { UserID pgtype.UUID @@ -223,7 +438,14 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc } fieldValues := make([]FieldValue, len(results)) for i, r := range results { - fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score} + // Serve display names ("English") — the user clicks a name, + // the search service normalizes it back to the stored ISO + // code ("en"). Unknown codes pass through verbatim. + shown := r.Value.String + if name := LanguageDisplayName(shown); name != "" { + shown = name + } + fieldValues[i] = FieldValue{Value: shown, Count: r.Count, Score: r.Score} } return fieldValues, nil diff --git a/internal/services/series_parsing.go b/internal/services/series_parsing.go index ebfe1af3..f573480d 100644 --- a/internal/services/series_parsing.go +++ b/internal/services/series_parsing.go @@ -217,6 +217,36 @@ func normalizeLanguageCode(v string) string { return "" } +// languageCodeToName — canonical English display name per ISO code, +// mirroring languageNameToCode (server owns display; the column stores +// ISO 639-1). +var languageCodeToName = map[string]string{ + "en": "English", "es": "Spanish", "fr": "French", "de": "German", + "it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian", + "uk": "Ukrainian", "pl": "Polish", "cs": "Czech", "sk": "Slovak", + "sv": "Swedish", "no": "Norwegian", "da": "Danish", "fi": "Finnish", + "is": "Icelandic", "hu": "Hungarian", "ro": "Romanian", "bg": "Bulgarian", + "el": "Greek", "tr": "Turkish", "ar": "Arabic", "he": "Hebrew", + "fa": "Persian", "hi": "Hindi", "bn": "Bengali", "ur": "Urdu", + "th": "Thai", "vi": "Vietnamese", "id": "Indonesian", "ms": "Malay", + "ja": "Japanese", "ko": "Korean", "zh": "Chinese", "sw": "Swahili", + "af": "Afrikaans", "ca": "Catalan", "hr": "Croatian", "sr": "Serbian", + "sl": "Slovenian", "lt": "Lithuanian", "lv": "Latvian", "et": "Estonian", + "ka": "Georgian", "hy": "Armenian", "cy": "Welsh", "ga": "Irish", + "eu": "Basque", "gl": "Galician", +} + +// LanguageDisplayName renders a stored language value for DISPLAY. +// Returns "" when the value is not a known code — callers keep the raw +// value then (legacy verbatim rows like "Pig Latin" display as +// themselves). +func LanguageDisplayName(code string) string { + if name, ok := languageCodeToName[strings.ToLower(strings.TrimSpace(code))]; ok { + return name + } + return "" +} + var languageNameToCode = map[string]string{ "english": "en", "spanish": "es", "español": "es", "french": "fr", "français": "fr", "german": "de", "deutsch": "de", "italian": "it", diff --git a/internal/services/series_parsing_test.go b/internal/services/series_parsing_test.go index 6c20237b..3b4ba009 100644 --- a/internal/services/series_parsing_test.go +++ b/internal/services/series_parsing_test.go @@ -26,10 +26,10 @@ func TestSplitSeriesString(t *testing.T) { func TestSeriesSequenceNumber(t *testing.T) { cases := []struct { - in string - num float64 - found bool - lossy bool + in string + num float64 + found bool + lossy bool }{ {"3", 3, true, false}, {"1.5", 1.5, true, false}, @@ -181,12 +181,34 @@ func TestPickSmartGenre(t *testing.T) { if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" { t.Errorf("audiobook is not a genre, got %q", genre) } - // Fiction is the last resort, not the winner. + // Fiction is the floor: alone it resolves, against a real shelf it loses. if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" { t.Errorf("plain Fiction should still resolve, got %q", genre) } - if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "epic fantasy" { - t.Errorf("specificity must beat generic, got %q", genre) + // Genre model v2: Epic Fantasy is a SUBGENRE — the genre column gets + // its parent shelf and the specific label lives in tags. (Intended + // change from the old longest-match behavior, which returned + // "epic fantasy" here.) + if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "fantasy" { + t.Errorf("subgenre must resolve to its shelf, got %q", genre) + } + // User-verified traces (Batch B decision): + if genre, _ := pickSmartGenre([]string{"Pirates", "Adventure"}); genre != "adventure" { + t.Errorf("direct shelf beats mapped subgenre, got %q", genre) + } + if genre, _ := pickSmartGenre([]string{"Pirates"}); genre != "adventure" { + t.Errorf("mapped subgenre alone resolves to its shelf, got %q", genre) + } + if genre, _ := pickSmartGenre([]string{"Sci-Fi", "Science Fiction", "Fiction"}); genre != "science fiction" { + t.Errorf("direct shelf beats floor, got %q", genre) + } + // Type-words describe the library type, not a shelf. + if genre, _ := pickSmartGenre([]string{"Manga"}); genre != "" { + t.Errorf("manga is a type-word, got %q", genre) + } + // Unknown-only input: no shelf is known — tags carry it, never guess. + if genre, all := pickSmartGenre([]string{"Completely Made-Up Genre"}); genre != "" { + t.Errorf("unknown-only input must leave genre empty, got %q (tags=%v)", genre, all) } }