fix(filters): the episodes-scope vocabulary buried the episode fields under book-only metadata

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.
This commit is contained in:
John O'Keefe committed 2026-10-31 13:58:42 -04:00
1 parent 536fb4794c
commit fd716a7dab
2 files changed
+110 -36

No files matched your search

+70 -35
View File
@@ -325,24 +325,30 @@ var fixedDomains = map[string][]string{
var filterFields = []FilterField{ var filterFields = []FilterField{
// ── People ──────────────────────────────────────────────────── // ── People ────────────────────────────────────────────────────
{Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy, {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}, Clause: clauseAuthorArray, Suggest: suggestAuthor},
{Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy, {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}, Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator},
{Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy, {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}, Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib},
// ── Publishing ──────────────────────────────────────────────── // ── Publishing ────────────────────────────────────────────────
{Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy, {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}, Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher},
{Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy, {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}, Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn},
{Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy, {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}, Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn},
{Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber, {Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
@@ -353,16 +359,20 @@ var filterFields = []FilterField{
// ── Topics ──────────────────────────────────────────────────── // ── Topics ────────────────────────────────────────────────────
{Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy, {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}, Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn},
{Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy, {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}, Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags},
{Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy, {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}, Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn},
{Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum, {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}, Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
// ── Book facts ──────────────────────────────────────────────── // ── Book facts ────────────────────────────────────────────────
@@ -370,28 +380,33 @@ var filterFields = []FilterField{
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn}, Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn},
{Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText, {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}, Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn},
{Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText, {Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone}, Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone},
{Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence, {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}, Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone},
{Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy, {Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains, Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseLanguage, Suggest: suggestColumn}, Clause: clauseLanguage, Suggest: suggestColumn},
{Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber, {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}, Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone},
{Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber, {Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone}, Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone},
{Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber, {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}, Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone},
{Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber, {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}, Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone},
{Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate, {Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate,
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays, Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
@@ -400,16 +415,20 @@ var filterFields = []FilterField{
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays, Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone}, Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone},
{Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence, {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}, Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone},
{Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence, {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}, Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
{Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier, {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}, Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone},
{Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier, {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}, Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone},
{Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber, {Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
@@ -421,10 +440,12 @@ var filterFields = []FilterField{
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue, Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone}, Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool, {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}, Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
{Key: "manually_edited", Label: "Manually edited", Group: GroupBookFacts, Kind: KindBool, {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. // metadata_overrides is NOT NULL TEXT[] — '{}' means untouched.
Clause: func(r FilterRule, b *filterBuilder) string { Clause: func(r FilterRule, b *filterBuilder) string {
switch r.Op { switch r.Op {
@@ -436,53 +457,66 @@ var filterFields = []FilterField{
return "TRUE" return "TRUE"
}, Suggest: suggestNone}, }, Suggest: suggestNone},
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence, {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}, Clause: clauseCover, Suggest: suggestNone},
{Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier, {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}, Clause: clauseAddedBy, Suggest: suggestAddedBy},
// ── Comics & manga ──────────────────────────────────────────── // ── Comics & manga ────────────────────────────────────────────
{Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy, {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 // Full series membership: the primary series column AND the
// additional series held in alternate_info — a book whose // additional series held in alternate_info — a book whose
// membership of X is secondary must not hide. // membership of X is secondary must not hide.
Clause: clauseSeriesMembership, Suggest: suggestSeries}, Clause: clauseSeriesMembership, Suggest: suggestSeries},
{Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber, {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}, Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone},
{Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber, {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}, Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone},
{Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber, {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}, Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone},
{Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum, {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}, Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn},
{Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum, {Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum,
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs, Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn}, Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn},
{Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool, {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}, Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone},
{Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool, {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}, Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone},
{Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool, {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}, Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone},
{Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum, {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}, Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn},
{Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum, {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}, Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn},
// ── My reading (user-relative — the VIEWER's rows) ──────────── // ── My reading (user-relative — the VIEWER's rows) ────────────
{Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus, {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}, Clause: clauseStatus, Suggest: suggestNone},
{Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber, {Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast, Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
@@ -515,7 +549,8 @@ var filterFields = []FilterField{
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast, Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
Clause: clauseTimeSpent, Suggest: suggestNone}, Clause: clauseTimeSpent, Suggest: suggestNone},
{Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber, {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}, Clause: clausePagesRead, Suggest: suggestNone},
{Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection, {Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
// Membership mirrors the collection page: excluded items and // Membership mirrors the collection page: excluded items and
+40 -1
View File
@@ -97,7 +97,11 @@ func TestFilterParseValidation(t *testing.T) {
"12345678-1234-1234-1234-123456789012" + `"]}]}`) "12345678-1234-1234-1234-123456789012" + `"]}]}`)
invalid(`{not json`, "invalid qf JSON") 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":"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":"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":"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") 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"])
}
}