From eb686723908fbadd7ea66951235fa9721f0adeda Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 19:07:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(filters):=20one=20vocabulary,=20server-ren?= =?UTF-8?q?dered=20=E2=80=94=20the=20TS=20mirror=20retires=20(item=203.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- internal/services/filter_registry.go | 122 +++++++++ templates/bookshelf.templ | 2 + templates/bookshelf_templ.go | 154 ++++++----- web/src/filter_builder.test.ts | 32 +++ web/src/filter_builder.ts | 376 ++++++++------------------- 5 files changed, 345 insertions(+), 341 deletions(-) diff --git a/internal/services/filter_registry.go b/internal/services/filter_registry.go index 6ba6ba7d..69a5ae3c 100644 --- a/internal/services/filter_registry.go +++ b/internal/services/filter_registry.go @@ -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, diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index 65636764..908e30f7 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -3,6 +3,7 @@ package templates import ( "bookhoard/internal/database" "bookhoard/internal/handlers" + "bookhoard/internal/services" "fmt" ) @@ -176,6 +177,7 @@ templ BookShelf( innerHTML templating). Rows are a flat projection of the tree (depth = indent); groups nest via + group. -->