From fd716a7dab18fb093a0be6205fd018facc2e3a32 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 31 Oct 2026 13:58:42 -0400 Subject: [PATCH] fix(filters): the episodes-scope vocabulary buried the episode fields under book-only metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope door's first live test showed the episodes editor leading its field menu with People (Author/Narrator/Contributor) and Publishing — book-only fields that can never match an episode row — while the fields the scope exists for (Feed, Downloaded, Played) sat below the fold. Root cause: the registry's book fields carry no Scopes restriction (nil = all scopes), a leftover from before the episode fields existed. 35 unambiguous book-only fields (people, publishing metadata, topics, series/comics, identifiers, cover, reading-specific statuses) are now tagged Scopes:books. Title, description, published dates, language, file facts, explicit, the my-reading group (listening progress is real), and collection stay dual-scope. Parse continues to accept the hidden fields everywhere (their composed clauses simply match nothing on episode rows) — this governs the AUTHORING surface, not the grammar. TestFilterParseValidation's empty-root rejection updated to the new contract (empty root legal — the seed; nested empty groups still invalid), and TestEpisodesScopeHidesBookOnlyFields pins both scopes' vocabulary. --- internal/services/filter_registry.go | 105 ++++++++++++++-------- internal/services/filter_registry_test.go | 41 ++++++++- 2 files changed, 110 insertions(+), 36 deletions(-) diff --git a/internal/services/filter_registry.go b/internal/services/filter_registry.go index e14ed7b0..b1b54f75 100644 --- a/internal/services/filter_registry.go +++ b/internal/services/filter_registry.go @@ -325,24 +325,30 @@ var fixedDomains = map[string][]string{ var filterFields = []FilterField{ // ── People ──────────────────────────────────────────────────── {Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy, - Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, @@ -353,16 +359,20 @@ var filterFields = []FilterField{ // ── Topics ──────────────────────────────────────────────────── {Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy, - Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs, Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn}, // ── Book facts ──────────────────────────────────────────────── @@ -370,28 +380,33 @@ var filterFields = []FilterField{ 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, @@ -400,16 +415,20 @@ var filterFields = []FilterField{ 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, @@ -421,10 +440,12 @@ var filterFields = []FilterField{ 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, + Scopes: []string{ScopeBooks}, + Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone}, {Key: "manually_edited", Label: "Manually edited", Group: GroupBookFacts, Kind: KindBool, - Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, + Scopes: []string{ScopeBooks}, + Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, // metadata_overrides is NOT NULL TEXT[] — '{}' means untouched. Clause: func(r FilterRule, b *filterBuilder) string { switch r.Op { @@ -436,53 +457,66 @@ var filterFields = []FilterField{ return "TRUE" }, Suggest: suggestNone}, {Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence, - Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, + Scopes: []string{ScopeBooks}, + 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, @@ -515,7 +549,8 @@ var filterFields = []FilterField{ 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, + Scopes: []string{ScopeBooks}, + 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 diff --git a/internal/services/filter_registry_test.go b/internal/services/filter_registry_test.go index dcfbfece..8f219171 100644 --- a/internal/services/filter_registry_test.go +++ b/internal/services/filter_registry_test.go @@ -97,7 +97,11 @@ func TestFilterParseValidation(t *testing.T) { "12345678-1234-1234-1234-123456789012" + `"]}]}`) invalid(`{not json`, "invalid qf JSON") - invalid(`{"mode":"all","children":[]}`, "no children") + // An EMPTY ROOT is legal since the episodes scope door (the create + // modal seeds one; semantics = matches nothing) — see + // TestEmptyRootTreeSeedsEpisodesScope. A NESTED empty group stays + // invalid: + invalid(`{"mode":"all","children":[{"mode":"any","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") @@ -196,3 +200,38 @@ func TestEmptyRootTreeSeedsEpisodesScope(t *testing.T) { t.Fatal("nested empty group must stay invalid") } } + +// The episodes-scope vocabulary leads with what the scope can match: +// book-only metadata (people, publishing metadata, series/comics, +// book identifiers) is hidden from the menu. Parse still accepts them +// (their composed clauses simply match nothing on episode rows) — this +// pins the AUTHORING surface, not the grammar. +func TestEpisodesScopeHidesBookOnlyFields(t *testing.T) { + episodes := FilterFieldsPayload(ScopeEpisodes) + keys := map[string]bool{} + for _, f := range episodes { + keys[f.Key] = true + } + hidden := []string{"author", "narrator", "publisher", "isbn", "series", "format_group", "subtitle"} + for _, k := range hidden { + if keys[k] { + t.Errorf("episodes vocabulary must not offer book-only field %q", k) + } + } + for _, want := range []string{"feed", "downloaded", "played", "published_date", "title"} { + if !keys[want] { + t.Errorf("episodes vocabulary must offer %q", want) + } + } + + // The books scope keeps the book fields and still rejects episode + // fields. + books := FilterFieldsPayload(ScopeBooks) + bkeys := map[string]bool{} + for _, f := range books { + bkeys[f.Key] = true + } + if !bkeys["author"] || bkeys["feed"] { + t.Fatalf("books vocabulary wrong: author=%v feed=%v", bkeys["author"], bkeys["feed"]) + } +}