feat(filters): one vocabulary, server-rendered — the TS mirror retires (item 3.3)

"One filter system to maintain" now holds at the presentation layer
too: the field/op list the builder renders comes from
services.FilterFieldsPayload(scope) — the Go registry's wire face,
scope-filtered and group-ordered — rendered into the bookshelf mount
as a data attribute (templates.JSONArg), ingested once by the Alpine
component. The registry carries operator presentation (labels,
negation, arity) in FilterOpWire alongside the grammar; per-field
value-cell semantics (valueKind) derive from the existing Kind.

The hardcoded TS mirror (OPS, op(), F(), TEXT_OPS/NUM_OPS/DATE_OPS,
FIELD_GROUPS, its known drift surface) is deleted; filter_builder.ts
now ingests the wire (ingestFilterFields — malformed payloads leave
empty menus rather than client-invented rows), and the field menu's
episode fields are HIDDEN on the All-Books page for free (the books
scope payload excludes them — the plan's "hide podcasts from the
rules on the all books page" is now structural, not enforced by a
second hand-kept list).

Unit tests ingest a minimal wire fixture (the payload shape is the
contract, not the mirror); vitest 167 green, tsc + vp clean.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:07:45 -04:00
1 parent 6b2125a45a
commit eb68672390
5 files changed
+345 -341

No files matched your search

+122
View File
@@ -186,6 +186,128 @@ func (f FilterField) InScope(scope string) bool {
return false
}
// FilterOpWire is one operator's presentation contract (item 3.3):
// THE system carries both grammar and rendering in one place — the
// frontend renders this wire, it invents nothing.
type FilterOpWire struct {
Key string `json:"key"`
Label string `json:"label"`
Negated bool `json:"negated,omitempty"`
// Values is the value-cell arity: "many" (one multi-value input),
// "one", "two", or "none".
Values string `json:"values"`
}
var filterOps = map[string]FilterOpWire{
OpContains: {Key: OpContains, Label: "contains", Values: "many"},
OpNotContains: {Key: OpNotContains, Label: "doesn't contain", Negated: true, Values: "many"},
OpIs: {Key: OpIs, Label: "is", Values: "many"},
OpIsNot: {Key: OpIsNot, Label: "isn't", Negated: true, Values: "many"},
OpEmpty: {Key: OpEmpty, Label: "is empty", Values: "none"},
OpIsTrue: {Key: OpIsTrue, Label: "is true", Values: "none"},
OpIsFalse: {Key: OpIsFalse, Label: "is false", Values: "none"},
OpBetween: {Key: OpBetween, Label: "between", Values: "two"},
OpAtLeast: {Key: OpAtLeast, Label: "at least", Values: "one"},
OpAtMost: {Key: OpAtMost, Label: "at most", Values: "one"},
OpWithinDays: {Key: OpWithinDays, Label: "within the last", Values: "one"},
OpHas: {Key: OpHas, Label: "has", Values: "none"},
OpMissing: {Key: OpMissing, Label: "missing", Values: "none"},
OpUnrated: {Key: OpUnrated, Label: "unrated", Values: "none"},
OpIn: {Key: OpIn, Label: "in", Values: "many"},
OpNotIn: {Key: OpNotIn, Label: "isn't in", Negated: true, Values: "many"},
OpInAny: {Key: OpInAny, Label: "in any", Values: "none"},
OpInNone: {Key: OpInNone, Label: "in none", Negated: true, Values: "none"},
}
// FilterFieldWire is one field's presentation entry (the registry's
// wire face): the frontend menu is scope-filtered OFF this list.
type FilterFieldWire struct {
Key string `json:"key"`
Label string `json:"label"`
Group string `json:"group"`
ValueKind string `json:"valueKind"`
Ops []FilterOpWire `json:"ops"`
DefaultOp string `json:"defaultOp"`
Suggest bool `json:"suggest"`
Options []string `json:"options,omitempty"`
}
// valueKinds — what the value cell renders (the registry's Kind mapped
// to frontend-cell semantics).
const (
VKText = "text"
VKNumber = "number"
VKDays = "days"
VKId = "id"
VKFixed = "fixed"
VKNone = "none"
)
// valueKindFor maps the SQL-facing Kind to the cell semantics.
func valueKindFor(kind FilterKind, key string) string {
switch kind {
case KindScalarFuzzy, KindArrayFuzzy, KindText:
return VKText
case KindEnum, KindStatus, KindLibrary, KindCollection, KindIdentifier:
return VKId
case KindBool:
return VKNone
case KindNumber, KindDate:
if kind == KindDate {
return VKDays
}
return VKNumber
case KindPresence, KindNoteText:
return VKNone
}
if key == "added_by" {
return VKId
}
return VKNone
}
// FilterFieldsPayload is THE builder vocabulary for one scope: group-
// ordered wire fields (registry order), each with its ops' presentation.
// The frontend renders this; it hardcodes nothing.
func FilterFieldsPayload(scope string) []FilterFieldWire {
suggestKnown := map[SuggestSource]bool{
suggestAuthor: true, suggestNarrator: true, suggestContrib: true,
suggestPublisher: true, suggestTags: true, suggestColumn: true,
suggestSeries: true, suggestLibrary: true, suggestCollection: true,
suggestAddedBy: true, suggestFeed: true,
}
presented := make(map[string]bool)
out := []FilterFieldWire{}
for _, f := range filterFields {
if !f.InScope(scope) {
continue
}
if presented[f.Key] {
continue
}
presented[f.Key] = true
ops := make([]FilterOpWire, 0, len(f.Ops))
for _, opKey := range f.Ops {
ops = append(ops, filterOps[opKey])
}
wire := FilterFieldWire{
Key: f.Key,
Label: f.Label,
Group: f.Group,
ValueKind: valueKindFor(f.Kind, f.Key),
Ops: ops,
DefaultOp: f.DefaultOp,
Suggest: suggestKnown[f.Suggest],
}
if domain, ok := fixedDomains[f.Key]; ok {
wire.ValueKind = VKFixed
wire.Options = domain
}
out = append(out, wire)
}
return out
}
// opsWithValue / opsWithoutValue drive validation.
var opsNoValue = map[string]bool{
OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true,