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:
John O'Keefe committed 2026-10-19 18:59:23 -04:00
1 parent 7537063cb7
commit 7e586df48a
13 files changed
+2297 -283

No files matched your search

+312
View File
@@ -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
}
+19
View File
@@ -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.
+21
View File
@@ -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)
+754 -55
View File
@@ -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,
+233
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+153
View File
@@ -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
}
+173
View File
@@ -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)
}
}
+146 -90
View File
@@ -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
View File
@@ -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
+30
View File
@@ -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",
+29 -7
View File
@@ -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)
} }
} }