feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test.
This commit is contained in:
1 parent
ebb7f4a5b0
commit
4ee24e7152
18 files changed
+3788
-376
No files matched your search
+112
-213
@@ -2,26 +2,44 @@
|
||||
|
||||
import { Alpine } from "./alpine";
|
||||
import { showToast } from "./toast";
|
||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||
import type {
|
||||
FilterGroupState,
|
||||
FilterRuleState,
|
||||
} from "./filter_builder";
|
||||
|
||||
function clearFilters(): void {
|
||||
// Clear all form state (reuses helper)
|
||||
clearFormWithoutSubmit();
|
||||
|
||||
// Reset the rule builder tree (the builder's wire inputs are
|
||||
// serializer-managed and already dropped by cleanupDynamicHiddenInputs)
|
||||
builderData()?.clearBuilder();
|
||||
|
||||
// Trigger form submit with cleared filters
|
||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
}
|
||||
|
||||
// builderData returns the rule builder's Alpine data (nested x-data
|
||||
// inside the drawer) — the same DOM-lookup pattern the bookshelf
|
||||
// component uses.
|
||||
function builderData(): any | null {
|
||||
const el = document.querySelector('[x-data*="filterBuilderExtra"]');
|
||||
if (!el || !(el as any)._x_dataStack) return null;
|
||||
return (el as any)._x_dataStack[0] ?? null;
|
||||
}
|
||||
|
||||
// Remove dynamically created hidden inputs from filter form
|
||||
// Preserves: limit, offset, has_cover (static/managed inputs)
|
||||
// Preserves: limit, offset (static/managed inputs). The builder's wire
|
||||
// inputs (flat facet params + qf) are serializer-managed and dropped so
|
||||
// the next serialization starts clean.
|
||||
function cleanupDynamicHiddenInputs(): void {
|
||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
|
||||
// Names of inputs to preserve (static or Alpine-managed)
|
||||
const preserveNames = ["limit", "offset", "has_cover"];
|
||||
const preserveNames = ["limit", "offset"];
|
||||
|
||||
// Find all hidden inputs
|
||||
const hiddenInputs = filterForm.querySelectorAll('input[type="hidden"]');
|
||||
@@ -43,23 +61,6 @@ function clearFormWithoutSubmit(): void {
|
||||
// Reset all form fields to their HTML defaults
|
||||
filterForm.reset();
|
||||
|
||||
// Remove has_cover input entirely
|
||||
const hasCoverInput = document.querySelector(
|
||||
'[name="has_cover"]',
|
||||
) as HTMLInputElement;
|
||||
if (hasCoverInput) {
|
||||
hasCoverInput.remove();
|
||||
}
|
||||
|
||||
// Reset Alpine hasCoverState to null
|
||||
const bookshelfComponent = document.querySelector('[x-data*="bookshelf"]');
|
||||
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
|
||||
const data = (bookshelfComponent as any)._x_dataStack[0];
|
||||
if (data && typeof data.hasCoverState !== "undefined") {
|
||||
data.hasCoverState = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any hidden inputs from previous filter loads
|
||||
cleanupDynamicHiddenInputs();
|
||||
|
||||
@@ -81,12 +82,8 @@ Alpine.data("bookshelf", () => ({
|
||||
showSaveModal: false,
|
||||
showFiltersDropdown: false,
|
||||
filtersOpen: false,
|
||||
hasCoverState: null as boolean | null,
|
||||
dropdownAlign: "right" as "left" | "right",
|
||||
resizeTimeout: null as number | null,
|
||||
tagSuggestions: [] as TagSuggestion[],
|
||||
showTagDropdown: false,
|
||||
tagHighlightIndex: -1,
|
||||
// Active-filter chips (Batch B): Alpine renders them from this state;
|
||||
// refreshed by refreshFilterChips after every grid refetch.
|
||||
activeFilters: [] as FilterChip[],
|
||||
@@ -107,9 +104,12 @@ Alpine.data("bookshelf", () => ({
|
||||
"filter-form",
|
||||
) as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
if (param === "has_cover") {
|
||||
filterForm.querySelector('[name="has_cover"]')?.remove();
|
||||
this.hasCoverState = null;
|
||||
// Tree chips (qf mode): "rule:<path>" — remove that rule from the
|
||||
// builder tree, resync the wire inputs, re-apply.
|
||||
if (param.startsWith("rule:")) {
|
||||
const builder = builderData();
|
||||
const path = param.slice(5).split("-").map((n) => parseInt(n, 10));
|
||||
builder?.removeNode(path);
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
return;
|
||||
}
|
||||
@@ -172,18 +172,6 @@ Alpine.data("bookshelf", () => ({
|
||||
});
|
||||
});
|
||||
},
|
||||
// Cycle through has_cover states: null -> true -> false -> null
|
||||
cycleHasCover(): void {
|
||||
if (this.hasCoverState === null) {
|
||||
this.hasCoverState = true;
|
||||
} else if (this.hasCoverState === true) {
|
||||
this.hasCoverState = false;
|
||||
} else {
|
||||
this.hasCoverState = null;
|
||||
}
|
||||
// NOTE: We don't trigger form submission here
|
||||
// The user must click "Search" or press Enter to apply the filter
|
||||
},
|
||||
// Handle post-HTMX swap cleanup for saved filters
|
||||
afterFilterSave(): void {
|
||||
// Hide empty state if it exists
|
||||
@@ -257,32 +245,11 @@ Alpine.data("bookshelf", () => ({
|
||||
clearFormWithoutSubmit();
|
||||
|
||||
// Don't clear form - just update values
|
||||
// Populate form fields from filter data
|
||||
// Populate form fields from filter data. Every saved param lands
|
||||
// as a (hidden) form input — the builder's hydration reads them
|
||||
// (qf wins over flat, the pinned precedence).
|
||||
Object.entries(filterData).forEach(([key, value]) => {
|
||||
if (key === "has_cover") {
|
||||
const bookshelfComponent = document.querySelector(
|
||||
'[x-data*="bookshelf"]',
|
||||
);
|
||||
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
|
||||
const data = (bookshelfComponent as any)._x_dataStack[0];
|
||||
if (data && typeof data.hasCoverState !== "undefined") {
|
||||
// Convert string value to boolean|null
|
||||
if (value === "true") {
|
||||
data.hasCoverState = true;
|
||||
} else if (value === "false") {
|
||||
data.hasCoverState = false;
|
||||
} else {
|
||||
data.hasCoverState = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return; // Don't process has_cover like other fields
|
||||
}
|
||||
|
||||
if (!value) return; // Skip empty values
|
||||
// Special handling for has_cover - update Alpine state directly
|
||||
|
||||
// Handle all other fields normally
|
||||
const visibleField = filterForm.querySelector(
|
||||
`[name="${key}"]`,
|
||||
) as HTMLInputElement;
|
||||
@@ -302,6 +269,9 @@ Alpine.data("bookshelf", () => ({
|
||||
}
|
||||
});
|
||||
|
||||
// Rehydrate the rule-builder tree from the refilled form
|
||||
builderData()?.hydrateFromForm();
|
||||
|
||||
// Trigger HTMX to apply the filter by submitting the form
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
|
||||
@@ -349,139 +319,6 @@ Alpine.data("bookshelf", () => ({
|
||||
showToast("Error deleting filter", "error");
|
||||
}
|
||||
},
|
||||
|
||||
// Autocomplete helper function
|
||||
async fetchFieldValues(
|
||||
field: string,
|
||||
search: string,
|
||||
datalistId: string,
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.error("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
if (search.length < 2) return;
|
||||
|
||||
const currentLibraryId = (
|
||||
document.getElementById("library-select") as HTMLSelectElement
|
||||
)?.value;
|
||||
|
||||
const libParam = currentLibraryId ? `&library_id=${currentLibraryId}` : "";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/search?${field}=${encodeURIComponent(
|
||||
search,
|
||||
)}${libParam}&limit=50`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch field values");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update datalist via DOM manipulation
|
||||
const datalist = document.getElementById(datalistId);
|
||||
if (!datalist) {
|
||||
console.error(`Datalist ${datalistId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing options
|
||||
datalist.innerHTML = "";
|
||||
|
||||
// Add new options
|
||||
data.results.forEach((item: { value: string; count: number }) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.value;
|
||||
option.textContent = `${item.value} (${item.count})`;
|
||||
datalist.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching field values:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch author values for autocomplete
|
||||
async fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("author", input.value, "author-datalist");
|
||||
},
|
||||
|
||||
// Fetch tag values for autocomplete (custom dropdown)
|
||||
async fetchTagValues(input: HTMLInputElement): Promise<void> {
|
||||
const libraryId = (document.getElementById("library-select") as HTMLSelectElement)?.value;
|
||||
if (!libraryId || input.value.length < 2) {
|
||||
this.tagSuggestions = [];
|
||||
this.showTagDropdown = false;
|
||||
return;
|
||||
}
|
||||
const results = await searchTagSuggestions(input.value, libraryId);
|
||||
this.tagSuggestions = results;
|
||||
this.showTagDropdown = results.length > 0;
|
||||
this.tagHighlightIndex = -1;
|
||||
},
|
||||
|
||||
selectTagSuggestion(tag: string) {
|
||||
const input = document.querySelector('input[name="tags_filter"]') as HTMLInputElement;
|
||||
if (input) input.value = tag;
|
||||
this.showTagDropdown = false;
|
||||
this.tagSuggestions = [];
|
||||
this.tagHighlightIndex = -1;
|
||||
},
|
||||
|
||||
hideTagDropdown() {
|
||||
setTimeout(() => { this.showTagDropdown = false; this.tagHighlightIndex = -1; }, 200);
|
||||
},
|
||||
|
||||
onTagFilterKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
||||
this.tagHighlightIndex = (this.tagHighlightIndex + 1) % this.tagSuggestions.length;
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
||||
this.tagHighlightIndex = this.tagHighlightIndex <= 0
|
||||
? this.tagSuggestions.length - 1
|
||||
: this.tagHighlightIndex - 1;
|
||||
}
|
||||
} else if (event.key === "Enter" && this.tagHighlightIndex >= 0 && this.showTagDropdown) {
|
||||
event.preventDefault();
|
||||
this.selectTagSuggestion(this.tagSuggestions[this.tagHighlightIndex].value);
|
||||
} else if (event.key === "Escape") {
|
||||
this.showTagDropdown = false;
|
||||
this.tagSuggestions = [];
|
||||
this.tagHighlightIndex = -1;
|
||||
}
|
||||
},
|
||||
|
||||
// // Fetch genre values for autocomplete
|
||||
// async fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
||||
// await this.fetchFieldValues("genre", input.value, "genre-datalist");
|
||||
// },
|
||||
|
||||
// Fetch series values for autocomplete
|
||||
async fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("series", input.value, "series-datalist");
|
||||
},
|
||||
|
||||
// Fetch narrator values for autocomplete (A2a)
|
||||
async fetchNarratorValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("narrator", input.value, "narrator-datalist");
|
||||
},
|
||||
|
||||
// Fetch language values for autocomplete
|
||||
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("language", input.value, "language-datalist");
|
||||
},
|
||||
}));
|
||||
|
||||
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
|
||||
@@ -494,6 +331,26 @@ Alpine.data("bookshelf", () => ({
|
||||
function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
if (!filterForm) return false;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Rule-builder trees ride the qf param (faceted plan Phase 9).
|
||||
// Precedence (pinned): when qf is present the flat params are
|
||||
// ignored — the tree IS the query.
|
||||
const qf = params.get("qf");
|
||||
if (qf) {
|
||||
const input = filterForm.querySelector(
|
||||
'[name="qf"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.value = qf;
|
||||
} else {
|
||||
const hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = "qf";
|
||||
hidden.value = qf;
|
||||
filterForm.appendChild(hidden);
|
||||
}
|
||||
builderData()?.hydrateFromForm();
|
||||
return true;
|
||||
}
|
||||
const filterNames = [
|
||||
"author_filter",
|
||||
"series_filter",
|
||||
@@ -513,6 +370,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
"abridged_filter",
|
||||
"year_min",
|
||||
"year_max",
|
||||
"qf",
|
||||
];
|
||||
let applied = false;
|
||||
for (const name of filterNames) {
|
||||
@@ -545,6 +403,8 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
interface FilterChip {
|
||||
param: string;
|
||||
label: string;
|
||||
// Tree chips only: negated operators render red.
|
||||
negated: boolean;
|
||||
}
|
||||
|
||||
// prettifyGenreLabel ports templates.prettifyGenre (Batch B transform):
|
||||
@@ -610,23 +470,55 @@ function refreshFilterChips(): void {
|
||||
if (typeof data.activeFilters === "undefined") return;
|
||||
|
||||
const chips: FilterChip[] = [];
|
||||
filterForm
|
||||
.querySelectorAll<HTMLInputElement>(
|
||||
'[name$="_filter"], [name="year_min"], [name="year_max"]',
|
||||
)
|
||||
.forEach((input) => {
|
||||
if (input.value) {
|
||||
chips.push({
|
||||
param: input.name,
|
||||
label: facetChipLabel(input.name, input.value),
|
||||
});
|
||||
}
|
||||
});
|
||||
const hasCoverInput = filterForm.querySelector(
|
||||
'[name="has_cover"]',
|
||||
const qfInput = filterForm.querySelector(
|
||||
'[name="qf"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (hasCoverInput && hasCoverInput.value) {
|
||||
chips.push({ param: "has_cover", label: facetChipLabel("has_cover", hasCoverInput.value) });
|
||||
|
||||
if (qfInput && qfInput.value) {
|
||||
// Tree mode: one chip per rule (red for negations — the chips
|
||||
// template styles them via chip.negated). Values that are IDs
|
||||
// (collection/library) label through the builder's id→name cache.
|
||||
const builder = builderData();
|
||||
try {
|
||||
const tree = JSON.parse(qfInput.value) as FilterGroupState;
|
||||
const walk = (group: FilterGroupState, path: number[]) => {
|
||||
(group.children as (FilterRuleState | FilterGroupState)[]).forEach(
|
||||
(child, idx) => {
|
||||
const childPath = [...path, idx];
|
||||
if ("children" in (child as FilterGroupState)) {
|
||||
walk(child as FilterGroupState, childPath);
|
||||
return;
|
||||
}
|
||||
const rule = child as FilterRuleState;
|
||||
const label = builder
|
||||
? builder.chipLabelFor(rule)
|
||||
: `${rule.field} ${rule.op}`;
|
||||
chips.push({
|
||||
param: "rule:" + childPath.join("-"),
|
||||
label,
|
||||
negated: builder?.isNegated(rule) ?? false,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
walk(tree, []);
|
||||
} catch {
|
||||
// malformed qf — no tree chips
|
||||
}
|
||||
} else {
|
||||
filterForm
|
||||
.querySelectorAll<HTMLInputElement>(
|
||||
'[name$="_filter"], [name="year_min"], [name="year_max"], [name="has_cover"]',
|
||||
)
|
||||
.forEach((input) => {
|
||||
if (input.value) {
|
||||
chips.push({
|
||||
param: input.name,
|
||||
label: facetChipLabel(input.name, input.value),
|
||||
negated: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
data.activeFilters = chips;
|
||||
@@ -640,7 +532,7 @@ function refreshFilterChips(): void {
|
||||
"imprint_filter", "edition_filter", "age_rating_filter",
|
||||
"format_group_filter", "manga_type_filter", "reading_direction_filter",
|
||||
"explicit_filter", "abridged_filter", "year_min", "year_max",
|
||||
"has_cover", "library_id",
|
||||
"has_cover", "library_id", "qf",
|
||||
]);
|
||||
const next = new URLSearchParams();
|
||||
url.searchParams.forEach((value, key) => {
|
||||
@@ -674,8 +566,15 @@ function initBookshelf(): void {
|
||||
// pre-fills the form and applies it to the first grid load. Author
|
||||
// links on the detail page (and anything else) can share filtered
|
||||
// views; a comma-joined value is a multi-select (server splits it).
|
||||
// qf deep links rehydrate as the rule tree (qf wins over flat — the
|
||||
// pinned precedence).
|
||||
const urlApplied = applyURLFilters(filterForm);
|
||||
|
||||
// The builder's tree mirrors the applied wire state (URL or empty)
|
||||
// so the drawer opens with the current rules; the wire inputs are
|
||||
// then serializer-owned.
|
||||
builderData()?.hydrateFromForm();
|
||||
|
||||
// Check if books were already rendered server-side
|
||||
const booksGrid = document.getElementById("books-grid");
|
||||
const hasServerBooks =
|
||||
|
||||
Reference in new issue
Block a user