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:
1 parent
6b2125a45a
commit
eb68672390
5 files changed
+345
-341
No files matched your search
@@ -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
@@ -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) ────────────
|
||||
|
||||
Reference in new issue
Block a user