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:
1 parent
536fb4794c
commit
fd716a7dab
2 files changed
+75
-1
No files matched your search
@@ -325,23 +325,29 @@ var fixedDomains = map[string][]string{
|
||||
var filterFields = []FilterField{
|
||||
// ── People ────────────────────────────────────────────────────
|
||||
{Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseAuthorArray, Suggest: suggestAuthor},
|
||||
{Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
@@ -353,15 +359,19 @@ var filterFields = []FilterField{
|
||||
|
||||
// ── Topics ────────────────────────────────────────────────────
|
||||
{Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
|
||||
|
||||
@@ -370,27 +380,32 @@ 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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
@@ -400,15 +415,19 @@ 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,
|
||||
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,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
|
||||
{Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
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,
|
||||
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,
|
||||
@@ -421,9 +440,11 @@ var filterFields = []FilterField{
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
|
||||
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
|
||||
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,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
// metadata_overrides is NOT NULL TEXT[] — '{}' means untouched.
|
||||
Clause: func(r FilterRule, b *filterBuilder) string {
|
||||
@@ -436,52 +457,65 @@ var filterFields = []FilterField{
|
||||
return "TRUE"
|
||||
}, Suggest: suggestNone},
|
||||
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseCover, Suggest: suggestNone},
|
||||
{Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseAddedBy, Suggest: suggestAddedBy},
|
||||
|
||||
// ── Comics & manga ────────────────────────────────────────────
|
||||
{Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpIs}, DefaultOp: OpIs,
|
||||
Clause: clauseStatus, Suggest: suggestNone},
|
||||
{Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
|
||||
@@ -515,6 +549,7 @@ var filterFields = []FilterField{
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseTimeSpent, Suggest: suggestNone},
|
||||
{Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber,
|
||||
Scopes: []string{ScopeBooks},
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||
Clause: clausePagesRead, Suggest: suggestNone},
|
||||
{Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user