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. -->
diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go
index 97bbeb94..32e67a4b 100644
--- a/templates/bookshelf_templ.go
+++ b/templates/bookshelf_templ.go
@@ -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, "
0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\">
0\">
0\" x-cloak>
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\">
0\">
0\" x-cloak>
")
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, "
")
- 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, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "Save Filter
Save Filter