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

+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) ────────────