feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint
Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.
- Nine new filter params on SearchMediaItemsUnified: story_arc,
imprint, edition (scalar-fuzzy), age_rating, format_group,
manga_type, reading_direction (exact), explicit, abridged
(presence-bound bools). Comma = OR-within multi-select on every
text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
> empty (unknown-only stays tags-only). Type-words (Manga, Comics,
Graphic Novel) join the demote list. Both 'adventure' and 'action &
adventure' are shelves — provider data uses both and the verified
traces pin them.
- Language filter UX: comma elements normalize through the ingest
language map, so display names ('English') match rows stored as ISO
codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
driven value+count listing (14 static per-column queries, family-lens
join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
403 on the search endpoints (was a silent empty set) — JSON branch
and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
search JSON endpoint and the HTMX grid partial; the HTML branch
previously dropped publisher_filter and narrator_filter silently.
Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
This commit is contained in:
1 parent
7537063cb7
commit
7e586df48a
13 files changed
+2297
-283
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -161,3 +161,22 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
- `author_filter` / `publisher_filter` are now per-element against the
|
- `author_filter` / `publisher_filter` are now per-element against the
|
||||||
arrays; a comma-joined value is OR-within-facet multi-select. The
|
arrays; a comma-joined value is OR-within-facet multi-select. The
|
||||||
param NAMES and single-value behavior are unchanged.
|
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.
|
||||||
|
|
||||||
@@ -164,6 +164,27 @@ type Querier interface {
|
|||||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
// label before stats); a capture row must only reference a REAL device.
|
// label before stats); a capture row must only reference a REAL device.
|
||||||
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
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)
|
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||||
|
|||||||
@@ -2347,6 +2347,662 @@ func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUs
|
|||||||
return has_device, err
|
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
|
const FindHashConflictGroups = `-- name: FindHashConflictGroups :many
|
||||||
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
||||||
FROM media_items
|
FROM media_items
|
||||||
@@ -13257,44 +13913,69 @@ WHERE COALESCE(lv.is_visible, true) = true
|
|||||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
||||||
WHERE word_similarity($9, narrator) > 0.3
|
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
|
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
||||||
AND (
|
AND (
|
||||||
$10 = 0 OR
|
$19 = 0 OR
|
||||||
EXTRACT(YEAR FROM mi.date_published) >= $10 OR
|
EXTRACT(YEAR FROM mi.date_published) >= $19 OR
|
||||||
(mi.date_published IS NULL AND mi.copyright_year >= $10)
|
(mi.date_published IS NULL AND mi.copyright_year >= $19)
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
$11 = 0 OR
|
$20 = 0 OR
|
||||||
EXTRACT(YEAR FROM mi.date_published) <= $11 OR
|
EXTRACT(YEAR FROM mi.date_published) <= $20 OR
|
||||||
(mi.date_published IS NULL AND mi.copyright_year <= $11)
|
(mi.date_published IS NULL AND mi.copyright_year <= $20)
|
||||||
)
|
)
|
||||||
-- Boolean (exact)
|
-- Boolean (exact)
|
||||||
AND (
|
AND (
|
||||||
$12::bool IS NULL OR -- Not specified = show all
|
$21::bool IS NULL OR -- Not specified = show all
|
||||||
($12::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
($21::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 FALSE AND mi.cover_image_path IS NULL)
|
||||||
)
|
)
|
||||||
-- Search query (fuzzy or exact based on quotes)
|
-- Search query (fuzzy or exact based on quotes)
|
||||||
AND (
|
AND (
|
||||||
$13 = '' OR
|
$22 = '' OR
|
||||||
-- Fuzzy search (default)
|
-- Fuzzy search (default)
|
||||||
$14 = false AND (
|
$23 = false AND (
|
||||||
word_similarity($13, mi.title) > 0.3 OR
|
word_similarity($22, mi.title) > 0.3 OR
|
||||||
word_similarity($13, COALESCE(mi.author, '')) > 0.3 OR
|
word_similarity($22, COALESCE(mi.author, '')) > 0.3 OR
|
||||||
word_similarity($13, COALESCE(mi.series, '')) > 0.3 OR
|
word_similarity($22, COALESCE(mi.series, '')) > 0.3 OR
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
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
|
LIMIT 1
|
||||||
) OR
|
) OR
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
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
|
LIMIT 1
|
||||||
) OR
|
) OR
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
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
|
LIMIT 1
|
||||||
)
|
)
|
||||||
) OR
|
) OR
|
||||||
@@ -13308,25 +13989,25 @@ WHERE COALESCE(lv.is_visible, true) = true
|
|||||||
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
||||||
-- )
|
-- )
|
||||||
-- Exact search (with quotes) - true exact match, not substring
|
-- Exact search (with quotes) - true exact match, not substring
|
||||||
$14 = true AND (
|
$23 = true AND (
|
||||||
mi.title = $13 OR
|
mi.title = $22 OR
|
||||||
COALESCE(mi.author, '') = $13 OR
|
COALESCE(mi.author, '') = $22 OR
|
||||||
COALESCE(mi.series, '') = $13 OR
|
COALESCE(mi.series, '') = $22 OR
|
||||||
$13 = ANY(mi.tags_search) OR
|
$22 = ANY(mi.tags_search) OR
|
||||||
$13 = ANY(mi.contributors_search) OR
|
$22 = ANY(mi.contributors_search) OR
|
||||||
$13 = ANY(mi.narrators_search)
|
$22 = ANY(mi.narrators_search)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
-- Primary sort: relevance score when searching
|
-- Primary sort: relevance score when searching
|
||||||
CASE
|
CASE
|
||||||
WHEN $13 != '' THEN
|
WHEN $22 != '' THEN
|
||||||
GREATEST(
|
GREATEST(
|
||||||
CASE WHEN $14 = false THEN
|
CASE WHEN $23 = false THEN
|
||||||
word_similarity($13, mi.title)
|
word_similarity($22, mi.title)
|
||||||
ELSE 0 END,
|
ELSE 0 END,
|
||||||
CASE WHEN $14 = false THEN
|
CASE WHEN $23 = false THEN
|
||||||
word_similarity($13, COALESCE(mi.author, ''))
|
word_similarity($22, COALESCE(mi.author, ''))
|
||||||
ELSE 0 END,
|
ELSE 0 END,
|
||||||
word_similarity($3, COALESCE(mi.author, '')),
|
word_similarity($3, COALESCE(mi.author, '')),
|
||||||
word_similarity($5, COALESCE(mi.genre, '')),
|
word_similarity($5, COALESCE(mi.genre, '')),
|
||||||
@@ -13338,60 +14019,69 @@ ORDER BY
|
|||||||
END DESC,
|
END DESC,
|
||||||
-- Secondary sort: user-specified sort parameter
|
-- Secondary sort: user-specified sort parameter
|
||||||
CASE
|
CASE
|
||||||
WHEN $15 = 'title ASC' THEN mi.title
|
WHEN $24 = 'title ASC' THEN mi.title
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $15 = 'title DESC' THEN mi.title
|
WHEN $24 = 'title DESC' THEN mi.title
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END DESC,
|
END DESC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $15 = 'author ASC' THEN COALESCE(mi.author, '')
|
WHEN $24 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $15 = 'author DESC' THEN COALESCE(mi.author, '')
|
WHEN $24 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END DESC,
|
END DESC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $15 = 'created_at ASC' THEN mi.created_at
|
WHEN $24 = 'created_at ASC' THEN mi.created_at
|
||||||
ELSE '1970-01-01'::timestamp
|
ELSE '1970-01-01'::timestamp
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
CASE
|
||||||
WHEN $15 = 'created_at DESC' THEN mi.created_at
|
WHEN $24 = 'created_at DESC' THEN mi.created_at
|
||||||
ELSE '1970-01-01'::timestamp
|
ELSE '1970-01-01'::timestamp
|
||||||
END DESC,
|
END DESC,
|
||||||
CASE
|
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 ''
|
ELSE ''
|
||||||
END ASC,
|
END ASC,
|
||||||
CASE
|
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 ''
|
ELSE ''
|
||||||
END DESC,
|
END DESC,
|
||||||
-- Tertiary sort: title (default fallback)
|
-- Tertiary sort: title (default fallback)
|
||||||
mi.title ASC
|
mi.title ASC
|
||||||
LIMIT $17 OFFSET $16
|
LIMIT $26 OFFSET $25
|
||||||
`
|
`
|
||||||
|
|
||||||
type SearchMediaItemsUnifiedParams struct {
|
type SearchMediaItemsUnifiedParams struct {
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
||||||
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
||||||
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
||||||
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
|
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
|
||||||
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
||||||
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
||||||
NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"`
|
NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"`
|
||||||
YearMin interface{} `db:"year_min" json:"year_min"`
|
StoryArcFilter interface{} `db:"story_arc_filter" json:"story_arc_filter"`
|
||||||
YearMax interface{} `db:"year_max" json:"year_max"`
|
ImprintFilter interface{} `db:"imprint_filter" json:"imprint_filter"`
|
||||||
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
|
EditionFilter interface{} `db:"edition_filter" json:"edition_filter"`
|
||||||
SearchQuery interface{} `db:"search_query" json:"search_query"`
|
AgeRatingFilter interface{} `db:"age_rating_filter" json:"age_rating_filter"`
|
||||||
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
|
FormatGroupFilter interface{} `db:"format_group_filter" json:"format_group_filter"`
|
||||||
Sort interface{} `db:"sort" json:"sort"`
|
MangaTypeFilter interface{} `db:"manga_type_filter" json:"manga_type_filter"`
|
||||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
ReadingDirectionFilter interface{} `db:"reading_direction_filter" json:"reading_direction_filter"`
|
||||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
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 {
|
type SearchMediaItemsUnifiedRow struct {
|
||||||
@@ -13464,6 +14154,15 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
|||||||
arg.LanguageFilter,
|
arg.LanguageFilter,
|
||||||
arg.TagsFilter,
|
arg.TagsFilter,
|
||||||
arg.NarratorFilter,
|
arg.NarratorFilter,
|
||||||
|
arg.StoryArcFilter,
|
||||||
|
arg.ImprintFilter,
|
||||||
|
arg.EditionFilter,
|
||||||
|
arg.AgeRatingFilter,
|
||||||
|
arg.FormatGroupFilter,
|
||||||
|
arg.MangaTypeFilter,
|
||||||
|
arg.ReadingDirectionFilter,
|
||||||
|
arg.ExplicitFilter,
|
||||||
|
arg.AbridgedFilter,
|
||||||
arg.YearMin,
|
arg.YearMin,
|
||||||
arg.YearMax,
|
arg.YearMax,
|
||||||
arg.HasCover,
|
arg.HasCover,
|
||||||
|
|||||||
@@ -718,6 +718,31 @@ WHERE COALESCE(lv.is_visible, true) = true
|
|||||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
||||||
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
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
|
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
||||||
AND (
|
AND (
|
||||||
sqlc.narg('year_min') = 0 OR
|
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
|
ORDER BY score DESC, count DESC
|
||||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
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
|
-- Media Notes queries
|
||||||
-- name: GetMediaNote :one
|
-- name: GetMediaNote :one
|
||||||
SELECT * FROM media_notes WHERE id = $1;
|
SELECT * FROM media_notes WHERE id = $1;
|
||||||
|
|||||||
+169
-71
@@ -73,7 +73,7 @@ type CreateMediaItemRequest struct {
|
|||||||
type UpdateMediaItemRequest struct {
|
type UpdateMediaItemRequest struct {
|
||||||
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
||||||
Author string `form:"author" json:"author"`
|
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"`
|
Subtitle string `form:"subtitle" json:"subtitle"`
|
||||||
Explicit bool `form:"explicit" json:"explicit"`
|
Explicit bool `form:"explicit" json:"explicit"`
|
||||||
ISBN string `form:"isbn" json:"isbn"`
|
ISBN string `form:"isbn" json:"isbn"`
|
||||||
@@ -2349,81 +2349,33 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle media item search with filters
|
// Handle media item search with filters
|
||||||
query := c.QueryParam("q")
|
params := ParseMediaSearchParams(c)
|
||||||
libraryID := c.QueryParam("library_id")
|
params.UserID = userID.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}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Logger().Info("Search parameters",
|
c.Logger().Info("Search parameters",
|
||||||
"query", query,
|
"query", params.SearchQuery,
|
||||||
"library_id", libraryID,
|
"library_id", libraryIDString(c),
|
||||||
"libUUID_valid", libUUID.Valid,
|
"libUUID_valid", params.LibraryID.Valid,
|
||||||
"libUUID_bytes", libUUID.Bytes)
|
"libUUID_bytes", params.LibraryID.Bytes)
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
// Family-lens gate (AGENTS.md → Library visibility): an explicit
|
||||||
if limit == 0 {
|
// library_id outside the requester's visible set is a 403, never a
|
||||||
limit = 50
|
// silent empty set.
|
||||||
}
|
if params.LibraryID.Valid {
|
||||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
visible, err := mh.libraryVisible(c, params.LibraryID)
|
||||||
|
if err != nil {
|
||||||
// Extract filter parameters
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
authorFilter := c.QueryParam("author_filter")
|
}
|
||||||
seriesFilter := c.QueryParam("series_filter")
|
if !visible {
|
||||||
genreFilter := c.QueryParam("genre_filter")
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Logger().Info("SearchMediaItems called",
|
c.Logger().Info("SearchMediaItems called",
|
||||||
"query", query,
|
"query", params.SearchQuery,
|
||||||
"library_id_provided", libraryID != "",
|
"library_id_provided", params.LibraryID.Valid,
|
||||||
"library_id_uuid", libUUID.Valid,
|
"library_id_uuid", params.LibraryID.Valid,
|
||||||
"library_id_bytes", libUUID.Bytes)
|
"library_id_bytes", params.LibraryID.Bytes)
|
||||||
|
|
||||||
// Call shared search service
|
// Call shared search service
|
||||||
results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params)
|
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 {
|
if len(results) == 0 {
|
||||||
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
||||||
"error": "no results found",
|
"error": "no results found",
|
||||||
"query": query,
|
"query": params.SearchQuery,
|
||||||
"results": []interface{}{},
|
"results": []interface{}{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2445,6 +2397,152 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, results)
|
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.)
|
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
||||||
// Returns distinct field values with counts and similarity scores for dropdown population
|
// 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 {
|
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||||
|
|||||||
+30
-54
@@ -3,15 +3,11 @@ package router
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +26,9 @@ func registerSearchRoutes(cfg *Config) {
|
|||||||
// Otherwise return JSON (API clients)
|
// Otherwise return JSON (API clients)
|
||||||
return cfg.MediaHandler.SearchMediaItems(c)
|
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)
|
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,60 +38,37 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return c.String(http.StatusUnauthorized, "Unauthorized")
|
return c.String(http.StatusUnauthorized, "Unauthorized")
|
||||||
}
|
}
|
||||||
// Extract query parameters
|
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
|
||||||
query := c.QueryParam("q")
|
// — 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")
|
libraryID := c.QueryParam("library_id")
|
||||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
||||||
if limit == 0 {
|
// Family-lens gate: an explicit library_id outside the requester's
|
||||||
limit = 50
|
// visible set is a 403 (same rule as the JSON branch).
|
||||||
}
|
if params.LibraryID.Valid {
|
||||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
_, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
|
||||||
// Validate library_id
|
|
||||||
var libUUID pgtype.UUID
|
|
||||||
if libraryID != "" {
|
|
||||||
lib, err := uuid.Parse(libraryID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.String(http.StatusBadRequest, "invalid library_id")
|
log.Printf("VisibleLibrarySet failed: %v", err)
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
|
||||||
|
}
|
||||||
|
visible := false
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == params.LibraryID {
|
||||||
|
visible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
|
return c.HTML(http.StatusForbidden, `<div style="color: red;">Library not visible</div>`)
|
||||||
}
|
}
|
||||||
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
|
// Execute search using handler method
|
||||||
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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('<param>_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 `<Param>_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 `<Param>_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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,85 +2,128 @@ package services
|
|||||||
|
|
||||||
import "strings"
|
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,
|
// Sources arrive as comma-blobs ("Epic, Action & Adventure, Military,
|
||||||
// Sword & Sorcery" — one Audible genre string), media-format noise
|
// Sword & Sorcery" — one Audible genre string), media-format noise
|
||||||
// ("Audiobook"), or maximally generic tags ("Fiction"). This is a
|
// ("Audiobook"), maximally generic tags ("Fiction"), or library-type
|
||||||
// deterministic ranked rule set — no network, no guessing service:
|
// 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 , ; /
|
// 1. split candidate strings on , ; /
|
||||||
// 2. alias-map to canonical genres
|
// 2. alias-map variants to canonical forms (shelf or subgenre)
|
||||||
// 3. discard demoted (format/edition) tokens — they never win
|
// 3. discard demoted tokens (formats/editions/type-words) — never win
|
||||||
// 4. most specific (longest) canonical hit wins; "Fiction" is last resort
|
// 4. pick: first DIRECT shelf (source order) > first MAPPED subgenre
|
||||||
// 5. every candidate (split parts included) still lands in tags, so
|
// shelf > Fiction/Nonfiction floor > "" (unknown-only: tags carry it)
|
||||||
// filtering keeps full coverage even when the genre column holds one
|
// 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{
|
var genreAliases = map[string]string{
|
||||||
"sci-fi": "science fiction", "scifi": "science fiction", "sf": "science fiction",
|
"sci-fi": "science fiction", "scifi": "science fiction",
|
||||||
"sff": "science fiction", "sci fi": "science fiction",
|
"sf": "science fiction", "sff": "science fiction",
|
||||||
|
"sci fi": "science fiction", "sci_fi": "science fiction",
|
||||||
"speculative fiction": "science fiction",
|
"speculative fiction": "science fiction",
|
||||||
" detectives": "mystery", "detective": "mystery",
|
"detectives": "mystery", "detective": "mystery",
|
||||||
"whodunnit": "mystery", "whodunit": "mystery",
|
"whodunnit": "mystery", "whodunit": "mystery",
|
||||||
"adventure fiction": "action & adventure",
|
"adventure fiction": "action & adventure",
|
||||||
"action and adventure": "action & adventure",
|
"action and adventure": "action & adventure",
|
||||||
"ya": "young adult",
|
"ya": "young adult", "mg": "middle grade",
|
||||||
"mg": "middle grade",
|
"coming-of-age": "coming of age",
|
||||||
"graphic novel": "graphic novels",
|
"histfiction": "historical fiction",
|
||||||
"coming of age": "coming of age",
|
"epic": "epic fantasy",
|
||||||
"coming-of-age": "coming of age",
|
"military scifi": "military science fiction",
|
||||||
"histfiction": "historical fiction",
|
"military sf": "military science fiction",
|
||||||
"epic": "epic fantasy",
|
"post apocalyptic": "post-apocalyptic",
|
||||||
"high fantasy": "epic fantasy",
|
"self help": "self-help",
|
||||||
"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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// canonicalGenres is the known-genre set (lowercase). A candidate that
|
// genreDemoted never wins the genre column — media formats, editions,
|
||||||
// normalizes to one of these wins the `genre` column; longest match beats
|
// and library-TYPE words (ComicInfo genre="Manga" describes the type,
|
||||||
// shorter (Sword & Sorcery > Fiction).
|
// not a shelf; shelves apply uniformly across all library types). They
|
||||||
var canonicalGenres = map[string]bool{
|
// still pass through to tags.
|
||||||
"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.
|
|
||||||
var genreDemoted = map[string]bool{
|
var genreDemoted = map[string]bool{
|
||||||
"audiobook": true, "unabridged": true, "abridged": true,
|
"audiobook": true, "unabridged": true, "abridged": true,
|
||||||
"dramatization": true, "dramatized": true, "audio drama": 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 &
|
// splitGenreCandidates splits compound genre strings ("Epic, Action &
|
||||||
@@ -102,41 +145,54 @@ func splitGenreCandidates(genres []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickSmartGenre chooses the best genre from candidate strings. Returns the
|
// normalizeGenreCandidate lowercases, trims, and alias-resolves one
|
||||||
// winner ("" when there are no candidates) and the full candidate list for
|
// candidate; "" means noise (empty input).
|
||||||
// the tags array.
|
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) {
|
func pickSmartGenre(genres []string) (genre string, all []string) {
|
||||||
all = splitGenreCandidates(genres)
|
all = splitGenreCandidates(genres)
|
||||||
best := ""
|
direct := "" // first non-floor shelf, source order
|
||||||
bestLen := -1
|
mapped := "" // first subgenre-resolved shelf, source order
|
||||||
|
floor := "" // fiction / nonfiction, source order
|
||||||
for _, c := range all {
|
for _, c := range all {
|
||||||
lower := strings.ToLower(strings.TrimSpace(c))
|
lower := normalizeGenreCandidate(c)
|
||||||
if lower == "" || genreDemoted[lower] {
|
if lower == "" || genreDemoted[lower] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if genreAliases[lower] != "" {
|
switch {
|
||||||
lower = genreAliases[lower]
|
case genreShelves[lower]:
|
||||||
}
|
if isGenreFloor(lower) {
|
||||||
if !canonicalGenres[lower] {
|
if floor == "" {
|
||||||
continue
|
floor = lower
|
||||||
}
|
}
|
||||||
// Specificity: the longest canonical form wins; earlier candidates
|
} else if direct == "" {
|
||||||
// win ties (source order is provider significance).
|
direct = lower
|
||||||
if len(lower) > bestLen {
|
}
|
||||||
best = lower
|
case genreSubgenreShelf[lower] != "":
|
||||||
bestLen = len(lower)
|
if mapped == "" {
|
||||||
|
mapped = genreSubgenreShelf[lower]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Unknown candidates are tags-only — never guess a shelf.
|
||||||
}
|
}
|
||||||
if best != "" {
|
if direct != "" {
|
||||||
return best, all
|
return direct, all
|
||||||
}
|
}
|
||||||
// No lexicon hit: fall back to the first non-demoted candidate verbatim
|
if mapped != "" {
|
||||||
// (display honesty over false taxonomy), "" when only noise existed.
|
return mapped, all
|
||||||
for _, c := range all {
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(c))
|
|
||||||
if lower != "" && !genreDemoted[lower] {
|
|
||||||
return strings.TrimSpace(c), all
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return "", all
|
return floor, all
|
||||||
}
|
}
|
||||||
+228
-6
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -38,6 +39,18 @@ type SearchParams struct {
|
|||||||
Sort string
|
Sort string
|
||||||
Limit int
|
Limit int
|
||||||
Offset 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
|
// 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) {
|
func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) {
|
||||||
// Parse search query for exact match detection
|
// Parse search query for exact match detection
|
||||||
isExact, searchQuery := s.parseSearchQuery(params.SearchQuery)
|
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
|
// 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 := ""
|
// searchPattern := ""
|
||||||
// if isExact {
|
// if isExact {
|
||||||
@@ -79,11 +100,24 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
|
|||||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||||
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
||||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
|
||||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
|
||||||
HasCover: params.HasCover,
|
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
|
||||||
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
|
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
|
||||||
IsExactSearch: pgtype.Bool{Bool: isExact, 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},
|
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
|
||||||
Sort: pgtype.Text{String: params.Sort, Valid: true},
|
Sort: pgtype.Text{String: params.Sort, Valid: true},
|
||||||
Limit: pgtype.Int4{Int32: int32(params.Limit), 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
|
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)
|
// FieldSearchParams contains parameters for field-specific search (autocomplete)
|
||||||
type FieldSearchParams struct {
|
type FieldSearchParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -223,7 +438,14 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
|||||||
}
|
}
|
||||||
fieldValues := make([]FieldValue, len(results))
|
fieldValues := make([]FieldValue, len(results))
|
||||||
for i, r := range 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
|
return fieldValues, nil
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,36 @@ func normalizeLanguageCode(v string) string {
|
|||||||
return ""
|
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{
|
var languageNameToCode = map[string]string{
|
||||||
"english": "en", "spanish": "es", "español": "es", "french": "fr",
|
"english": "en", "spanish": "es", "español": "es", "french": "fr",
|
||||||
"français": "fr", "german": "de", "deutsch": "de", "italian": "it",
|
"français": "fr", "german": "de", "deutsch": "de", "italian": "it",
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ func TestSplitSeriesString(t *testing.T) {
|
|||||||
|
|
||||||
func TestSeriesSequenceNumber(t *testing.T) {
|
func TestSeriesSequenceNumber(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in string
|
in string
|
||||||
num float64
|
num float64
|
||||||
found bool
|
found bool
|
||||||
lossy bool
|
lossy bool
|
||||||
}{
|
}{
|
||||||
{"3", 3, true, false},
|
{"3", 3, true, false},
|
||||||
{"1.5", 1.5, true, false},
|
{"1.5", 1.5, true, false},
|
||||||
@@ -181,12 +181,34 @@ func TestPickSmartGenre(t *testing.T) {
|
|||||||
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
|
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
|
||||||
t.Errorf("audiobook is not a genre, got %q", 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" {
|
if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" {
|
||||||
t.Errorf("plain Fiction should still resolve, got %q", genre)
|
t.Errorf("plain Fiction should still resolve, got %q", genre)
|
||||||
}
|
}
|
||||||
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "epic fantasy" {
|
// Genre model v2: Epic Fantasy is a SUBGENRE — the genre column gets
|
||||||
t.Errorf("specificity must beat generic, got %q", genre)
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user