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,
+2
View File
@@ -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. -->
<div x-data="filterBuilderExtra" x-cloak
data-filter-fields={ templates.JSONArg(services.FilterFieldsPayload("books")) }
x-effect="filtersOpen === true && ensureReadyRule()"
>
<template x-for="row in builderRows" :key="row.key">
+84 -70
View File
@@ -11,6 +11,7 @@ import templruntime "github.com/a-h/templ/runtime"
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"fmt"
)
@@ -119,7 +120,7 @@ func BookShelf(
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 92, Col: 135}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -132,7 +133,7 @@ func BookShelf(
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 94, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -195,7 +196,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -213,7 +214,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -234,7 +235,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -247,7 +248,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -260,7 +261,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -278,7 +279,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -291,7 +292,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -304,7 +305,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -323,7 +324,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -336,7 +337,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -349,7 +350,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -367,7 +368,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -380,7 +381,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -393,7 +394,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -406,7 +407,20 @@ func BookShelf(
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak data-filter-fields=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templates.JSONArg(services.FilterFieldsPayload("books")))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 180, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = Line truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -415,53 +429,53 @@ func BookShelf(
return templ_7745c5c3_Err
}
if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 310, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if count > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 319, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 312, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if count > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 321, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if offset <= 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " disabled")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " disabled")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -469,43 +483,43 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 328, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 333, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 330, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 335, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if offset+limit >= count {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " disabled")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " disabled")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><span>Next</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "><span>Next</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -513,12 +527,12 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -526,7 +540,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+32
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
ingestFilterFields,
isFlatable,
isRule,
pruneIncomplete,
@@ -9,6 +10,37 @@ import {
type FilterRuleState,
} from "./filter_builder";
// The vocabulary now arrives from the Go registry (item 3.3); unit
// tests ingest a minimal wire so arity/ops lookups have data — the
// shape here mirrors services.FilterFieldsPayload, not a mirror.
ingestFilterFields([
{
key: "published_year",
label: "Published year",
group: "Publishing",
valueKind: "number",
defaultOp: "at_least",
suggest: false,
ops: [
{ key: "between", label: "between", arity: "two" },
{ key: "at_least", label: "at least", arity: "one" },
{ key: "at_most", label: "at most", arity: "one" },
],
},
{
key: "cover",
label: "Cover",
group: "Book facts",
valueKind: "none",
defaultOp: "has",
suggest: false,
ops: [
{ key: "has", label: "has", arity: "none" },
{ key: "missing", label: "missing", arity: "none" },
],
},
]);
// ruleToFlatParam/isFlatable encode the PINNED deep-link wire contract:
// these params are byte-compatible with bookmarked URLs already in the
// wild, so assertions here are contract pins, not implementation details.
+105 -271
View File
@@ -16,6 +16,81 @@ import { Alpine } from "./alpine";
// ─── operator + field metadata (client mirror of
// internal/services/filter_registry.go — labels drive the menus) ────
// ─── operator + field vocabulary (ONE source: the Go registry) ──────
// internal/services.FilterFieldsPayload renders this page's fields,
// scope-filtered, with every operator's label/negation/arity. The
// frontend renders the wire and invents nothing (item 3.3) — the old
// hardcoded client mirror (and its known drift) is retired.
interface WireOp {
key: string;
label: string;
negated?: boolean;
arity?: OpMeta["arity"];
}
interface WireField {
key: string;
label: string;
group: string;
valueKind: FieldMeta["valueKind"];
ops: WireOp[];
defaultOp: string;
suggest: boolean;
options?: string[];
}
let wireOps: Record<string, OpMeta> = {};
let wireFieldGroups: { group: string; fields: FieldMeta[] }[] = [];
const FIELD_BY_KEY = new Map<string, FieldMeta>();
export function ingestFilterFields(raw: unknown): void {
if (!Array.isArray(raw)) return;
const fields = raw as WireField[];
wireOps = {};
wireFieldGroups = [];
FIELD_BY_KEY.clear();
for (const f of fields) {
if (!fieldMetaOK(f)) continue;
const fm: FieldMeta = {
key: f.key,
label: f.label,
group: f.group,
valueKind: f.valueKind,
ops: [],
defaultOp: f.defaultOp,
suggest: f.suggest === true,
options: f.options,
};
let group = wireFieldGroups.find((g) => g.group === f.group);
if (!group) {
group = { group: f.group, fields: [] };
wireFieldGroups.push(group);
}
group.fields.push(fm);
FIELD_BY_KEY.set(f.key, fm);
for (const o of f.ops ?? []) {
wireOps[o.key] = {
key: o.key,
label: o.label,
negated: o.negated === true,
arity: (o.arity ?? "many") as OpMeta["arity"],
};
}
}
}
function fieldMetaOK(f: WireField): boolean {
return (
typeof f.key === "string" &&
f.key !== "" &&
typeof f.label === "string" &&
typeof f.group === "string" &&
typeof f.valueKind === "string" &&
Array.isArray(f.ops) &&
typeof f.defaultOp === "string"
);
}
interface OpMeta {
key: string;
label: string;
@@ -23,40 +98,6 @@ interface OpMeta {
arity: "none" | "one" | "two" | "many";
}
const op = (key: string, label: string, negated: boolean, arity: OpMeta["arity"]): OpMeta => ({
key,
label,
negated,
arity,
});
// The map key DERIVES from meta.key — the wire value and the display
// label cannot drift apart again (a mis-keyed entry threw every
// non-self-named operator out of the allow-list: "doesn't contain" was
// being sent literally, and the server rightly refused it).
const OPS: Record<string, OpMeta> = Object.fromEntries(
[
op("contains", "contains", false, "many"),
op("not_contains", "doesn't contain", true, "many"),
op("is", "is", false, "many"),
op("is_not", "isn't", true, "many"),
op("empty", "is empty", false, "none"),
op("is_true", "is true", false, "none"),
op("is_false", "is false", false, "none"),
op("between", "between", false, "two"),
op("at_least", "at least", false, "one"),
op("at_most", "at most", false, "one"),
op("within_days", "within the last", false, "one"),
op("has", "has", false, "none"),
op("missing", "missing", false, "none"),
op("unrated", "unrated", false, "none"),
op("in", "in", false, "many"),
op("not_in", "isn't in", true, "many"),
op("in_any", "in any", false, "none"),
op("in_none", "in none", false, "none"),
].map((m) => [m.key, m]),
);
interface FieldMeta {
key: string;
label: string;
@@ -71,229 +112,6 @@ interface FieldMeta {
defaultOp: string;
}
const F = (
key: string,
label: string,
group: string,
valueKind: FieldMeta["valueKind"],
ops: string[],
defaultOp: string,
suggest = false,
options?: string[],
): FieldMeta => ({ key, label, group, valueKind, ops, defaultOp, suggest, options });
const TEXT_OPS = ["contains", "not_contains", "is", "is_not", "empty"];
const NUM_OPS = ["between", "at_least", "at_most", "empty"];
const DATE_OPS = ["within_days", "between", "empty"];
const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [
{
group: "People",
fields: [
F("author", "Author", "People", "text", TEXT_OPS, "contains", true),
F("narrator", "Narrator", "People", "text", TEXT_OPS, "contains", true),
F("contributor", "Contributor", "People", "text", TEXT_OPS, "contains", true),
],
},
{
group: "Publishing",
fields: [
F("publisher", "Publisher", "Publishing", "text", TEXT_OPS, "contains", true),
F("imprint", "Imprint", "Publishing", "text", TEXT_OPS, "contains", true),
F("edition", "Edition", "Publishing", "text", TEXT_OPS, "contains", true),
F("published_year", "Published year", "Publishing", "number", NUM_OPS, "at_least"),
],
},
{
group: "Topics",
fields: [
F("genre", "Genre", "Topics", "text", TEXT_OPS, "contains", true),
F("tags", "Tags", "Topics", "text", TEXT_OPS, "contains", true),
F("story_arc", "Story arc", "Topics", "text", TEXT_OPS, "contains", true),
F("age_rating", "Age rating", "Topics", "text", ["is", "is_not", "empty"], "is", true),
],
},
{
group: "Book facts",
fields: [
F("title", "Title", "Book facts", "text", TEXT_OPS, "contains", true),
F("subtitle", "Subtitle", "Book facts", "text", TEXT_OPS, "contains", true),
F("description", "Description", "Book facts", "text", TEXT_OPS, "contains"),
F("summary", "Summary", "Book facts", "none", ["has", "missing"], "has"),
F("language", "Language", "Book facts", "text", TEXT_OPS, "contains", true),
F("page_count", "Page count", "Book facts", "number", NUM_OPS, "at_least"),
F("chapter_count", "Chapter count", "Book facts", "number", NUM_OPS, "at_least"),
F("total_characters", "Total characters", "Book facts", "number", NUM_OPS, "at_least"),
F("community_rating", "Community rating", "Book facts", "number", NUM_OPS, "at_least"),
F("added_at", "Date added", "Book facts", "days", DATE_OPS, "within_days"),
F("imported_at", "Date imported", "Book facts", "days", DATE_OPS, "within_days"),
F("web_url", "Web URL", "Book facts", "none", ["has", "missing"], "has"),
F("metadata_notes", "Metadata notes", "Book facts", "none", ["has", "missing"], "has"),
F("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
F("asin", "ASIN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
F("file_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
F(
"file_availability",
"File availability",
"Book facts",
"fixed",
["is", "is_not"],
"is",
false,
["available", "missing"],
),
F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"),
F(
"manually_edited",
"Manually edited",
"Book facts",
"none",
["is_true", "is_false"],
"is_true",
),
F("added_by", "Added by", "Book facts", "id", ["is", "has", "missing"], "has", true),
],
},
{
group: "Comics & manga",
fields: [
F("series", "Series", "Comics & manga", "text", TEXT_OPS, "contains", true),
F("series_number", "Series number", "Comics & manga", "number", NUM_OPS, "at_least"),
F("series_count", "Series count", "Comics & manga", "number", NUM_OPS, "at_least"),
F("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true),
F("mime_type", "File type (mime)", "Comics & manga", "text", ["is", "is_not"], "is", true),
F(
"is_reflowable",
"Reflowable",
"Comics & manga",
"none",
["is_true", "is_false"],
"is_true",
),
F(
"has_fixed_layout",
"Fixed layout",
"Comics & manga",
"none",
["is_true", "is_false"],
"is_true",
),
F(
"is_black_and_white",
"Black & white",
"Comics & manga",
"none",
["is_true", "is_false"],
"is_true",
),
F(
"manga_type",
"Manga type",
"Comics & manga",
"text",
["is", "is_not", "empty"],
"is",
true,
),
F(
"reading_direction",
"Reading direction",
"Comics & manga",
"text",
["is", "is_not", "empty"],
"is",
true,
),
],
},
{
group: "My reading",
fields: [
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, [
"unread",
"reading",
"finished",
]),
F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"),
F("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
F(
"my_rating",
"My rating",
"My reading",
"fixed",
["is", "at_least", "at_most", "unrated"],
"at_least",
false,
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
),
F("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
F("highlights", "Highlights", "My reading", "none", ["has", "missing", "within_days"], "has"),
F(
"note_text",
"Note text",
"My reading",
"text",
["contains", "not_contains", "empty"],
"contains",
true,
),
F(
"highlight_text",
"Highlight text",
"My reading",
"text",
["contains", "not_contains", "empty"],
"contains",
true,
),
F(
"time_spent",
"Time spent (minutes)",
"My reading",
"number",
["between", "at_least", "at_most"],
"at_least",
),
F(
"pages_read",
"Pages read",
"My reading",
"number",
["between", "at_least", "at_most"],
"at_least",
),
F(
"collection",
"In collection",
"My reading",
"id",
["in", "not_in", "in_any", "in_none"],
"in",
true,
),
],
},
{
group: "Library",
fields: [F("library", "Library", "Library", "id", ["is", "is_not"], "is", true)],
},
];
// fieldByKey / fieldGroupsById — lookup helpers.
const FIELD_BY_KEY = new Map<string, FieldMeta>();
for (const g of FIELD_GROUPS) {
for (const f of g.fields) {
FIELD_BY_KEY.set(f.key, f);
}
}
// Grouped ops per field, precomputed in the field's op order.
const OPS_BY_FIELD = new Map<string, OpMeta[]>();
for (const [, f] of FIELD_BY_KEY) {
OPS_BY_FIELD.set(f.key, f.ops.map((k) => OPS[k]).filter(Boolean));
}
// Flat-expressible fields: key → the flat wire param a `contains`/`is`
// rule serializes to (the PINNED deep-link contract — semantics
// byte-identical to today's params).
@@ -409,7 +227,7 @@ export function pruneIncomplete(
if (pruned && pruned.children.length > 0) children.push(pruned);
return;
}
const arity = OPS[child.op]?.arity ?? "many";
const arity = wireOps[child.op]?.arity ?? "many";
const vals = (child.values ?? []).map((v) => (v ?? "").trim());
if (arity === "none") {
children.push({ field: child.field, op: child.op, values: [] });
@@ -530,6 +348,20 @@ interface Suggestion {
function filterBuilderData() {
return {
// Lifecycle: the Go registry renders this page's vocabulary (the
// data-filter-fields payload); ingest once so the menus are born
// scope-filtered and server-owned (item 3.3).
init() {
const raw = (this.$el as HTMLElement | null)?.dataset.filterFields;
if (raw && !FIELD_BY_KEY.size) {
try {
ingestFilterFields(JSON.parse(raw));
} catch {
// A malformed payload leaves empty menus — the server is
// source-of-truth; never invent rows client-side.
}
}
},
// Rule tree (source of truth; hidden wire inputs are synced after
// every mutation).
filterTree: { mode: "all", children: [] } as FilterGroupState,
@@ -545,7 +377,7 @@ function filterBuilderData() {
_countTimer: null as number | null,
get fieldGroups() {
return FIELD_GROUPS;
return wireFieldGroups;
},
get builderRows(): BuilderRow[] {
@@ -562,20 +394,20 @@ function filterBuilderData() {
return FIELD_BY_KEY.get(key)?.label ?? "Field";
},
opsFor(fieldKey: string): OpMeta[] {
return OPS_BY_FIELD.get(fieldKey) ?? [];
return (FIELD_BY_KEY.get(fieldKey)?.ops ?? []) as unknown as OpMeta[];
},
opMeta(opKey: string): OpMeta | undefined {
return OPS[opKey];
return wireOps[opKey];
},
opLabelOf(rule: FilterRuleState): string {
const m = OPS[rule.op];
const m = wireOps[rule.op];
return m ? m.label : rule.op;
},
isNegated(rule: FilterRuleState): boolean {
return OPS[rule.op]?.negated ?? false;
return wireOps[rule.op]?.negated ?? false;
},
valueArity(rule: FilterRuleState): OpMeta["arity"] {
return OPS[rule.op]?.arity ?? "many";
return wireOps[rule.op]?.arity ?? "many";
},
hasValueCell(rule: FilterRuleState): boolean {
const field = FIELD_BY_KEY.get(rule.field);
@@ -769,13 +601,15 @@ function filterBuilderData() {
// groups with no matches hide.
get visibleFieldGroups(): { group: string; fields: FieldMeta[] }[] {
const q = this.fieldFilter.trim().toLowerCase();
if (!q) return FIELD_GROUPS;
return FIELD_GROUPS.map((g) => ({
group: g.group,
fields: g.fields.filter(
(f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
),
})).filter((g) => g.fields.length > 0);
if (!q) return wireFieldGroups;
return wireFieldGroups
.map((g) => ({
group: g.group,
fields: g.fields.filter(
(f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
),
}))
.filter((g) => g.fields.length > 0);
},
// ── suggestions (field-aware, under the OTHER rules) ────────────