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:
John O'Keefe committed 2026-11-01 01:16:20 -04:00
1 parent 81e54f89b2
commit c448fa438f
5 files changed
+88 -14

No files matched your search

+14 -2
View File
@@ -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
+39
View File
@@ -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)
}