fix(filter-builder): one operator name per field (Option A); drawer ids resolve on refresh
The is/in question, settled per user decision: they were exact synonyms (feed's clause compiles is/is_not and in/not_in to the same ANY), so the vocabulary collapses instead of being explained. - Registry: feed's offered ops are in/isn't-in (+has/missing); legacyFieldOps keeps is/is_not ACCEPTED at parse forever — every stored tree and saved filter from before the collapse parses and evaluates unchanged. Library keeps is/isn't (its only names); collection's genuinely distinct operators are untouched. - Client: id-kind cells are uniform — every operator on an id field accepts a set, so all id cells say "pick one or more" and accumulate picks. The per-operator placeholder/pick branching is gone (it also never fired: the registry types is as Values:"many"). - The drawer-refresh id fix rides along: hydrateFromForm now calls resolveIdLabels, so a page refresh with an active saved filter resolves stored ids to names in the bookshelf drawer (the collection rules page already did at init). Pins: feed payload omits the synonyms while legacy feed is/is_not trees parse (Go unit); uniform accumulate + placeholder (vitest); menu-open regression and hydrate-resolution pins kept. Web 183/183; services, filter + episode-tree + search/collections contract slices green (full suite skipped per user — vocabulary + UI-layer change, engine paths pinned by the targeted slices).
This commit is contained in:
1 parent
81e54f89b2
commit
c448fa438f
5 files changed
+88
-14
No files matched your search
@@ -566,7 +566,11 @@ var filterFields = []FilterField{
|
||||
// `played` is THE finished predicate (viewer progress >= 0.999 —
|
||||
// the same definition as hide_finished, item 2.4).
|
||||
{Key: "feed", Label: "Feed", Group: GroupEpisodes, Kind: KindIdentifier,
|
||||
Ops: []string{OpIs, OpIsNot, OpIn, OpNotIn, OpHas, OpMissing}, DefaultOp: OpIn,
|
||||
// One name per semantic (2026-11-01, Option A): is/is_not were
|
||||
// exact synonyms of in/not_in (the clause compiles both to the
|
||||
// same ANY). Stored trees carrying the legacy keys keep
|
||||
// parsing — see legacyFieldOps.
|
||||
Ops: []string{OpIn, OpNotIn, OpHas, OpMissing}, DefaultOp: OpIn,
|
||||
Clause: clauseFeed, Suggest: suggestFeed, Scopes: []string{ScopeEpisodes}},
|
||||
{Key: "downloaded", Label: "Downloaded", Group: GroupEpisodes, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
@@ -631,13 +635,21 @@ func FilterFieldKeys() []string {
|
||||
}
|
||||
|
||||
// fieldOpsAllowed reports whether op is allowed on the field.
|
||||
// legacyFieldOps — operator synonyms still ACCEPTED at parse for
|
||||
// trees saved before the vocabulary collapse (2026-11-01): feed once
|
||||
// offered is/is_not alongside in/not_in with identical semantics. The
|
||||
// menus offer one name per field; stored trees parse forever.
|
||||
var legacyFieldOps = map[string]map[string]bool{
|
||||
"feed": {OpIs: true, OpIsNot: true},
|
||||
}
|
||||
|
||||
func fieldOpsAllowed(f FilterField, op string) bool {
|
||||
for _, o := range f.Ops {
|
||||
if o == op {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return legacyFieldOps[f.Key][op]
|
||||
}
|
||||
|
||||
// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI
|
||||
|
||||
@@ -235,3 +235,42 @@ func TestEpisodesScopeHidesBookOnlyFields(t *testing.T) {
|
||||
t.Fatalf("books vocabulary wrong: author=%v feed=%v", bkeys["author"], bkeys["feed"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2026-11-01, Option A: one operator name per semantic ────────────
|
||||
// feed's MENU offers in/not_in only (is/is_not were exact synonyms);
|
||||
// trees saved before the collapse keep parsing forever.
|
||||
|
||||
func TestFeedVocabularyCollapse(t *testing.T) {
|
||||
f, ok := FilterFieldByKey("feed")
|
||||
require.True(t, ok)
|
||||
for _, o := range f.Ops {
|
||||
assert.NotEqual(t, OpIs, o, "the feed menu must not offer the is synonym")
|
||||
assert.NotEqual(t, OpIsNot, o, "the feed menu must not offer the is_not synonym")
|
||||
}
|
||||
assert.Contains(t, f.Ops, OpIn)
|
||||
assert.Contains(t, f.Ops, OpNotIn)
|
||||
|
||||
// The payload (the client's menus) carries the same collapse.
|
||||
for _, wf := range FilterFieldsPayload(ScopeEpisodes) {
|
||||
if wf.Key != "feed" {
|
||||
continue
|
||||
}
|
||||
for _, op := range wf.Ops {
|
||||
assert.NotEqual(t, OpIs, op.Key, "payload must not offer is on feed")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("feed missing from the episodes payload")
|
||||
}
|
||||
|
||||
func TestFeedLegacyOpsStillParse(t *testing.T) {
|
||||
// Stored trees from before the collapse: is/is_not on feed parse
|
||||
// and validate like their in/not_in twins.
|
||||
legacy := `{"scope":"episodes","mode":"any","children":[
|
||||
{"field":"feed","op":"is","values":["11111111-1111-1111-1111-111111111111"]},
|
||||
{"field":"feed","op":"is_not","values":["22222222-2222-2222-2222-222222222222"]}
|
||||
]}`
|
||||
tree, err := ParseFilterTree(legacy)
|
||||
require.NoError(t, err, "legacy feed is/is_not trees parse forever")
|
||||
require.Len(t, tree.Children, 2)
|
||||
}
|
||||
Reference in new issue
Block a user