From 4ee24e7152957a2defc9b8b9292bdabb5c235cc6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 20 Oct 2026 14:49:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(filters):=20the=20rule=20builder=20?= =?UTF-8?q?=E2=80=94=20composed=20engines=20over=20the=20field=20registry?= =?UTF-8?q?=20(faceted=20plan=20Phase=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test. --- cmd/server/main.go | 2 +- cmd/server/tests/filter_builder_test.go | 718 ++++++++++++++ cmd/server/tests/test_helpers_test.go | 2 +- docs/contract_pins.md | 41 + internal/handlers/media.go | 153 ++- internal/router/search.go | 9 + internal/services/filter_query.go | 1060 +++++++++++++++++++++ internal/services/filter_registry.go | 548 +++++++++++ internal/services/filter_registry_test.go | 131 +++ internal/services/search.go | 48 +- templates/bookshelf.templ | 203 ++-- templates/bookshelf_templ.go | 10 +- web/src/bookshelf.ts | 325 +++---- web/src/filter_builder.ts | 774 +++++++++++++++ web/src/main.ts | 1 + web/src/toast.ts | 14 +- web/static/input.css | 123 +++ web/static/style.css | 2 +- 18 files changed, 3788 insertions(+), 376 deletions(-) create mode 100644 cmd/server/tests/filter_builder_test.go create mode 100644 internal/services/filter_query.go create mode 100644 internal/services/filter_registry.go create mode 100644 internal/services/filter_registry_test.go create mode 100644 web/src/filter_builder.ts diff --git a/cmd/server/main.go b/cmd/server/main.go index 0930bd0f..92b536d7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -165,7 +165,7 @@ func main() { dashboardHandler := handlers.NewDashboardHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries) - mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService) + mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService) matchingHandler := handlers.NewMatchingHandler(queries, connManager) jobsHandler := handlers.NewJobsHandler(queries, worker) diff --git a/cmd/server/tests/filter_builder_test.go b/cmd/server/tests/filter_builder_test.go new file mode 100644 index 00000000..653cb731 --- /dev/null +++ b/cmd/server/tests/filter_builder_test.go @@ -0,0 +1,718 @@ +package main + +// Filter rule builder integration tests (faceted plan Phase 9): the +// composed engines behind the qf wire param — tree semantics (AND/OR/ +// negation/nesting), every user-relative field class (the "my data" +// weld), the note-text substring pin, family lens, suggestion-under- +// rules exactness, qf validation, and qf-over-flat precedence. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "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" +) + +// qfSearch runs the search endpoint with arbitrary params (qf trees go +// in the qf param as JSON) and returns (status, decoded rows). +func qfSearch(t *testing.T, setup *TestServerSetup, token string, 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 "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, nil + } + var rows []map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows)) + return resp.StatusCode, rows +} + +// qfRule is a compact rule builder for tests. +func qfRule(field, op string, values ...string) map[string]interface{} { + return map[string]interface{}{"field": field, "op": op, "values": values} +} + +func qfAll(children ...map[string]interface{}) string { + return qfGroup("all", children...) +} + +func qfAny(children ...map[string]interface{}) string { + return qfGroup("any", children...) +} + +func qfGroup(mode string, children ...map[string]interface{}) string { + b, _ := json.Marshal(map[string]interface{}{"mode": mode, "children": children}) + return string(b) +} + +func qfTitles(t *testing.T, rows []map[string]interface{}) []string { + t.Helper() + out := make([]string, 0, len(rows)) + for _, r := range rows { + title, _ := r["title"].(string) + out = append(out, title) + } + return out +} + +// builderFixtureLib builds one test library + four books with +// trigram-disjoint values. Returns the library UUID and the book titles. +func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, name string) (uuid.UUID, pgtype.UUID) { + t.Helper() + ctx := context.Background() + libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") + require.NoError(t, err) + library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: fmt.Sprintf("Test Builder %s %d", name, time.Now().UnixNano()), + Description: pgtype.Text{String: "filter builder 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) }) + + year := pgtype.Int4{Int32: 1999, Valid: true} + datePublished := pgtype.Date{Time: time.Date(1999, 3, 1, 0, 0, 0, 0, time.UTC), Valid: true} + books := []database.CreateMediaItemParams{ + { + LibraryID: library.ID, Title: name + " Tide", + Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "fantasy", Valid: true}, + Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true}, + Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true}, + Series: pgtype.Text{String: "Voss Saga", Valid: true}, + SeriesNumber: pgtype.Float8{Float64: 1, Valid: true}, + Language: pgtype.Text{String: "en", Valid: true}, + DatePublished: datePublished, CopyrightYear: year, + FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }, + { + LibraryID: library.ID, Title: name + " Stone", + Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", Valid: true}, + Tags: []string{"space"}, PageCount: pgtype.Int4{Int32: 500, Valid: true}, + Series: pgtype.Text{String: "Voss Saga", Valid: true}, + SeriesNumber: pgtype.Float8{Float64: 2, Valid: true}, + FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 2048, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }, + { + LibraryID: library.ID, Title: name + " Fen", + Authors: []string{"Benjamin Quill"}, Genre: pgtype.Text{String: "fantasy", Valid: true}, + Tags: []string{"epic"}, PageCount: pgtype.Int4{Int32: 200, Valid: true}, + Abridged: true, + FilePath: fmt.Sprintf("/tmp/builder/%s-fen-%d.epub", name, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 512, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }, + { + LibraryID: library.ID, Title: name + " Wick", + Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true}, + PageCount: pgtype.Int4{Int32: 700, Valid: true}, + FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 4096, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }, + } + for i := range books { + if books[i].Title == "" { + books[i].Title = fmt.Sprintf("%s Book %d", name, i) + } + // created_at binds explicitly — a zero pgtype would insert NULL + // and the date fields (added_at within N days) would never match. + books[i].CreatedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true} + books[i].ImportedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true} + // Ingest populates the *_search columns (normalized lowercase); + // direct-create fixtures must mirror that or the *_search-side + // match clauses (tags via tags_search) see nothing. + if len(books[i].Authors) > 0 && books[i].AuthorsSearch == nil { + lowered := make([]string, len(books[i].Authors)) + for j, a := range books[i].Authors { + lowered[j] = strings.ToLower(a) + } + books[i].AuthorsSearch = lowered + } + if len(books[i].Tags) > 0 && books[i].TagsSearch == nil { + lowered := make([]string, len(books[i].Tags)) + for j, tag := range books[i].Tags { + lowered[j] = strings.ToLower(tag) + } + books[i].TagsSearch = lowered + } + _, err := setup.DB.CreateMediaItem(ctx, books[i]) + require.NoError(t, err, "create %s", books[i].Title) + } + return libUUID, library.ID +} + +// secondBuilderUser creates a second test user (the "other reader" for +// the my-data weld tests) and returns (userID bytes, JWT). +func secondBuilderUser(t *testing.T, setup *TestServerSetup) (uuid.UUID, string) { + t.Helper() + ctx := context.Background() + suffix := time.Now().UnixNano() + second, err := setup.DB.CreateUser(ctx, database.CreateUserParams{ + Email: fmt.Sprintf("testbuilder%d@tests.bookhoard.internal", suffix), + Username: fmt.Sprintf("testbuilder%d", suffix), + PasswordHash: "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq", + FirstName: pgtype.Text{String: "Second", Valid: true}, + LastName: pgtype.Text{String: "Reader", Valid: true}, + Role: "user", + Timezone: pgtype.Text{String: "UTC", Valid: true}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteUser(ctx, second.ID) }) + token := loginWithCredentials(t, setup.Server, second.Email, "Test@Pass123!") + secondUUID, err := uuid.FromBytes(second.ID.Bytes[:]) + require.NoError(t, err) + return secondUUID, token +} + +func TestFilterComposedSemantics(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + libUUID, _ := builderFixtureLib(t, setup, userID, "Sem") + + titles := func(qf string) []string { + p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}} + status, rows := qfSearch(t, setup, setup.RegularToken, p) + // 404 is the endpoint's pinned no-results contract — an empty set. + require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status) + return qfTitles(t, rows) + } + + // Plain rule: author contains (fuzzy, per-element). + got := titles(qfAll(qfRule("author", "contains", "Voss"))) + assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "author contains") + + // Negation as operator: doesn't contain. + got = titles(qfAll(qfRule("author", "not_contains", "Voss"))) + assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "author not_contains") + + // ALL group = AND. + got = titles(qfAll( + qfRule("author", "contains", "Voss"), + qfRule("genre", "contains", "fantasy"), + )) + assert.Equal(t, []string{"Sem Tide"}, got, "ALL group") + + // ANY group = OR. + got = titles(qfAny( + qfRule("genre", "is", "history"), + qfRule("abridged", "is_true"), + )) + assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "ANY group") + + // Nesting: group ANDed into its parent. + got = titles(qfAll( + qfRule("author", "contains", "Voss"), + map[string]interface{}{"mode": "any", "children": []map[string]interface{}{ + qfRule("genre", "is", "history"), + qfRule("page_count", "at_least", "400"), + }}, + )) + assert.Equal(t, []string{"Sem Stone"}, got, "nested ANY inside ALL") + + // Negated subgroup inside ALL: author Voss AND NOT(genre fantasy). + got = titles(qfAll( + qfRule("author", "contains", "Voss"), + map[string]interface{}{"mode": "any", "children": []map[string]interface{}{ + qfRule("genre", "not_contains", "fantasy"), + qfRule("title", "contains", "zzz-no-match"), + }}, + )) + assert.Equal(t, []string{"Sem Stone"}, got, "negation inside nested group") + + // Array exact + negation. + got = titles(qfAll(qfRule("tags", "is", "epic"))) + assert.ElementsMatch(t, []string{"Sem Tide", "Sem Fen"}, got, "tags is") + got = titles(qfAll(qfRule("tags", "is_not", "epic"))) + assert.ElementsMatch(t, []string{"Sem Stone", "Sem Wick"}, got, "tags isn't") + + // Numbers: between / at_most. + got = titles(qfAll(qfRule("page_count", "between", "250", "550"))) + assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "page_count between") + got = titles(qfAll(qfRule("page_count", "at_most", "250"))) + assert.ElementsMatch(t, []string{"Sem Fen"}, got, "page_count at_most") + + // Empty operators. + got = titles(qfAll(qfRule("series", "empty"))) + assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "series is empty") + got = titles(qfAll(qfRule("isbn", "missing"))) + assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "isbn missing") + + // Enums exact + isn't. + got = titles(qfAll(qfRule("age_rating", "is", "Teen"))) + assert.Empty(t, got, "age_rating is (none set)") + got = titles(qfAll(qfRule("mime_type", "is", "application/epub+zip"))) + assert.Len(t, got, 4, "mime_type is") + + // Series full membership: the alternate_info series matches too. + ctx := context.Background() + _, err := setup.DBPool.Exec(ctx, + `UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb + WHERE title = $1`, "Sem Fen") + require.NoError(t, err) + got = titles(qfAll(qfRule("series", "contains", "Quill Chron"))) + assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership") + got = titles(qfAll(qfRule("series", "is", "Quill Chronicles"))) + assert.Equal(t, []string{"Sem Fen"}, got, "series exact matches alternate_info membership") + + // Dates: within the last N days (all fixtures created now). + got = titles(qfAll(qfRule("added_at", "within_days", "7"))) + assert.Len(t, got, 4, "added_at within the last 7 days") + + // Identifiers: is / isn't (isn't includes books without one). + got = titles(qfAll(qfRule("isbn", "is", "1111111111111"))) + assert.Equal(t, []string{"Sem Tide"}, got, "isbn is") + got = titles(qfAll(qfRule("isbn", "is_not", "1111111111111"))) + assert.Len(t, got, 3, "isbn isn't includes books without an ISBN") + + // Presence. + got = titles(qfAll(qfRule("cover", "missing"))) + assert.Len(t, got, 4, "cover missing") + + // Published year (flat year-range semantics: date_published falls + // back to copyright_year). + got = titles(qfAll(qfRule("published_year", "at_least", "1990"))) + assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least") +} + +func TestFilterUserRelativeFields(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userA := getRegularUserID(t, setup.DB) + userBUUID, tokenB := secondBuilderUser(t, setup) + ctx := context.Background() + + libUUID, _ := builderFixtureLib(t, setup, userA, "Mine") + + // Resolve the fixture media item ids by title. + itemID := func(title string) pgtype.UUID { + var id pgtype.UUID + err := setup.DBPool.QueryRow(ctx, + `SELECT id FROM media_items WHERE title = $1`, title).Scan(&id) + require.NoError(t, err, "fixture item %s", title) + return id + } + tideID := itemID("Mine Tide") + stoneID := itemID("Mine Stone") + fenID := itemID("Mine Fen") + wickID := itemID("Mine Wick") + + toPg := func(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: id, Valid: true} } + aID := toPg(userA) + bID := toPg(userBUUID) + + // Reader A: reading Tide (50%), finished Stone, rated Tide 8, + // reviewed Fen, noted on Tide ("the catharsis scene"), highlighted + // Stone, time on Wick (2h), collection holding Tide. + _, err := setup.DBPool.Exec(ctx, + `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`, + tideID, aID, stoneID, aID) + require.NoError(t, err) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`, + tideID, bID) + require.NoError(t, err) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID) + require.NoError(t, err) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID) + require.NoError(t, err) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID) + require.NoError(t, err) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID) + require.NoError(t, err) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID) + require.NoError(t, err) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`, + aID, wickID, aID, tideID) + require.NoError(t, err) + + var collID pgtype.UUID + err = setup.DBPool.QueryRow(ctx, + `INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`, + aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) }) + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID) + require.NoError(t, err) + + titlesFor := func(token string, qf string) []string { + p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}} + status, rows := qfSearch(t, setup, token, p) + // 404 = the pinned no-results contract (an empty set). + require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status) + return qfTitles(t, rows) + } + + // Status: the "my data" weld — same tree, different readers. + assert.ElementsMatch(t, []string{"Mine Tide"}, + titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "reading"))), "A: Tide is in progress") + assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("status", "is", "reading"))), "B: nothing in progress") + assert.ElementsMatch(t, []string{"Mine Tide"}, + titlesFor(tokenB, qfAll(qfRule("status", "is", "finished"))), "B finished Tide; A did not") + assert.ElementsMatch(t, []string{"Mine Fen", "Mine Wick"}, + titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "unread"))), "A unread") + + // Progress percent. + assert.ElementsMatch(t, []string{"Mine Stone"}, + titlesFor(setup.RegularToken, qfAll(qfRule("progress_percent", "at_least", "60")))) + + // Last read. + assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("last_read", "within_days", "7"))), 2) + + // My rating: two worlds. + assert.ElementsMatch(t, []string{"Mine Tide"}, + titlesFor(setup.RegularToken, qfAll(qfRule("my_rating", "at_least", "5")))) + assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("my_rating", "at_least", "5"))), "B rated 3") + assert.ElementsMatch(t, []string{"Mine Fen", "Mine Stone", "Mine Wick"}, + titlesFor(tokenB, qfAll(qfRule("my_rating", "unrated"))), "B rated only Tide") + + // Review presence. + assert.ElementsMatch(t, []string{"Mine Fen"}, + titlesFor(setup.RegularToken, qfAll(qfRule("review", "has")))) + assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("review", "has")))) + + // Notes presence + recency. + assert.ElementsMatch(t, []string{"Mine Tide"}, + titlesFor(setup.RegularToken, qfAll(qfRule("notes", "has")))) + + // Note text: substring pin + the weld. A's note says "catharsis": + // searching "catharsis" matches, "Carthage" does NOT (substring, + // not fuzzy), and B — no notes — sees nothing either way. + assert.ElementsMatch(t, []string{"Mine Tide"}, + titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "catharsis")))) + assert.Empty(t, titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "Carthage"))), + "substring semantics: catharsis ≠ Carthage") + assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("note_text", "contains", "catharsis"))), + "my data: B's notes are the only notes in B's world") + + // Highlight text. + assert.ElementsMatch(t, []string{"Mine Stone"}, + titlesFor(setup.RegularToken, qfAll(qfRule("highlight_text", "contains", "dark forest")))) + + // Time spent (minutes) + pages read. + assert.ElementsMatch(t, []string{"Mine Wick"}, + titlesFor(setup.RegularToken, qfAll(qfRule("time_spent", "at_least", "60")))) + assert.ElementsMatch(t, []string{"Mine Wick"}, + titlesFor(setup.RegularToken, qfAll(qfRule("pages_read", "at_least", "100")))) + + // Collections: in / in none. + assert.ElementsMatch(t, []string{"Mine Tide"}, + titlesFor(setup.RegularToken, qfAll(qfRule("collection", "in_any")))) + assert.Len(t, titlesFor(tokenB, qfAll(qfRule("collection", "in_none"))), 4, + "B is in no collections") + + // Library field (holding structure): the fixture library holds all + // four books. + assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("library", "is", libUUID.String()))), 4) +} + +func TestFilterSuggestionsUnderRules(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + libUUID, _ := builderFixtureLib(t, setup, userID, "Sug") + + // Add a second distinct author+genre pair (trigram-disjoint). + ctx := context.Background() + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + Title: "Sug Dune", + Authors: []string{"Zephyrine Hale"}, + Genre: pgtype.Text{String: "spacewestern", Valid: true}, + FilePath: fmt.Sprintf("/tmp/builder/sug-dune-%d.epub", time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 128, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err) + + suggest := func(field, typed, qf string) (int, []map[string]interface{}) { + p := url.Values{"library_id": {libUUID.String()}, field: {typed}, "limit": {"20"}} + if qf != "" { + p.Set("qf", qf) + } + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.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.StatusOK { + return resp.StatusCode, nil + } + var body struct { + Results []map[string]interface{} `json:"results"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + return resp.StatusCode, body.Results + } + valuesOf := func(results []map[string]interface{}) []string { + out := make([]string, 0, len(results)) + for _, r := range results { + v, _ := r["value"].(string) + out = append(out, v) + } + return out + } + + // Typed genre menu: both genres surface for their prefixes. + status, results := suggest("genre", "fan", "") + require.Equal(t, http.StatusOK, status) + assert.Contains(t, valuesOf(results), "fantasy") + + // The two-worlds example: author contains Voss → the genre menu is + // HER genres only; author DOESN'T contain → her-only genres absent. + status, results = suggest("genre", "fan", qfAll(qfRule("author", "contains", "Voss"))) + require.Equal(t, http.StatusOK, status) + assert.Contains(t, valuesOf(results), "fantasy") + + // NOTE: "fan" + not_contains STILL lists fantasy — Quill's Fen is + // fantasy too; the genre is not author-exclusive. The clean + // two-worlds demo is spacewestern (only Hale's book), below. + + // ...and the OTHER world's genre appears only when Voss is excluded. + status, results = suggest("genre", "space", qfAll(qfRule("author", "not_contains", "Voss"))) + require.Equal(t, http.StatusOK, status) + assert.Contains(t, valuesOf(results), "spacewestern", "the other world's genre surfaces under negation") + status, results = suggest("genre", "space", qfAll(qfRule("author", "contains", "Voss"))) + require.Equal(t, http.StatusOK, status) + assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered") + + // Suggestions are symmetric: the genre rule constrains the AUTHOR + // menu just the same. + status, results = suggest("author", "zeph", qfAll(qfRule("genre", "is", "spacewestern"))) + require.Equal(t, http.StatusOK, status) + assert.Contains(t, valuesOf(results), "Zephyrine Hale") + status, results = suggest("author", "amara", qfAll(qfRule("genre", "is", "spacewestern"))) + require.Equal(t, http.StatusOK, status) + assert.NotContains(t, valuesOf(results), "Amara Voss", "zero-match values never offered") + + // Library field menu: visibility-scoped, id values + name display + // (this test's fixture libraries are named "Test Builder Sug …"). + status, results = suggest("library", "Test Builder", "") + require.Equal(t, http.StatusOK, status) + found := false + for _, r := range results { + if r["value"] == libUUID.String() { + found = true + } + } + assert.True(t, found, "the visible library is offered") +} + +func TestFilterFamilyLens(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) + + makeLib := func(name string) (uuid.UUID, pgtype.UUID) { + lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: fmt.Sprintf("Test Lens %s %d", name, time.Now().UnixNano()), + Description: pgtype.Text{String: "family lens test library", Valid: true}, + LibraryTypeID: libType.ID, + CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + }) + require.NoError(t, err) + id, err := uuid.FromBytes(lib.ID.Bytes[0:16]) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) }) + return id, lib.ID + } + _, visibleID := makeLib("Visible") + hiddenUUID, hiddenID := makeLib("Hidden") + + // One book in each, same author so the All-Books menu would + // aggregate both if the lens failed. + for _, lib := range []struct { + id pgtype.UUID + title string + }{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} { + _, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: lib.id, Title: lib.title, + Authors: []string{"Lens Secret Author"}, + FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 64, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err) + } + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`, + hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true}) + require.NoError(t, err) + + search := func(params url.Values) (int, []map[string]interface{}) { + return qfSearch(t, setup, setup.RegularToken, params) + } + + // The composed search never sees the hidden library's books — + // even when a rule names it explicitly. + qfHide := qfAll(qfRule("library", "is", hiddenUUID.String())) + status, rows := search(url.Values{"qf": {qfHide}}) + // 200-with-empty or the 404 no-results contract — either way: nothing. + assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status) + assert.Empty(t, rows, "a rule naming a hidden library matches nothing") + + // Explicit out-of-set scope + a tree: 403 (never silent). + status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "contains", "Lens"))}, "library_id": {hiddenUUID.String()}}) + assert.Equal(t, http.StatusForbidden, status) + + // The library suggestion menu never offers hidden libraries (a + // real typed prefix — empty typed values skip the autocomplete + // branch entirely). + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+url.Values{ + "library": {"Test Lens"}, "limit": {"50"}, + }.Encode(), nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + var body struct { + Results []map[string]interface{} `json:"results"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + for _, r := range body.Results { + assert.NotEqual(t, hiddenUUID.String(), r["value"], "hidden library never offered") + } + + // filter-count: out-of-set scope is a 403 too. + req, _ = http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?qf="+url.QueryEscape(qfAll(qfRule("author", "contains", "Lens")))+"&library_id="+hiddenUUID.String(), nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestFilterQFValidationAndPrecedence(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + libUUID, _ := builderFixtureLib(t, setup, userID, "Val") + + search := func(params url.Values) (int, []map[string]interface{}) { + return qfSearch(t, setup, setup.RegularToken, params) + } + + // Unknown field → 400. + status, _ := search(url.Values{"qf": {qfAll(qfRule("not_a_field", "is", "x"))}}) + assert.Equal(t, http.StatusBadRequest, status, "unknown field is a 400") + + // Unknown operator → 400. + status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "explodes", "x"))}}) + assert.Equal(t, http.StatusBadRequest, status, "unknown operator is a 400") + + // Operator not allowed on the field → 400. + status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "contains", "3"))}}) + assert.Equal(t, http.StatusBadRequest, status, "operator not allowed on field") + + // between with one value → 400. + status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "between", "3"))}}) + assert.Equal(t, http.StatusBadRequest, status, "between needs two values") + + // Malformed JSON → 400. + status, _ = search(url.Values{"qf": {"{not json"}}) + assert.Equal(t, http.StatusBadRequest, status) + + // SQL injection in a VALUE is just a value (bound parameter): no + // error, no match. + inj := qfAll(qfRule("author", "contains", "x' OR 1=1 --")) + status, rows := search(url.Values{"qf": {inj}}) + assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status, + "injection attempt is a bound value, not SQL") + assert.Empty(t, rows) + + // Precedence: a URL carrying BOTH qf and flat params — qf wins. + p := url.Values{ + "library_id": {libUUID.String()}, + "qf": {qfAll(qfRule("author", "contains", "Voss"))}, + "author_filter": {"Coraline Dar"}, + } + status, rows = search(p) + require.Equal(t, http.StatusOK, status) + assert.ElementsMatch(t, []string{"Val Tide", "Val Stone"}, qfTitles(t, rows), + "qf wins over flat on a hand-crafted URL") + + // Live count endpoint: rules + scope, a true COUNT. + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?"+url.Values{ + "qf": {qfAll(qfRule("author", "contains", "Voss"))}, "library_id": {libUUID.String()}, + }.Encode(), nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + var body struct { + Count int `json:"count"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, 2, body.Count, "live count is the true match count") + + // Empty group → 400 (parse validation). + status, _ = search(url.Values{"qf": {`{"mode":"all","children":[]}`}}) + assert.Equal(t, http.StatusBadRequest, status) +} + +func TestFilterFlatEquivalence(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + libUUID, _ := builderFixtureLib(t, setup, userID, "Eqv") + scope := url.Values{"library_id": {libUUID.String()}} + + // The tree and its flat deep link return the SAME rows (the smart + // serialization promise: flat links keep their semantics). + statusFlat, flatRows := qfSearch(t, setup, setup.RegularToken, + mergeParams(scope, url.Values{"author_filter": {"Voss"}})) + require.Equal(t, http.StatusOK, statusFlat) + statusTree, treeRows := qfSearch(t, setup, setup.RegularToken, + mergeParams(scope, url.Values{"qf": {qfAll(qfRule("author", "contains", "Voss"))}})) + require.Equal(t, http.StatusOK, statusTree) + assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows), + "tree and flat link return identical rows") + + // Enum: exact flat vs tree is (both may 404-no-results — the empty + // set on both sides is still equivalence). + statusFlat, flatRows = qfSearch(t, setup, setup.RegularToken, + mergeParams(scope, url.Values{"age_rating_filter": {"Teen"}})) + statusTree, treeRows = qfSearch(t, setup, setup.RegularToken, + mergeParams(scope, url.Values{"qf": {qfAll(qfRule("age_rating", "is", "Teen"))}})) + require.Equal(t, statusFlat, statusTree, "same result status") + assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows)) +} diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 7836c149..7f432696 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -507,7 +507,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { statisticsService := services.NewStatisticsService(queries) statsHandler := handlers.NewStatsHandler(statisticsService) seriesHandler := handlers.NewSeriesHandler(queries) - mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService, worker) + mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker) matchingHandler := handlers.NewMatchingHandler(queries, connManager) // Create conversion service for OPDS diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 3ccdc88f..6b22ca81 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -301,3 +301,44 @@ cover_image_path, has_conflict, format_group, badge`. the standing rule forbids). The single-facet `/api/media-items/facets?facet=X` shape still REQUIRES `library_id` — that pin is unchanged. + +## 2026-10-20 — Filter rule builder: the `qf` wire param + composed engines (faceted plan Phase 9) + +- NEW wire param on the search endpoint family: `qf` — a JSON rule tree + `{mode: all|any, children: [{field, op, values} | {mode, children}]}`. + Fields/operators are registry-typed constants + (`services/filter_registry.go` — ~55 fields incl. every book column, + file/lifecycle facts, the three HOLDING STRUCTURES — library, + collections (playlists included, `excluded IS NOT TRUE` membership), + series (primary + `alternate_info` full membership) — and the whole + user-relative class reading the VIEWER's rows). Unknown fields or + operators, disallowed operator/field pairs, malformed values, empty + groups, and nesting beyond 8 are a **400** (`ErrInvalidFilterTree`), + never a silently-ignored token. Values are always bound pgx + parameters — an injection-shaped value is just a value. +- **Precedence (pinned)**: when a request carries BOTH `qf` and flat + facet params, **qf wins** — the flat params are ignored by the engine. + Flat links keep working unchanged server-side (and a tree equivalent + to a flat link returns identical rows — pinned by test). +- The composed engines (`services/filter_query.go`) run the mandatory + WHERE head (visibility join + archived + missing-scan — family lens) + for search, suggestions, and count alike; row shape is identical to + `SearchMediaItemsUnifiedRow`. A rule naming a hidden library matches + nothing; an explicit out-of-set `library_id` stays a 403 on every + entry point. +- NEW endpoint `GET /api/media-items/filter-count?qf=…[&library_id=…]` + — the builder's live count: a true COUNT(*) of the rules + scope + (not the page length). Family-lens 403 applies. +- Field-values autocomplete UPGRADED in place (same route): ANY registry + field key in its bare form (`?note_text=…`, `?collection=…`, + `?library=…`) routes to the field-values path; with a `qf` param the + OTHER rules bind as filters (symmetric under-filter suggestions, + zero-match values never offered). Without a tree, the classic six + (author/genre/tags/narrator/series/language) keep their static + queries and response shape — pinned contract, unchanged. ID-valued + suggestions (library/collection/added_by) add an OPTIONAL + `display` field to result items (`value` stays the id; `omitempty` + elsewhere). +- The Phase-7 facet-panel partial stays removed; the all-facets JSON + endpoint and its two shapes are unchanged (they serve flat params — + the builder's UI does not use them). diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 7733ad20..42b78347 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -226,11 +226,11 @@ type MediaHandler struct { // NewMediaHandler requires the shared ProgressService — progress writes // must funnel through SaveProgress (the LWW-seeded canonical writer); // a nil service has nowhere to fall back to and must not be wired. -func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler { +func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler { mh := &MediaHandler{ db: db, libraryService: libraryService, - searchService: services.NewSearchService(db), + searchService: services.NewSearchService(db, raw), progressSvc: progressSvc, annotationSvc: annotationSvc, } @@ -2328,30 +2328,29 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"}) } - // Detect autocomplete queries first (author=value, genre=value, etc.) - if author := c.QueryParam("author"); author != "" { - return mh.handleFieldValuesSearch(c, userID.ID, "author", author) - } - if genre := c.QueryParam("genre"); genre != "" { - return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre) - } - if tags := c.QueryParam("tags"); tags != "" { - return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags) - } - if narrator := c.QueryParam("narrator"); narrator != "" { - return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator) - } - if series := c.QueryParam("series"); series != "" { - return mh.handleFieldValuesSearch(c, userID.ID, "series", series) - } - if language := c.QueryParam("language"); language != "" { - return mh.handleFieldValuesSearch(c, userID.ID, "language", language) + // Autocomplete/suggestion queries: ANY registry field key in its + // bare form (author=, genre=, note_text=, collection=, ...) routes + // to the field-values path — static for the classic six without a + // rule tree, composed under the tree otherwise (faceted plan + // Phase 9: same route, no new API surface). + for _, key := range services.FilterFieldKeys() { + if v := c.QueryParam(key); v != "" { + return mh.handleFieldValuesSearch(c, userID.ID, key, v) + } } // Handle media item search with filters params := ParseMediaSearchParams(c) params.UserID = userID.ID + // Filter rule builder: a malformed qf is a 400 (bad request), never + // a silently-ignored token and never a 500. + if params.FilterParseErr != "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid filter: " + params.FilterParseErr, + }) + } + c.Logger().Info("Search parameters", "query", params.SearchQuery, "library_id", libraryIDString(c), @@ -2445,7 +2444,7 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams { return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true} } - return services.SearchParams{ + params := services.SearchParams{ SearchQuery: c.QueryParam("q"), LibraryID: libUUID, AuthorFilter: c.QueryParam("author_filter"), @@ -2471,6 +2470,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams { Limit: limit, Offset: offset, } + + // Filter rule builder (faceted plan Phase 9): the qf param carries + // the rule tree. Parse + validate HERE (the shared parser — every + // entry point validates identically); a failure lands in + // FilterParseErr for the endpoints to answer 400, never a silently + // ignored token. Precedence: qf present → flat facet params are + // ignored by the engine (qf wins). + if qf := c.QueryParam("qf"); qf != "" { + tree, err := services.ParseFilterTree(qf) + if err != nil { + params.FilterParseErr = err.Error() + } else { + params.FilterTree = tree + } + } + return params } // libraryVisible reports whether the requested library is inside the @@ -2583,14 +2598,22 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error { }) } -// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.) -// Returns distinct field values with counts and similarity scores for dropdown population +// handleFieldValuesSearch handles autocomplete/suggestion queries +// (author=value, genre=value, note_text=value, …). Returns distinct +// field values with counts and similarity scores for dropdown +// population. // // library_id is OPTIONAL: absent means "every library the requesting user // can see" — the per-user family lens is enforced inside the query (the // library_visibility join), so the All-Books view's suggestions aggregate // the visible world. An explicit library_id outside the visible set is a // 403 (AGENTS standing rule: never silently pass). +// +// Routing (faceted plan Phase 9): with a qf rule tree, or for fields +// beyond the classic six, suggestions come from the COMPOSED engine — +// the other rules bind as filters (symmetric: every rule's menu sees +// every other rule; zero-match values are never offered). Without a +// tree, the classic six keep their static queries (pinned contract). func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error { var libPg pgtype.UUID if libraryID := c.QueryParam("library_id"); libraryID != "" { @@ -2614,27 +2637,93 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U } offset, _ := strconv.Atoi(c.QueryParam("offset")) - params := services.FieldSearchParams{ - UserID: userID, - LibraryID: libPg, - FieldType: fieldType, - SearchQuery: searchQuery, - Limit: limit, - Offset: offset, + // qf: the OTHER completed rules (the client strips the rule being + // edited). Parse + validate here — a malformed tree is a 400. + var tree *services.FilterTree + if qf := c.QueryParam("qf"); qf != "" { + t, err := services.ParseFilterTree(qf) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter: " + err.Error()}) + } + tree = t } - results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params) + // Static path: the classic six with no rule tree (unchanged + // contract — the tag-dropdown island and deep links still ride it). + staticSix := tree == nil + if staticSix { + switch fieldType { + case "author", "genre", "tags", "narrator", "series", "language": + params := services.FieldSearchParams{ + UserID: userID, + LibraryID: libPg, + FieldType: fieldType, + SearchQuery: searchQuery, + Limit: limit, + Offset: offset, + } + results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params) + if err != nil { + c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{ + "results": results, + "total": len(results), + }) + } + } + + // Composed path: registry field under the other rules. + results, err := mh.searchService.ComposedFieldValues(c.Request().Context(), services.ComposedSuggestParams{ + UserID: userID, + LibraryID: libPg, + FieldKey: fieldType, + Typed: searchQuery, + Tree: tree, + Limit: limit, + }) if err != nil { c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(results), }) } +// FilterCount handles GET /api/media-items/filter-count — the rule +// builder's live match count: the composed rules + the library scope, +// no pagination (a true COUNT(*), not the page length). +func (mh *MediaHandler) FilterCount(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"}) + } + params := ParseMediaSearchParams(c) + if params.FilterParseErr != "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid filter: " + params.FilterParseErr, + }) + } + if params.LibraryID.Valid { + visible, err := mh.libraryVisible(c, params.LibraryID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + if !visible { + return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"}) + } + } + count, err := mh.searchService.ComposedCount(c.Request().Context(), user.ID, params.LibraryID, params.FilterTree) + if err != nil { + c.Logger().Error("filter count error", "error", err.Error()) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"count": count}) +} + // getFullFilePath returns the absolute filesystem path for a media item // Uses LibraryService for resolution (one source of truth) func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) { diff --git a/internal/router/search.go b/internal/router/search.go index 50d4518e..1984573f 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -29,6 +29,9 @@ func registerSearchRoutes(cfg *Config) { // 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) + // Rule-builder live count (faceted plan Phase 9): the composed + // tree's true match count (COUNT(*)), rules + library scope. + protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount) protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks) } @@ -45,6 +48,12 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { // parser fixes that too.) params := handlers.ParseMediaSearchParams(c) params.UserID = user.ID + + // Filter rule builder: a malformed qf is a 400, never a silently + // ignored token. + if params.FilterParseErr != "" { + return c.HTML(http.StatusBadRequest, `
Invalid filter
`) + } limit := params.Limit offset := params.Offset libraryID := c.QueryParam("library_id") diff --git a/internal/services/filter_query.go b/internal/services/filter_query.go new file mode 100644 index 00000000..19f6cb72 --- /dev/null +++ b/internal/services/filter_query.go @@ -0,0 +1,1060 @@ +package services + +import ( + "context" + "errors" + "fmt" + "strings" + + "bookhoard/internal/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// ════════════════════════════════════════════════════════════════════ +// Composed filter engines (faceted plan Phase 9): one Go clause builder +// over the raw pool feeds the search, the suggestions, and the live +// count. Fields/operators arrive as registry-typed constants (parsed + +// validated in filter_registry.go); every value is a bound pgx +// parameter — no string-concatenated SQL from user input, ever. +// +// Match semantics mirror the flat facet SQL exactly (word_similarity +// 0.3 fuzzy; ANY() exact; per-element EXISTS; IS NULL / = '' for +// empty), so a flat link and its equivalent rule tree return the same +// rows. +// ════════════════════════════════════════════════════════════════════ + +// ErrInvalidFilterTree marks a qf parse/validation failure — handlers +// map it to 400 (bad request), never 500. +var ErrInvalidFilterTree = errors.New("invalid filter tree") + +// filterBuilder accumulates bound parameters and the user-relative JOINs +// a tree's clauses need. Parameter numbering is textual-order-independent +// (Postgres references params by number), so lazy join binding is safe. +type filterBuilder struct { + args []any + joins []string + joinSeen map[string]bool + userIDBind string +} + +func newFilterBuilder(userID pgtype.UUID) *filterBuilder { + b := &filterBuilder{joinSeen: map[string]bool{}} + b.userIDBind = b.bind(userID) + return b +} + +func (b *filterBuilder) bind(v any) string { + b.args = append(b.args, v) + return fmt.Sprintf("$%d", len(b.args)) +} + +func (b *filterBuilder) needJoin(key, sql string) { + if !b.joinSeen[key] { + b.joinSeen[key] = true + b.joins = append(b.joins, sql) + } +} + +// readingProgressLEFT JOIN — shared by every progress/status field. +func (b *filterBuilder) readingProgress() { + b.needJoin("rp", "LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = "+b.userIDBind) +} + +// myRatingLEFT JOIN — shared by the my_rating field. +func (b *filterBuilder) myRating() { + b.needJoin("mr", "LEFT JOIN media_ratings mr ON mr.media_item_id = mi.id AND mr.user_id = "+b.userIDBind) +} + +// filterFrom is the MANDATORY FROM head — identical to the flat +// SearchMediaItemsUnified: visibility join (family lens), library and +// library-type joins for the row shape. +const filterFrom = `FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = ` + +// headWhere renders the mandatory WHERE head: the family lens + +// integrity facts, plus the optional library scope (validated upstream — +// an explicit out-of-scope library never reaches the SQL). +func (b *filterBuilder) headWhere(libraryID pgtype.UUID) string { + w := "COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL AND mi.missing_scan_count = 0" + if libraryID.Valid { + w += " AND mi.library_id = " + b.bind(libraryID) + } + return w +} + +// ─── clause helpers (flat-SQL mirrors) ──────────────────────────────── + +// fuzzyScalar contains: any value fuzzy-matches the column. +func fuzzyScalarContains(col string, values []string, b *filterBuilder) string { + v := b.bind(values) + "::text[]" + return fmt.Sprintf(`(EXISTS (SELECT 1 FROM unnest(%s) AS f WHERE word_similarity(f, COALESCE(%s, '')) > 0.3))`, v, col) +} + +// fuzzyArray contains: any value fuzzy-matches any array element. +func fuzzyArrayContains(arrCol string, values []string, b *filterBuilder) string { + v := b.bind(values) + "::text[]" + return fmt.Sprintf(`(EXISTS ( + SELECT 1 + FROM unnest(%s) AS f + JOIN unnest(%s) AS a ON true + WHERE word_similarity(f, COALESCE(a, '')) > 0.3 +))`, v, arrCol) +} + +// clauseScalarFuzzyFactory — contains / doesn't contain / is / isn't / +// is empty over one text column (word_similarity 0.3, the system fuzzy). +func clauseScalarFuzzyFactory(col string) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpContains: + return fuzzyScalarContains(col, r.Values, b) + case OpNotContains: + return "NOT " + fuzzyScalarContains(col, r.Values, b) + case OpIs: + if len(r.Values) == 1 { + return "(" + col + " = " + b.bind(r.Values[0]) + ")" + } + return "(" + col + " = ANY(" + b.bind(r.Values) + "))" + case OpIsNot: + // IS DISTINCT FROM keeps rows with a NULL column in "isn't X" + // (a book with no genre ISN'T genre "Fantasy"). + if len(r.Values) == 1 { + return "(" + col + " IS DISTINCT FROM " + b.bind(r.Values[0]) + ")" + } + return "(NOT (" + col + " = ANY(" + b.bind(r.Values) + ")) OR " + col + " IS NULL)" + case OpEmpty: + return "(COALESCE(" + col + ", '') = '')" + } + return "TRUE" + } +} + +// clauseArrayFuzzyFactory — per-element fuzzy over a text[] (flat A2c +// §4.1 semantics), plus exact is/isn't (array overlap) and empty. +func clauseArrayFuzzyFactory(arrCol string) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpContains: + return fuzzyArrayContains(arrCol, r.Values, b) + case OpNotContains: + return "NOT " + fuzzyArrayContains(arrCol, r.Values, b) + case OpIs: + return "(" + b.bind(r.Values) + "::text[] && " + arrCol + ")" + case OpIsNot: + return "(NOT (" + b.bind(r.Values) + "::text[] && " + arrCol + ") OR " + arrCol + " IS NULL)" + case OpEmpty: + return "(" + arrCol + " IS NULL OR cardinality(" + arrCol + ") = 0)" + } + return "TRUE" + } +} + +// clauseAuthorArray — the author field: contains/not_contains fuzzy per +// element (flat mirror); is/is_not exact element equality; empty. +func clauseAuthorArray(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpContains: + return fuzzyArrayContains("mi.authors", r.Values, b) + case OpNotContains: + return "NOT " + fuzzyArrayContains("mi.authors", r.Values, b) + case OpIs: + return "(" + b.bind(r.Values) + "::text[] && mi.authors)" + case OpIsNot: + return "(NOT (" + b.bind(r.Values) + "::text[] && mi.authors) OR mi.authors IS NULL)" + case OpEmpty: + return "(mi.authors IS NULL OR cardinality(mi.authors) = 0)" + } + return "TRUE" +} + +// clauseEnumFactory — exact enum scalars: = ANY(...) like the flat +// exact-enum blocks. +func clauseEnumFactory(col string) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpIs: + return "(" + col + " = ANY(" + b.bind(r.Values) + "::text[]))" + case OpIsNot: + return "(NOT (" + col + " = ANY(" + b.bind(r.Values) + "::text[])) OR " + col + " IS NULL)" + case OpEmpty: + return "(" + col + " IS NULL OR " + col + " = '')" + } + return "TRUE" + } +} + +// clauseBoolFactory — presence-bound bools (NULL = unset matches +// neither is-true nor is-false, like the flat explicit/abridged blocks). +func clauseBoolFactory(col string) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpIsTrue: + return "(" + col + " = TRUE)" + case OpIsFalse: + return "(" + col + " = FALSE)" + } + return "TRUE" + } +} + +// clausePresenceFactory — has / missing over a text column +// (IS NULL / = '' per the plan's empty semantics). +func clausePresenceFactory(col string) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpHas: + return "(" + col + " IS NOT NULL AND " + col + " <> '')" + case OpMissing: + return "(" + col + " IS NULL OR " + col + " = '')" + } + return "TRUE" + } +} + +// clauseIdentifierFactory — exact is / isn't (IS DISTINCT FROM, so +// "isn't this ISBN" includes books without one) + has/missing. +func clauseIdentifierFactory(col string) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpIs: + return "(" + col + " = " + b.bind(r.Values[0]) + ")" + case OpIsNot: + return "(" + col + " IS DISTINCT FROM " + b.bind(r.Values[0]) + ")" + case OpHas: + return "(" + col + " IS NOT NULL AND " + col + " <> '')" + case OpMissing: + return "(" + col + " IS NULL OR " + col + " = '')" + } + return "TRUE" + } +} + +// clauseNumberFactory — between / at least / at most / is empty over a +// numeric column; values bind as numbers (validated + parsed earlier). +func clauseNumberFactory(col string, parse func(string) (any, error)) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpAtLeast: + n, _ := parse(r.Values[0]) + return "(" + col + " >= " + b.bind(n) + ")" + case OpAtMost: + n, _ := parse(r.Values[0]) + return "(" + col + " <= " + b.bind(n) + ")" + case OpBetween: + a, _ := parse(r.Values[0]) + c, _ := parse(r.Values[1]) + return "(" + col + " BETWEEN " + b.bind(a) + " AND " + b.bind(c) + ")" + case OpEmpty: + return "(" + col + " IS NULL)" + } + return "TRUE" + } +} + +// clauseDateFactory — within the last N days / between (end bound +// anchored to the START of the day AFTER the end date — the end-of-day +// arithmetic rule) / is empty. +func clauseDateFactory(col string) func(FilterRule, *filterBuilder) string { + return func(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpWithinDays: + n, _ := parseNumberInt(r.Values[0]) + days := int32(n.(int64)) + return "(" + col + " >= now() - make_interval(days => " + b.bind(days) + "::int))" + case OpBetween: + return "(" + col + " >= " + b.bind(r.Values[0]) + "::date AND " + col + " < (" + b.bind(r.Values[1]) + "::date + interval '1 day'))" + case OpEmpty: + return "(" + col + " IS NULL)" + } + return "TRUE" + } +} + +// ─── special clauses ───────────────────────────────────────────────── + +// clauseSeriesMembership — the book's FULL series membership: the +// primary series column AND the additional series held in +// alternate_info. "belongs to series X" must not miss a book whose +// membership of X is secondary. +func clauseSeriesMembership(r FilterRule, b *filterBuilder) string { + const alt = "mi.alternate_info->>'alternate_series'" + switch r.Op { + case OpContains: + v := b.bind(r.Values) + "::text[]" + return fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM unnest(%s) AS f + WHERE word_similarity(f, COALESCE(mi.series, '')) > 0.3 + OR word_similarity(f, COALESCE(%s, '')) > 0.3 +))`, v, alt) + case OpNotContains: + v := b.bind(r.Values) + "::text[]" + return fmt.Sprintf(`(NOT EXISTS ( + SELECT 1 FROM unnest(%s) AS f + WHERE word_similarity(f, COALESCE(mi.series, '')) > 0.3 + OR word_similarity(f, COALESCE(%s, '')) > 0.3 +))`, v, alt) + case OpIs: + v := b.bind(r.Values) + "::text[]" + return fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM unnest(%s) AS f + WHERE f = mi.series OR f = %s +))`, v, alt) + case OpIsNot: + v := b.bind(r.Values) + "::text[]" + return fmt.Sprintf(`(NOT EXISTS ( + SELECT 1 FROM unnest(%s) AS f + WHERE f = mi.series OR f = %s +))`, v, alt) + case OpEmpty: + return "((mi.series IS NULL OR mi.series = '') AND COALESCE(" + alt + ", '') = '')" + } + return "TRUE" +} + +// clausePublishedYear — the flat year-range semantics: date_published's +// year, falling back to copyright_year. +func clausePublishedYear(r FilterRule, b *filterBuilder) string { + expr := "COALESCE(EXTRACT(YEAR FROM mi.date_published)::int, mi.copyright_year)" + switch r.Op { + case OpAtLeast: + n, _ := parseNumberInt(r.Values[0]) + return "(" + expr + " >= " + b.bind(n) + ")" + case OpAtMost: + n, _ := parseNumberInt(r.Values[0]) + return "(" + expr + " <= " + b.bind(n) + ")" + case OpBetween: + a, _ := parseNumberInt(r.Values[0]) + c, _ := parseNumberInt(r.Values[1]) + return "(" + expr + " BETWEEN " + b.bind(a) + " AND " + b.bind(c) + ")" + case OpEmpty: + return "(mi.date_published IS NULL AND mi.copyright_year IS NULL)" + } + return "TRUE" +} + +// clauseCover — mirrors the flat has_cover block exactly. +func clauseCover(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpHas: + return "(mi.cover_image_path IS NOT NULL)" + case OpMissing: + return "(mi.cover_image_path IS NULL)" + } + return "TRUE" +} + +// clauseFileAvailability — the archived_at/missing_scan_count facts. +// NOTE: the mandatory head already hides missing files from every +// search surface, so "is missing" matches nothing today — the clause is +// still written honestly so the semantics hold if the head ever relaxes. +func clauseFileAvailability(r FilterRule, b *filterBuilder) string { + present := "(mi.archived_at IS NULL AND mi.missing_scan_count = 0)" + switch r.Op { + case OpIs: + if r.Values[0] == "available" { + return present + } + return "(NOT " + present + ")" + case OpIsNot: + if r.Values[0] == "available" { + return "(NOT " + present + ")" + } + return present + } + return "TRUE" +} + +// clauseAddedBy — the admin who added the book. +func clauseAddedBy(r FilterRule, b *filterBuilder) string { + switch r.Op { + case OpIs: + id, err := uuid.Parse(r.Values[0]) + if err != nil { + return "FALSE" + } + return "(mi.added_by_admin_id = " + b.bind(id) + ")" + case OpHas: + return "(mi.added_by_admin_id IS NOT NULL)" + case OpMissing: + return "(mi.added_by_admin_id IS NULL)" + } + return "TRUE" +} + +// clauseStatus — unread / in progress / finished from the viewer's +// reading_progress.percentage (finished threshold mirrors the +// dashboard's 0.999 hide-finished cut). +func clauseStatus(r FilterRule, b *filterBuilder) string { + b.readingProgress() + parts := make([]string, 0, len(r.Values)) + for _, v := range r.Values { + switch v { + case "unread": + parts = append(parts, "(rp.id IS NULL OR COALESCE(rp.percentage, 0) = 0)") + case "reading": + parts = append(parts, "(rp.percentage > 0 AND rp.percentage < 0.999)") + case "finished": + parts = append(parts, "(COALESCE(rp.percentage, 0) >= 0.999)") + } + } + if len(parts) == 0 { + return "TRUE" + } + return "(" + strings.Join(parts, " OR ") + ")" +} + +// clauseProgressPercent — the viewer's progress as a 0-100 number. +func clauseProgressPercent(r FilterRule, b *filterBuilder) string { + b.readingProgress() + pct := "COALESCE(rp.percentage, 0) * 100" + switch r.Op { + case OpAtLeast: + n, _ := parseNumberFloat(r.Values[0]) + return "(" + pct + " >= " + b.bind(n) + ")" + case OpAtMost: + n, _ := parseNumberFloat(r.Values[0]) + return "(" + pct + " <= " + b.bind(n) + ")" + case OpBetween: + a, _ := parseNumberFloat(r.Values[0]) + c, _ := parseNumberFloat(r.Values[1]) + return "(" + pct + " BETWEEN " + b.bind(a) + " AND " + b.bind(c) + ")" + case OpEmpty: + return "(rp.id IS NULL)" + } + return "TRUE" +} + +// clauseLastRead — recency of the viewer's last read. +func clauseLastRead(r FilterRule, b *filterBuilder) string { + b.readingProgress() + switch r.Op { + case OpWithinDays: + n, _ := parseNumberInt(r.Values[0]) + days := int32(n.(int64)) + return "(rp.last_read_at >= now() - make_interval(days => " + b.bind(days) + "::int))" + case OpBetween: + return "(rp.last_read_at >= " + b.bind(r.Values[0]) + "::date AND rp.last_read_at < (" + b.bind(r.Values[1]) + "::date + interval '1 day'))" + case OpEmpty: + return "(rp.id IS NULL)" + } + return "TRUE" +} + +// clauseMyRating — the viewer's 1-10 rating. +func clauseMyRating(r FilterRule, b *filterBuilder) string { + b.myRating() + switch r.Op { + case OpIs: + ints := make([]int64, 0, len(r.Values)) + for _, v := range r.Values { + n, _ := parseNumberInt(v) + ints = append(ints, n.(int64)) + } + return "(mr.rating = ANY(" + b.bind(ints) + "::int[]))" + case OpAtLeast: + n, _ := parseNumberInt(r.Values[0]) + return "(mr.rating >= " + b.bind(n) + ")" + case OpAtMost: + n, _ := parseNumberInt(r.Values[0]) + return "(mr.rating <= " + b.bind(n) + ")" + case OpUnrated: + return "(mr.id IS NULL)" + } + return "TRUE" +} + +// viewerRowsExists — an EXISTS clause over one of the viewer's +// joined-table surfaces (reviews / notes / highlights). +func viewerRowsExists(table, textCol string, b *filterBuilder) string { + return fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM %s t + WHERE t.media_item_id = mi.id AND t.user_id = %s +))`, table, b.userIDBind) +} + +func clauseReviewPresence(r FilterRule, b *filterBuilder) string { + ex := viewerRowsExists("media_reviews", "review", b) + if r.Op == OpHas { + return ex + } + return "(NOT " + ex + ")" +} + +// clauseNotesPresence / clauseHighlightsPresence — presence + "last one +// within N days". +func clauseNotesPresence(r FilterRule, b *filterBuilder) string { + return annotationPresence("media_notes", r, b) +} + +func clauseHighlightsPresence(r FilterRule, b *filterBuilder) string { + return annotationPresence("media_highlights", r, b) +} + +func annotationPresence(table string, r FilterRule, b *filterBuilder) string { + userBind := b.userIDBind + if r.Op == OpWithinDays { + n, _ := parseNumberInt(r.Values[0]) + days := int32(n.(int64)) + return fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM %s t + WHERE t.media_item_id = mi.id AND t.user_id = %s + AND t.created_at >= now() - make_interval(days => %s::int) +))`, table, userBind, b.bind(days)) + } + ex := fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM %s t + WHERE t.media_item_id = mi.id AND t.user_id = %s +))`, table, userBind) + if r.Op == OpHas { + return ex + } + return "(NOT " + ex + ")" +} + +// clauseNoteText / clauseHighlightText — the viewer's own words, +// substring ILIKE (NOT the system fuzzy: your words deserve exact-ish), +// a book matching when ANY of its rows for this viewer match. +// Wildcard characters in the note text (% _) act as wildcards — your +// own notes, your own metacharacters. +func clauseNoteText(r FilterRule, b *filterBuilder) string { + return annotationTextClause("media_notes", "content", r, b) +} + +func clauseHighlightText(r FilterRule, b *filterBuilder) string { + return annotationTextClause("media_highlights", "selection_text", r, b) +} + +func annotationTextClause(table, col string, r FilterRule, b *filterBuilder) string { + userBind := b.userIDBind + if r.Op == OpEmpty { + return fmt.Sprintf(`(NOT EXISTS ( + SELECT 1 FROM %s t + WHERE t.media_item_id = mi.id AND t.user_id = %s +))`, table, userBind) + } + match := fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM unnest(%s) AS f + JOIN %s t ON t.media_item_id = mi.id AND t.user_id = %s + WHERE t.%s ILIKE '%%' || f || '%%' +))`, b.bind(r.Values)+"::text[]", table, userBind, col) + if r.Op == OpNotContains { + return "(NOT " + match + ")" + } + return match +} + +// clauseTimeSpent / clausePagesRead — the viewer's reading_history SUM +// aggregates ("books I've spent 5+ hours on"). Time binds in seconds. +func clauseTimeSpent(r FilterRule, b *filterBuilder) string { + sum := fmt.Sprintf(`(SELECT COALESCE(SUM(rh.time_spent_seconds), 0) + FROM reading_history rh + WHERE rh.media_item_id = mi.id AND rh.user_id = %s)`, b.userIDBind) + return sumNumberClause(sum, r, b, 60) +} + +func clausePagesRead(r FilterRule, b *filterBuilder) string { + sum := fmt.Sprintf(`(SELECT COALESCE(SUM(rh.pages_read), 0) + FROM reading_history rh + WHERE rh.media_item_id = mi.id AND rh.user_id = %s)`, b.userIDBind) + return sumNumberClause(sum, r, b, 1) +} + +// sumNumberClause compares an aggregate subquery against bound bounds. +func sumNumberClause(sumExpr string, r FilterRule, b *filterBuilder, scale float64) string { + bound := func(v string) any { + f, err := parseNumberFloat(v) + if err != nil { + return int64(0) + } + return int64(f.(float64) * scale) + } + switch r.Op { + case OpAtLeast: + return "(" + sumExpr + " >= " + b.bind(bound(r.Values[0])) + ")" + case OpAtMost: + return "(" + sumExpr + " <= " + b.bind(bound(r.Values[0])) + ")" + case OpBetween: + return "(" + sumExpr + " BETWEEN " + b.bind(bound(r.Values[0])) + " AND " + b.bind(bound(r.Values[1])) + ")" + } + return "TRUE" +} + +// clauseCollection — in / isn't in / in any / in none, mirroring the +// collection page's own membership definition (excluded items and +// deleted collections never count; playlists are collections). +func clauseCollection(r FilterRule, b *filterBuilder) string { + userBind := b.userIDBind + if r.Op == OpInAny || r.Op == OpInNone { + ex := fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM collection_items ci + JOIN collections c ON c.id = ci.collection_id + WHERE ci.media_item_id = mi.id AND c.user_id = %s + AND c.deleted_at IS NULL AND ci.excluded IS NOT TRUE +))`, userBind) + if r.Op == OpInAny { + return ex + } + return "(NOT " + ex + ")" + } + ids := make([]uuid.UUID, 0, len(r.Values)) + for _, v := range r.Values { + if id, err := uuid.Parse(v); err == nil { + ids = append(ids, id) + } + } + ex := fmt.Sprintf(`(EXISTS ( + SELECT 1 FROM collection_items ci + JOIN collections c ON c.id = ci.collection_id + WHERE ci.media_item_id = mi.id AND c.user_id = %s + AND c.deleted_at IS NULL AND ci.excluded IS NOT TRUE + AND c.id = ANY(%s) +))`, userBind, b.bind(ids)+"::uuid[]") + if r.Op == OpIn { + return ex + } + return "(NOT " + ex + ")" +} + +// clauseLibrary — the library holding-structure field. Values are +// library IDs; visibility is enforced by the mandatory head regardless +// (a rule naming a hidden library simply matches nothing). +func clauseLibrary(r FilterRule, b *filterBuilder) string { + ids := make([]uuid.UUID, 0, len(r.Values)) + for _, v := range r.Values { + if id, err := uuid.Parse(v); err == nil { + ids = append(ids, id) + } + } + if r.Op == OpIs { + return "(l.id = ANY(" + b.bind(ids) + "::uuid[]))" + } + return "(NOT (l.id = ANY(" + b.bind(ids) + "::uuid[])))" +} + +// ─── tree → SQL ────────────────────────────────────────────────────── + +// buildGroupClause renders a group: children joined by the group mode, +// each child a rule clause or a nested group. A group ANDs into its +// parent. +func buildGroupClause(g *FilterChild, b *filterBuilder) string { + parts := make([]string, 0, len(g.Children)) + for _, c := range g.Children { + if c.Field == "" { + parts = append(parts, buildGroupClause(&c, b)) + continue + } + parts = append(parts, buildRuleClause(FilterRule{Field: c.Field, Op: c.Op, Values: c.Values}, b)) + } + if len(parts) == 0 { + return "TRUE" + } + if len(parts) == 1 { + return parts[0] + } + joiner := " AND " + if g.Mode == "any" { + joiner = " OR " + } + return "(" + strings.Join(parts, joiner) + ")" +} + +func buildRuleClause(r FilterRule, b *filterBuilder) string { + f, ok := FilterFieldByKey(r.Field) + if !ok { + return "TRUE" // unreachable: validation ran at parse + } + return f.Clause(r, b) +} + +// treeWhere builds the full rule-tree WHERE fragment ("" when the tree +// is nil). +func treeWhere(tree *FilterTree, b *filterBuilder) string { + if tree == nil || len(tree.Children) == 0 { + return "" + } + g := &FilterChild{Mode: tree.Mode, Children: tree.Children} + return " AND " + buildGroupClause(g, b) +} + +// ─── the composed search ───────────────────────────────────────────── + +// composedSelect — the exact SELECT list of SearchMediaItemsUnified, so +// the composed engine returns the identical row shape +// (database.SearchMediaItemsUnifiedRow) and the grid/JSON contracts are +// untouched. Keep in lockstep with queries.sql. +const composedSelect = `SELECT + mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, + mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, + mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, + mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, + mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, + mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, + mi.format_group, mi.format_mimetype, mi.is_reflowable, + mi.has_fixed_layout, mi.total_characters, mi.chapter_count, + mi.entitlement_id, mi.revision_number, mi.kobo_content_id, + mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.narrators, + mi.narrators_search, mi.abridged, mi.subtitle, mi.explicit, mi.authors, + mi.authors_search, mi.publishers, mi.publishers_search, + mi.audio_metadata, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, + mi.hash_confidence, l.name AS library_name, lt.name AS library_type_name +` + +// composedSortOrder — the flat query's user-sort CASE cascade (the +// relevance arm simplifies to the search query only; the flat query's +// filter-similarity terms only matter when q AND filters are both set). +func composedSortOrder(sortBind, qBind, exactBind string) string { + return ` +ORDER BY + CASE WHEN ` + qBind + ` != '' THEN GREATEST( + CASE WHEN ` + exactBind + ` = false THEN word_similarity(` + qBind + `, mi.title) ELSE 0 END, + CASE WHEN ` + exactBind + ` = false THEN word_similarity(` + qBind + `, COALESCE(mi.author, '')) ELSE 0 END, + word_similarity(` + qBind + `, COALESCE(mi.series, '')) + ) ELSE 0 END DESC, + CASE WHEN ` + sortBind + ` = 'title ASC' THEN mi.title ELSE '' END ASC, + CASE WHEN ` + sortBind + ` = 'title DESC' THEN mi.title ELSE '' END DESC, + CASE WHEN ` + sortBind + ` = 'author ASC' THEN COALESCE(mi.author, '') ELSE '' END ASC, + CASE WHEN ` + sortBind + ` = 'author DESC' THEN COALESCE(mi.author, '') ELSE '' END DESC, + CASE WHEN ` + sortBind + ` = 'created_at ASC' THEN mi.created_at ELSE '1970-01-01'::timestamp END ASC, + CASE WHEN ` + sortBind + ` = 'created_at DESC' THEN mi.created_at ELSE '1970-01-01'::timestamp END DESC, + CASE WHEN ` + sortBind + ` = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END ASC, + CASE WHEN ` + sortBind + ` = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END DESC, + mi.title ASC +LIMIT ` +} + +// ComposedSearch runs the rule tree — same row shape as the static +// unified search (identical response contracts). +func (s *SearchService) ComposedSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) { + b := newFilterBuilder(params.UserID) + isExact, searchQuery := s.parseSearchQuery(params.SearchQuery) + + qBind := b.bind(searchQuery) + exactBind := b.bind(isExact) + sortBind := b.bind(params.Sort) + libBind := b.bind(params.LibraryID) // invalid pgtype.UUID binds as NULL + limitBind := b.bind(params.Limit) + offsetBind := b.bind(params.Offset) + + // Build the WHERE first — clause construction requests the + // user-relative JOINs lazily, and joinBlock() must render AFTER + // they are collected. + whereSQL := b.headWhere(params.LibraryID) + treeWhere(params.FilterTree, b) + ` + AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid) + AND (` + qBind + ` = '' OR ` + composedQueryBlock(qBind, exactBind) + `)` + sqlStr := composedSelect + filterFrom + b.userIDBind + + b.joinBlock() + ` +WHERE ` + whereSQL + + composedSortOrder(sortBind, qBind, exactBind) + ` ` + limitBind + ` OFFSET ` + offsetBind + + rows, err := s.raw.Query(ctx, sqlStr, b.args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []database.SearchMediaItemsUnifiedRow + for rows.Next() { + var row database.SearchMediaItemsUnifiedRow + if err := rows.Scan(composedScanDest(&row)...); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} + +// composedScanDest returns the positional scan targets for +// composedSelect — column-for-column in SELECT order (must stay in +// lockstep with composedSelect / the flat query's SELECT list). +func composedScanDest(r *database.SearchMediaItemsUnifiedRow) []any { + return []any{ + &r.ID, &r.LibraryID, &r.Title, &r.Author, &r.Isbn, &r.Description, + &r.FilePath, &r.FileSize, &r.MimeType, &r.CoverImagePath, + &r.Series, &r.SeriesNumber, &r.Tags, &r.Asin, &r.DatePublished, + &r.Publisher, &r.Contributors, &r.Language, &r.Edition, &r.PageCount, + &r.Genre, &r.CopyrightYear, &r.GoodreadsID, &r.OpenlibraryID, + &r.GoogleBooksID, &r.AddedByAdminID, &r.CreatedAt, &r.UpdatedAt, + &r.FormatGroup, &r.FormatMimetype, &r.IsReflowable, + &r.HasFixedLayout, &r.TotalCharacters, &r.ChapterCount, + &r.EntitlementID, &r.RevisionNumber, &r.KoboContentID, + &r.KoboMetadata, &r.TagsSearch, &r.ContributorsSearch, &r.Narrators, + &r.NarratorsSearch, &r.Abridged, &r.Subtitle, &r.Explicit, &r.Authors, + &r.AuthorsSearch, &r.Publishers, &r.PublishersSearch, + &r.AudioMetadata, &r.FileSha256, &r.OpfIdentifier, &r.OpfUuid, + &r.HashConfidence, &r.LibraryName, &r.LibraryTypeName, + } +} + +// joinBlock renders the collected user-relative JOINs (lazy-requested +// by clauses) — placed after the FROM head, before WHERE. +func (b *filterBuilder) joinBlock() string { + if len(b.joins) == 0 { + return "" + } + return "\n" + strings.Join(b.joins, "\n") +} + +// composedQueryBlock mirrors the flat query's search-query block +// (fuzzy by default, exact with quotes). +func composedQueryBlock(qBind, exactBind string) string { + return `( + ` + exactBind + ` = false AND ( + word_similarity(` + qBind + `, mi.title) > 0.3 OR + word_similarity(` + qBind + `, COALESCE(mi.author, '')) > 0.3 OR + word_similarity(` + qBind + `, COALESCE(mi.series, '')) > 0.3 OR + EXISTS (SELECT 1 FROM unnest(mi.tags_search) AS tag WHERE word_similarity(` + qBind + `, tag) > 0.3) OR + EXISTS (SELECT 1 FROM unnest(mi.contributors_search) AS c WHERE word_similarity(` + qBind + `, c) > 0.3) OR + EXISTS (SELECT 1 FROM unnest(mi.narrators_search) AS n WHERE word_similarity(` + qBind + `, n) > 0.3) + ) OR + ` + exactBind + ` = true AND ( + mi.title = ` + qBind + ` OR + COALESCE(mi.author, '') = ` + qBind + ` OR + COALESCE(mi.series, '') = ` + qBind + ` OR + ` + qBind + ` = ANY(mi.tags_search) OR + ` + qBind + ` = ANY(mi.contributors_search) OR + ` + qBind + ` = ANY(mi.narrators_search) + ))` +} + +// ComposedCount — the live match count for the builder (rules + scope, +// no pagination): a true COUNT(*), not the page length. +func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) { + b := newFilterBuilder(userID) + libBind := b.bind(libraryID) + whereSQL := b.headWhere(libraryID) + treeWhere(tree, b) + ` + AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)` + sqlStr := `SELECT COUNT(*) ` + filterFrom + b.userIDBind + + b.joinBlock() + ` +WHERE ` + whereSQL + + var n int + if err := s.raw.QueryRow(ctx, sqlStr, b.args...).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// ─── the composed suggestion engine ────────────────────────────────── + +// ComposedSuggestParams — one field's value menu computed under the +// OTHER completed rules (symmetric, order-free; zero-match values are +// never offered). +type ComposedSuggestParams struct { + UserID pgtype.UUID + LibraryID pgtype.UUID // optional scope + FieldKey string + Typed string // the text typed so far + Tree *FilterTree // the other rules (nil = none) + Limit int +} + +// ComposedFieldValues — one registry field's top values + counts under +// the given rules. FieldValue (value/count/score/display) is the same +// shape the static autocomplete returns. +func (s *SearchService) ComposedFieldValues(ctx context.Context, p ComposedSuggestParams) ([]FieldValue, error) { + field, ok := FilterFieldByKey(p.FieldKey) + if !ok || field.Suggest == suggestNone { + return []FieldValue{}, nil + } + if p.Limit <= 0 { + p.Limit = 50 + } + + b := newFilterBuilder(p.UserID) + typedBind := b.bind(p.Typed) + limitBind := b.bind(p.Limit) + libBind := b.bind(p.LibraryID) + + // The typed filter differs per source family: substring for the + // viewer's own words (note/highlight), name-filter for the id + // sources (library/collection/added_by — you find them by name), + // word_similarity for metadata text. userBind is bound ONLY for the + // viewer-scoped sources — an unused parameter is a 42P18 error. + var userBind string + var valueSource, typedFilter string + switch field.Suggest { + case suggestNoteText, suggestHighlight: + userBind = b.bind(p.UserID) + table, col := "media_notes", "content" + if field.Suggest == suggestHighlight { + table, col = "media_highlights", "selection_text" + } + valueSource = fmt.Sprintf(`CROSS JOIN LATERAL ( + SELECT t.%s AS value + FROM %s t + WHERE t.media_item_id = mi.id AND t.user_id = %s +) AS src`, col, table, userBind) + if p.Typed != "" { + typedFilter = " AND src.value ILIKE '%' || " + typedBind + " || '%'" + } + case suggestSeries: + valueSource = `CROSS JOIN LATERAL ( + SELECT s AS value + FROM unnest(ARRAY[mi.series, mi.alternate_info->>'alternate_series']) AS s + WHERE s IS NOT NULL AND s <> '' +) AS src` + typedFilter = " AND word_similarity(" + typedBind + ", src.value) > 0.3" + case suggestLibrary: + return s.composedIDSourceValues(ctx, b, p, "libraries l2", "l2.id::text", "l2.name", + "JOIN media_items mi ON mi.library_id = l2.id", typedBind, limitBind, libBind) + case suggestCollection: + return s.composedIDSourceValues(ctx, b, p, + "collections c JOIN collection_items ci ON ci.collection_id = c.id", + "c.id::text", "c.name", + "JOIN media_items mi ON ci.media_item_id = mi.id AND ci.excluded IS NOT TRUE", + typedBind, limitBind, libBind) + case suggestAddedBy: + return s.composedIDSourceValues(ctx, b, p, + "users u JOIN media_items mi ON mi.added_by_admin_id = u.id", + "u.id::text", "u.username", "", typedBind, limitBind, libBind) + default: + // Column / array sources: the value expr mirrors the field's + // match column (tags via the *_search column, like the flat + // autocomplete; arrays unnested per element). + switch field.Key { + case "author": + valueSource = "CROSS JOIN LATERAL unnest(mi.authors) AS src(value)" + case "narrator": + valueSource = "CROSS JOIN LATERAL unnest(mi.narrators) AS src(value)" + case "contributor": + valueSource = "CROSS JOIN LATERAL unnest(mi.contributors) AS src(value)" + case "publisher": + valueSource = "CROSS JOIN LATERAL unnest(mi.publishers) AS src(value)" + case "tags": + valueSource = "CROSS JOIN LATERAL unnest(mi.tags_search) AS src(value)" + default: + col := suggestColumnForField(field.Key) + if col == "" { + return []FieldValue{}, nil + } + valueSource = "CROSS JOIN LATERAL (SELECT " + col + " AS value WHERE " + col + " IS NOT NULL AND " + col + " <> '') AS src" + } + typedFilter = " AND word_similarity(" + typedBind + ", src.value) > 0.3" + } + if p.Typed == "" { + typedFilter = "" // no typing = the field's world, under the other rules + } + + whereSQL := b.headWhere(p.LibraryID) + ` + AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)` + + treeWhere(p.Tree, b) + ` + AND src.value IS NOT NULL AND src.value <> ''` + typedFilter + sqlStr := `SELECT src.value, COUNT(*) AS count, + CASE WHEN ` + typedBind + ` = '' THEN 0 ELSE word_similarity(` + typedBind + `, src.value) END AS score + ` + filterFrom + b.userIDBind + ` +` + valueSource + b.joinBlock() + ` +WHERE ` + whereSQL + ` +GROUP BY src.value +ORDER BY score DESC, count DESC, src.value +LIMIT ` + limitBind + + rows, err := s.raw.Query(ctx, sqlStr, b.args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]FieldValue, 0, p.Limit) + for rows.Next() { + var v FieldValue + if err := rows.Scan(&v.Value, &v.Count, &v.Score); err != nil { + return nil, err + } + // Language mirrors the static autocomplete: the display NAME + // rides as the value (the search path normalizes names back to + // stored ISO codes — both here and in the composed clause). + if field.Key == "language" { + if name := LanguageDisplayName(v.Value); name != "" { + v.Value = name + } + } + out = append(out, v) + } + return out, rows.Err() +} + +// composedIDSourceValues — the id-valued sources (library / collection / +// added_by): value = id, display = name, typing filters the NAME, and +// counts are books-in-scope. Zero-count sources drop out (INNER JOIN). +func (s *SearchService) composedIDSourceValues(ctx context.Context, b *filterBuilder, p ComposedSuggestParams, + fromTables, valueExpr, displayExpr, joinMedia, typedBind, limitBind, libBind string) ([]FieldValue, error) { + nameFilter := "" + if p.Typed != "" { + nameFilter = " AND " + displayExpr + " ILIKE '%' || " + typedBind + " || '%'" + } + sqlStr := `SELECT ` + valueExpr + ` AS value, ` + displayExpr + ` AS display, COUNT(mi.id) AS count +FROM ` + fromTables + ` +` + joinMedia + ` +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = ` + b.userIDBind + ` +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 + AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)` + treeWhere(p.Tree, b) + nameFilter + ` +GROUP BY ` + valueExpr + `, ` + displayExpr + ` +ORDER BY count DESC, ` + displayExpr + ` +LIMIT ` + limitBind + + rows, err := s.raw.Query(ctx, sqlStr, b.args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]FieldValue, 0, p.Limit) + for rows.Next() { + var v FieldValue + if err := rows.Scan(&v.Value, &v.Display, &v.Count); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// suggestColumnForField maps a field key to its scalar suggestion +// column (fields whose menu is the column's own values). +func suggestColumnForField(key string) string { + switch key { + case "imprint": + return "mi.imprint" + case "edition": + return "mi.edition" + case "genre": + return "mi.genre" + case "story_arc": + return "mi.story_arc" + case "age_rating": + return "mi.age_rating" + case "title": + return "mi.title" + case "subtitle": + return "mi.subtitle" + case "language": + return "mi.language" + case "format_group": + return "mi.format_group" + case "mime_type": + return "mi.mime_type" + case "manga_type": + return "mi.manga_type" + case "reading_direction": + return "mi.reading_direction" + } + return "" +} + +// prettifyGenreValue kept out — genre suggestions ride raw values, the +// same as the static autocomplete. + +// clauseLanguage — the language field normalizes its rule values +// through the ingest-side map (display name → ISO code) at clause time, +// exactly like the flat language_filter block. A rule built from the +// menu ("English") matches stored codes ("en"). +func clauseLanguage(r FilterRule, b *filterBuilder) string { + r.Values = strings.Split(normalizeLanguageFilter(strings.Join(r.Values, ",")), ",") + return clauseScalarFuzzyFactory("mi.language")(r, b) +} diff --git a/internal/services/filter_registry.go b/internal/services/filter_registry.go new file mode 100644 index 00000000..5fc46de2 --- /dev/null +++ b/internal/services/filter_registry.go @@ -0,0 +1,548 @@ +package services + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/google/uuid" +) + +// ════════════════════════════════════════════════════════════════════ +// Filter rule builder — the field registry (faceted plan Phase 9). +// +// The mandate: EVERYTHING is filterable — every attribute on a book and +// everything that pertains to one. This registry is the completeness +// check: fields and operators are typed constants (the allow-list — no +// user string ever reaches a field/operator position), every value is a +// bound pgx parameter, and each entry carries its own match-clause +// closure so the composed engines (search + suggestions + count) grow +// by adding entries, not SQL. +// +// User-relative fields read the VIEWER's rows: the user id is +// server-injected (never a client param) — two users get different +// results for the same tree (the "my data" weld). +// +// Holding structures (library, collections, series — primary AND +// alternate) are fields like any other; any future structure that +// "holds" a book must join this registry. +// ════════════════════════════════════════════════════════════════════ + +// Filter operators (the allow-list). +const ( + OpContains = "contains" + OpNotContains = "not_contains" + OpIs = "is" + OpIsNot = "is_not" + OpEmpty = "empty" + OpIsTrue = "is_true" + OpIsFalse = "is_false" + OpBetween = "between" + OpAtLeast = "at_least" + OpAtMost = "at_most" + OpWithinDays = "within_days" + OpHas = "has" + OpMissing = "missing" + OpUnrated = "unrated" + OpIn = "in" + OpNotIn = "not_in" + OpInAny = "in_any" + OpInNone = "in_none" +) + +// FilterKind — how a field's values match and what the value cell renders. +type FilterKind string + +const ( + KindScalarFuzzy FilterKind = "scalar_fuzzy" // word_similarity on a text column + KindArrayFuzzy FilterKind = "array_fuzzy" // word_similarity per element of a text[] + KindText FilterKind = "text" // fuzzy contains + exact is + empty (title-like) + KindEnum FilterKind = "enum" // exact scalar, = ANY(values) + KindBool FilterKind = "bool" + KindNumber FilterKind = "number" + KindDate FilterKind = "date" + KindPresence FilterKind = "presence" // has / missing + KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing + KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows) + KindStatus FilterKind = "status" // unread / reading / finished + KindLibrary FilterKind = "library" + KindCollection FilterKind = "collection" +) + +// Suggestion sources — where a field's value menu comes from. A source +// of suggestNone means the value cell is free input (numbers, dates, +// bools, fixed enums). +type SuggestSource string + +const ( + suggestNone SuggestSource = "" + suggestAuthor SuggestSource = "author" + suggestNarrator SuggestSource = "narrator" + suggestContrib SuggestSource = "contributor" + suggestPublisher SuggestSource = "publisher" + suggestTags SuggestSource = "tags" + suggestColumn SuggestSource = "column" // the field's own scalar column + suggestSeries SuggestSource = "series" // primary + alternate_info + suggestNoteText SuggestSource = "note_text" // the viewer's note snippets + suggestHighlight SuggestSource = "highlight_text" + suggestLibrary SuggestSource = "library" // visibility-scoped libraries + suggestCollection SuggestSource = "collection" // the viewer's collections + suggestAddedBy SuggestSource = "added_by" // admins who added books in scope +) + +// Filter menu groups (display order for the field menu). +const ( + GroupPeople = "People" + GroupPublish = "Publishing" + GroupTopics = "Topics" + GroupBookFacts = "Book facts" + GroupComics = "Comics & manga" + GroupMyReading = "My reading" + GroupLibrary = "Library" +) + +// FilterRule is one leaf: field + operator + values (all bound params). +type FilterRule struct { + Field string + Op string + Values []string +} + +// FilterTree is the qf wire shape: a group (mode + children) whose +// children are rules or nested groups. Groups AND/OR their children +// together; a group ANDs into its parent. +type FilterTree struct { + Mode string `json:"mode"` + Children []FilterChild `json:"children"` +} + +// FilterChild is one node: a rule (Field set) or a group (Mode set). +type FilterChild struct { + Field string `json:"field,omitempty"` + Op string `json:"op,omitempty"` + Values []string `json:"values,omitempty"` + Mode string `json:"mode,omitempty"` + Children []FilterChild `json:"children,omitempty"` +} + +// FilterField is one registry entry. +type FilterField struct { + Key string // wire name (the qf "field" value) + Label string // menu label + Group string // menu group + Kind FilterKind + Ops []string // allowed operators, display order + DefaultOp string // operator a new rule starts on (field-switch reset) + Clause func(r FilterRule, b *filterBuilder) string + Suggest SuggestSource +} + +// opsWithValue / opsWithoutValue drive validation. +var opsNoValue = map[string]bool{ + OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true, + OpInAny: true, OpInNone: true, OpHas: true, OpMissing: true, +} + +// fixedDomains are the operator value domains that are STATIC (validated +// here) rather than data-driven suggestions. +var fixedDomains = map[string][]string{ + "status": {"unread", "reading", "finished"}, + "file_availability": {"available", "missing"}, +} + +// filterFields is THE registry. Order within a group = menu order. +var filterFields = []FilterField{ + // ── People ──────────────────────────────────────────────────── + {Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseAuthorArray, Suggest: suggestAuthor}, + {Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator}, + {Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib}, + + // ── Publishing ──────────────────────────────────────────────── + {Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher}, + {Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn}, + {Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn}, + {Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clausePublishedYear, Suggest: suggestNone}, + + // ── Topics ──────────────────────────────────────────────────── + {Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn}, + {Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags}, + {Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn}, + {Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum, + Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs, + Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn}, + + // ── Book facts ──────────────────────────────────────────────── + {Key: "title", Label: "Title", Group: GroupBookFacts, Kind: KindText, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn}, + {Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn}, + {Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone}, + {Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence, + Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone}, + {Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Clause: clauseLanguage, Suggest: suggestColumn}, + {Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone}, + {Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone}, + {Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone}, + {Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone}, + {Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate, + Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays, + Clause: clauseDateFactory("mi.created_at"), Suggest: suggestNone}, + {Key: "imported_at", Label: "Date imported", Group: GroupBookFacts, Kind: KindDate, + Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays, + Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone}, + {Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence, + Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone}, + {Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence, + Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone}, + {Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier, + Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone}, + {Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier, + Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone}, + {Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.file_size", parseNumberInt), Suggest: suggestNone}, + {Key: "file_availability", Label: "File availability", Group: GroupBookFacts, Kind: KindStatus, + Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs, + Clause: clauseFileAvailability, Suggest: suggestNone}, + {Key: "explicit", Label: "Explicit", Group: GroupBookFacts, Kind: KindBool, + Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, + Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone}, + {Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool, + Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, + Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone}, + {Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence, + Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clauseCover, Suggest: suggestNone}, + {Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier, + Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clauseAddedBy, Suggest: suggestAddedBy}, + + // ── Comics & manga ──────────────────────────────────────────── + {Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy, + Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + // Full series membership: the primary series column AND the + // additional series held in alternate_info — a book whose + // membership of X is secondary must not hide. + Clause: clauseSeriesMembership, Suggest: suggestSeries}, + {Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone}, + {Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone}, + {Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone}, + {Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum, + Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs, + Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn}, + {Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum, + Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs, + Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn}, + {Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool, + Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, + Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone}, + {Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool, + Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, + Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone}, + {Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool, + Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, + Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone}, + {Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum, + Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs, + Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn}, + {Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum, + Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs, + Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn}, + + // ── My reading (user-relative — the VIEWER's rows) ──────────── + {Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus, + Ops: []string{OpIs}, DefaultOp: OpIs, + Clause: clauseStatus, Suggest: suggestNone}, + {Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, + Clause: clauseProgressPercent, Suggest: suggestNone}, + {Key: "last_read", Label: "Last read", Group: GroupMyReading, Kind: KindDate, + Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays, + Clause: clauseLastRead, Suggest: suggestNone}, + {Key: "my_rating", Label: "My rating", Group: GroupMyReading, Kind: KindNumber, + Ops: []string{OpIs, OpAtLeast, OpAtMost, OpUnrated}, DefaultOp: OpAtLeast, + Clause: clauseMyRating, Suggest: suggestNone}, + {Key: "review", Label: "Has review", Group: GroupMyReading, Kind: KindPresence, + Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas, + Clause: clauseReviewPresence, Suggest: suggestNone}, + {Key: "notes", Label: "Notes", Group: GroupMyReading, Kind: KindPresence, + Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas, + Clause: clauseNotesPresence, Suggest: suggestNone}, + {Key: "highlights", Label: "Highlights", Group: GroupMyReading, Kind: KindPresence, + Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas, + Clause: clauseHighlightsPresence, Suggest: suggestNone}, + {Key: "note_text", Label: "Note text", Group: GroupMyReading, Kind: KindNoteText, + // "Contains" here is substring ILIKE, NOT the system-wide fuzzy: + // your own words deserve exact-ish. A book matches when ANY of + // the viewer's notes on it match (EXISTS + bound values). + Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains, + Clause: clauseNoteText, Suggest: suggestNoteText}, + {Key: "highlight_text", Label: "Highlight text", Group: GroupMyReading, Kind: KindNoteText, + Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains, + Clause: clauseHighlightText, Suggest: suggestHighlight}, + {Key: "time_spent", Label: "Time spent (minutes)", Group: GroupMyReading, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast, + Clause: clauseTimeSpent, Suggest: suggestNone}, + {Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber, + Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast, + Clause: clausePagesRead, Suggest: suggestNone}, + {Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection, + // Membership mirrors the collection page: excluded items and + // deleted collections never count; playlists are collections. + Ops: []string{OpIn, OpNotIn, OpInAny, OpInNone}, DefaultOp: OpIn, + Clause: clauseCollection, Suggest: suggestCollection}, + + // ── Library (holding structure + family lens) ───────────────── + {Key: "library", Label: "Library", Group: GroupLibrary, Kind: KindLibrary, + // Values are library IDs; the menu only ever offers the viewer's + // VISIBLE libraries (family lens — a hidden library can not even + // be offered). + Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs, + Clause: clauseLibrary, Suggest: suggestLibrary}, +} + +// FilterFieldByKey returns the registry entry for a wire key. +func FilterFieldByKey(key string) (FilterField, bool) { + for _, f := range filterFields { + if f.Key == key { + return f, true + } + } + return FilterField{}, false +} + +// FilterFieldsByGroup returns the registry grouped for the field menu, +// groups in display order. +func FilterFieldsByGroup() []struct { + Group string + Fields []FilterField +} { + order := []string{GroupPeople, GroupPublish, GroupTopics, GroupBookFacts, GroupComics, GroupMyReading, GroupLibrary} + out := make([]struct { + Group string + Fields []FilterField + }, 0, len(order)) + for _, g := range order { + group := struct { + Group string + Fields []FilterField + }{Group: g} + for _, f := range filterFields { + if f.Group == g { + group.Fields = append(group.Fields, f) + } + } + out = append(out, group) + } + return out +} + +// FilterFieldKeys returns every registered key (the completeness check +// for tests). +func FilterFieldKeys() []string { + keys := make([]string, 0, len(filterFields)) + for _, f := range filterFields { + keys = append(keys, f.Key) + } + return keys +} + +// fieldOpsAllowed reports whether op is allowed on the field. +func fieldOpsAllowed(f FilterField, op string) bool { + for _, o := range f.Ops { + if o == op { + return true + } + } + return false +} + +// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI +// discourages depth but the model allows a little headroom). +const maxFilterDepth = 8 + +// ParseFilterTree parses + validates the qf wire JSON. Validation is +// the allow-list enforcement: unknown fields/operators and malformed +// values are a parse error (→ 400), never a silently-ignored token. +func ParseFilterTree(raw string) (*FilterTree, error) { + var t FilterTree + if err := json.Unmarshal([]byte(raw), &t); err != nil { + return nil, fmt.Errorf("invalid qf JSON: %w", err) + } + if err := validateGroup(&FilterChild{Mode: t.Mode, Children: t.Children}, 1); err != nil { + return nil, err + } + return &t, nil +} + +func validateGroup(g *FilterChild, depth int) error { + if depth > maxFilterDepth { + return fmt.Errorf("filter group nesting deeper than %d", maxFilterDepth) + } + if g.Mode != "all" && g.Mode != "any" { + return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode) + } + if len(g.Children) == 0 { + return fmt.Errorf("filter group has no children") + } + for _, c := range g.Children { + if c.Field == "" && c.Mode != "" { + if err := validateGroup(&c, depth+1); err != nil { + return err + } + continue + } + if err := validateRule(c); err != nil { + return err + } + } + return nil +} + +// numericParsers map each numeric field's wire values to the Go value +// bound into SQL — validation and clause-building share one parser per +// field, so a value that passes validation always binds. +var numericParsers = map[string]func(string) (any, error){ + "published_year": parseNumberInt, + "page_count": parseNumberInt, + "chapter_count": parseNumberInt, + "total_characters": parseNumberInt, + "file_size": parseNumberInt, + "series_count": parseNumberInt, + "volume": parseNumberInt, + "my_rating": parseNumberInt, + "series_number": parseNumberFloat, + "community_rating": parseNumberFloat, + "progress_percent": parseNumberFloat, + "time_spent": parseNumberFloat, + "pages_read": parseNumberFloat, +} + +func validateRule(c FilterChild) error { + f, ok := FilterFieldByKey(c.Field) + if !ok { + return fmt.Errorf("unknown filter field %q", c.Field) + } + if !fieldOpsAllowed(f, c.Op) { + return fmt.Errorf("operator %q not allowed on field %q", c.Op, c.Field) + } + if opsNoValue[c.Op] { + if len(c.Values) != 0 { + return fmt.Errorf("operator %q takes no values", c.Op) + } + return nil + } + if len(c.Values) == 0 { + return fmt.Errorf("operator %q on field %q needs a value", c.Op, c.Field) + } + if c.Op == OpBetween && len(c.Values) != 2 { + return fmt.Errorf("between takes exactly two values") + } + // Numeric fields: values must parse (they bind as numbers). + if parse, ok := numericParsers[f.Key]; ok { + for _, v := range c.Values { + if _, err := parse(v); err != nil { + return fmt.Errorf("field %q needs numeric values, got %q", f.Key, v) + } + } + } + // Date within_days: a positive integer day count. + if c.Op == OpWithinDays { + n, err := strconv.Atoi(c.Values[0]) + if err != nil || n <= 0 { + return fmt.Errorf("within_days needs a positive day count, got %q", c.Values[0]) + } + } + // Static domains (status, file_availability). + if domain, ok := fixedDomains[f.Key]; ok { + for _, v := range c.Values { + found := false + for _, d := range domain { + if d == v { + found = true + break + } + } + if !found { + return fmt.Errorf("field %q value must be one of %v, got %q", f.Key, domain, v) + } + } + } + // UUID-valued fields (collection, library, added_by "is"). + switch f.Key { + case "library", "collection": + for _, v := range c.Values { + if _, err := uuid.Parse(v); err != nil { + return fmt.Errorf("field %q values must be UUIDs, got %q", f.Key, v) + } + } + case "added_by": + if c.Op == OpIs { + if _, err := uuid.Parse(c.Values[0]); err != nil { + return fmt.Errorf("added_by \"is\" needs a user UUID, got %q", c.Values[0]) + } + } + } + return nil +} + +func parseNumberInt(v string) (any, error) { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, err + } + return n, nil +} + +func parseNumberFloat(v string) (any, error) { + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return nil, err + } + return f, nil +} diff --git a/internal/services/filter_registry_test.go b/internal/services/filter_registry_test.go new file mode 100644 index 00000000..3a184f00 --- /dev/null +++ b/internal/services/filter_registry_test.go @@ -0,0 +1,131 @@ +package services + +// Filter registry unit tests (faceted plan Phase 9): every entry is +// complete (kind, operators, default operator, clause, suggestion +// source), the mandated surfaces are present (including the three +// holding structures), and the qf parse is a strict allow-list. + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterRegistryCompleteness(t *testing.T) { + keys := FilterFieldKeys() + require.NotEmpty(t, keys) + + seen := map[string]bool{} + for _, key := range keys { + require.False(t, seen[key], "duplicate registry key %q", key) + seen[key] = true + + f, ok := FilterFieldByKey(key) + require.True(t, ok) + require.NotEmpty(t, f.Label, "%s: label", key) + require.NotEmpty(t, f.Group, "%s: menu group", key) + require.NotEmpty(t, f.Kind, "%s: kind", key) + require.NotEmpty(t, f.Ops, "%s: operators", key) + require.NotNil(t, f.Clause, "%s: match clause", key) + require.Contains(t, f.Ops, f.DefaultOp, "%s: default operator must be allowed", key) + } + + // The mandated surfaces — every book attribute class, the user- + // relative class, and the three HOLDING STRUCTURES (library, + // collections, series — the registry is the completeness check). + mandated := []string{ + // people / publishing / topics + "author", "narrator", "contributor", "publisher", "imprint", + "edition", "published_year", "genre", "tags", "story_arc", "age_rating", + // reading surface (comics & manga) + "format_group", "mime_type", "is_reflowable", "has_fixed_layout", + "is_black_and_white", "manga_type", "reading_direction", "explicit", "abridged", + // series facts + numbers + dates + text + presence + identifiers + "series", "series_number", "series_count", "volume", + "page_count", "chapter_count", "total_characters", "file_size", + "community_rating", "added_at", "imported_at", + "title", "subtitle", "description", "summary", "language", + "web_url", "metadata_notes", "isbn", "asin", "cover", + "file_availability", "added_by", + // user-relative (everything that pertains to a book) + "status", "progress_percent", "last_read", "my_rating", "review", + "notes", "highlights", "note_text", "highlight_text", "time_spent", + "pages_read", "collection", + // holding structure + "library", + } + for _, key := range mandated { + assert.True(t, seen[key], "mandated filter field %q missing from the registry", key) + } +} + +func TestFilterParseValidation(t *testing.T) { + valid := func(raw string) { + _, err := ParseFilterTree(raw) + require.NoError(t, err, "expected %s to parse", raw) + } + invalid := func(raw, wantContain string) { + _, err := ParseFilterTree(raw) + require.Error(t, err, "expected %s to be rejected", raw) + if wantContain != "" { + require.Contains(t, err.Error(), wantContain) + } + } + + valid(`{"mode":"all","children":[{"field":"author","op":"contains","values":["Voss"]}]}`) + valid(`{"mode":"any","children":[ + {"field":"author","op":"contains","values":["Voss"]}, + {"mode":"all","children":[{"field":"genre","op":"is","values":["fantasy"]}]}]}`) + valid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["10","20"]}]}`) + valid(`{"mode":"all","children":[{"field":"cover","op":"has"}]}`) + valid(`{"mode":"all","children":[{"field":"status","op":"is","values":["reading","finished"]}]}`) + valid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["14"]}]}`) + valid(`{"mode":"all","children":[{"field":"my_rating","op":"unrated"}]}`) + valid(`{"mode":"all","children":[{"field":"collection","op":"in_any"}]}`) + valid(`{"mode":"all","children":[{"field":"library","op":"is","values":["` + + "12345678-1234-1234-1234-123456789012" + `"]}]}`) + + invalid(`{not json`, "invalid qf JSON") + invalid(`{"mode":"all","children":[]}`, "no children") + invalid(`{"mode":"sometimes","children":[{"field":"author","op":"contains","values":["x"]}]}`, "mode must be all|any") + invalid(`{"mode":"all","children":[{"field":"not_a_field","op":"is","values":["x"]}]}`, "unknown filter field") + invalid(`{"mode":"all","children":[{"field":"author","op":"explodes","values":["x"]}]}`, "not allowed on field") + invalid(`{"mode":"all","children":[{"field":"page_count","op":"contains","values":["1"]}]}`, "not allowed on field") + invalid(`{"mode":"all","children":[{"field":"page_count","op":"at_least","values":["abc"]}]}`, "numeric") + invalid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["1"]}]}`, "exactly two") + invalid(`{"mode":"all","children":[{"field":"cover","op":"has","values":["x"]}]}`, "takes no values") + invalid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["0"]}]}`, "positive day count") + invalid(`{"mode":"all","children":[{"field":"status","op":"is","values":["platypus"]}]}`, "must be one of") + invalid(`{"mode":"all","children":[{"field":"library","op":"is","values":["not-a-uuid"]}]}`, "must be UUIDs") + invalid(`{"mode":"all","children":[{"field":"author","op":"contains"}]}`, "needs a value") +} + +func TestFilterParseDepthCap(t *testing.T) { + // Nesting beyond maxFilterDepth is rejected. + raw := `{"mode":"all","children":[` + for i := 0; i < maxFilterDepth+2; i++ { + raw += `{"mode":"all","children":[` + } + raw += `{"field":"author","op":"contains","values":["x"]}` + for i := 0; i < maxFilterDepth+2; i++ { + raw += `]}` + } + raw += `]}` + _, err := ParseFilterTree(raw) + require.Error(t, err) + require.Contains(t, err.Error(), "nesting deeper") +} + +func TestFilterFieldsByGroupOrdering(t *testing.T) { + groups := FilterFieldsByGroup() + require.NotEmpty(t, groups) + first := groups[0].Group + if !strings.EqualFold(first, GroupPeople) { + t.Fatalf("first menu group should be %q, got %q", GroupPeople, first) + } + for _, g := range groups { + require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group) + } +} diff --git a/internal/services/search.go b/internal/services/search.go index 67136342..b09bcb65 100644 --- a/internal/services/search.go +++ b/internal/services/search.go @@ -14,12 +14,16 @@ import ( // SearchService handles all search and filter operations type SearchService struct { - db *database.Queries + db *database.Queries + raw database.DBTX // the raw pgx handle — the composed filter engines' path } -// NewSearchService creates a new search service instance -func NewSearchService(db *database.Queries) *SearchService { - return &SearchService{db: db} +// NewSearchService creates a new search service instance. raw is the +// same pool db wraps (database.DBTX) — the composed filter engines run +// hand-built, allow-listed SQL with bound parameters, which static sqlc +// queries cannot express. +func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService { + return &SearchService{db: db, raw: raw} } // SearchParams contains parameters for unified search @@ -52,6 +56,14 @@ type SearchParams struct { ReadingDirectionFilter string ExplicitFilter pgtype.Bool AbridgedFilter pgtype.Bool + + // Filter rule builder (faceted plan Phase 9). When FilterTree is + // set the composed engine runs INSTEAD of the flat facet params + // (qf wins over flat on a hand-crafted URL — precedence pinned by + // test); FilterParseErr carries a qf parse/validation failure for + // the endpoints to answer 400. + FilterTree *FilterTree + FilterParseErr string } // parseSearchQuery detects quoted strings for exact match search @@ -272,11 +284,15 @@ type FieldSearchParams struct { Offset int } -// FieldValue represents a single field value with metadata +// FieldValue is a single field value with metadata. Display carries a +// human label when the wire value is an ID (library/collection/added_by +// suggestions) — omitted everywhere else, so the classic static shape +// {value, count, score} is unchanged. type FieldValue struct { - Value string `json:"value"` - Count int64 `json:"count"` - Score float64 `json:"score"` + Value string `json:"value"` + Count int64 `json:"count"` + Score float64 `json:"score"` + Display string `json:"display,omitempty"` } // SearchFieldValues handles field-specific search for autocomplete dropdowns @@ -399,8 +415,22 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc } // ExecuteSearch performs search and returns results with count -// Shared between JSON endpoint and HTML rendering +// Shared between JSON endpoint and HTML rendering. +// Routing: a qf filter tree → the composed engine (flat params ignored +// — qf wins); else the static unified query. A parse/validation failure +// surfaces as ErrInvalidFilterTree (→ 400 at the endpoints). func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) { + if params.FilterParseErr != "" { + return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr) + } + if params.FilterTree != nil { + rows, err := s.ComposedSearch(ctx, params) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, 0, err + } + count := len(rows) + return rows, count, nil + } results, err := s.SearchMediaItemsUnified(ctx, params) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, 0, err diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index 34b41117..cc94451d 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -170,114 +170,98 @@ templ BookShelf( } -
- - - -
-
- - -
- -
-
-
- - - -
-
- - - -
-
- - - -
-
- -
- - -
-
-
- - -