feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)

The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.

Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.

The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.

Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:22:17 -04:00
1 parent eb68672390
commit 4f71eda6be
16 files changed
+518 -1422

No files matched your search

+43 -435
View File
@@ -1,451 +1,59 @@
// Collection smart editor (item 3.4): the page hosts the SHARED
// filterBuilderExtra island (one vocabulary, one engine — the Go
// registry's payload; the builder's Apply delegates here through the
// collectionTreeApply store hook). This module owns only the
// collection-specific write: PUT the tree; the qf engine drives
// membership everywhere. The legacy per-rule page (rule CRUD,
// test-rules preview, match-mode toggle) is retired — legacy rules
// arrays preload as their CONVERTED tree (RulesToFilterTree, rendered
// server-side) and upgrade at the first Apply.
import { Alpine } from "./alpine";
import { getSelectedLibrary } from "./storage";
import { showToast } from "./toast";
// ============================================================
// Collection Rules Page - Auto-Assign Rule Management
// ============================================================
let collectionId = "";
let matchMode: "any" | "all" = "any";
function initCollectionRules(id: string, mode: string): void {
function initCollectionRules(id: string, _scope: string, _treeJSON: string): void {
collectionId = id;
if (mode === "all") matchMode = "all";
loadRules();
setupEventDelegation();
}
// Match semantics (plan item 2.4): flips the collection's
// rule_match_mode on its own endpoint — writes exactly that column.
async function setMatchMode(mode: "any" | "all"): Promise<void> {
if (mode === matchMode) return;
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
try {
const response = await fetch(`/api/collections/${collectionId}/match-mode`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ mode }),
});
if (!response.ok) {
showToast("Failed to change match mode", "error");
return;
}
matchMode = mode;
// The reactive :class and the ANY/ALL copy update via the store-free
// Alpine properties below; reload not needed.
document.querySelectorAll<HTMLElement>(".match-mode-btn").forEach((btn) => {
const isAny = btn.textContent?.trim().startsWith("ANY");
btn.classList.toggle(
"btn-secondary",
(isAny && mode === "any") || (!isAny && mode === "all"),
);
btn.classList.toggle("btn-ghost", !((isAny && mode === "any") || (!isAny && mode === "all")));
});
showToast(
mode === "all" ? "Rules now must ALL match (and)" : "Rules match when ANY passes (or)",
"success",
);
} catch {
showToast("Failed to change match mode", "error");
}
}
function setupEventDelegation(): void {
const container = document.getElementById("rules-container");
if (!container) return;
container.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const button = target.closest("button") as HTMLButtonElement;
if (!button) return;
const action = button.dataset.action;
const ruleId = button.dataset.ruleId;
if (action === "toggle" && ruleId) {
const enabled = button.dataset.enabled === "true";
toggleRule(ruleId, enabled);
} else if (action === "delete" && ruleId) {
deleteRule(ruleId);
}
// The builder island emits its APPLY wire through this hook — the
// page (not the shared component) owns the collection write.
window.Alpine?.store("collectionTreeApply", {
hook: async (wire: string) => {
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
try {
const response = await fetch(`/api/collections/${collectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: pageCollectionName(), filter_tree: JSON.parse(wire) }),
});
if (response.ok) {
showToast("Smart collection updated", "success");
window.location.reload();
} else {
const body = await response.json().catch(() => ({}));
showToast(body.error || "Failed to update the collection", "error");
}
} catch {
showToast("Failed to update the collection", "error");
}
},
});
}
function pageCollectionName(): string {
return document.title.split(" - ")[0] || "Collection";
}
function backToCollection(): void {
if (!collectionId) return;
const libraryId = getSelectedLibrary();
const url = libraryId
? `/collections/${collectionId}?library_id=${libraryId}`
: `/collections/${collectionId}`;
window.location.href = url;
const path = window.location.pathname; // /collections/:id/rules
window.location.href = path.replace(/\/rules$/, "");
}
function getFieldLabel(field: string): string {
const labels: Record<string, string> = {
genre: "Genre",
series: "Series",
author: "Author",
language: "Language",
publisher: "Publisher",
copyright_year: "Copyright Year",
tags: "Tags",
};
return labels[field] || field;
}
function getOperatorLabel(operator: string): string {
const labels: Record<string, string> = {
equals: "equals",
not_equals: "does not equal",
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
greater_than: "greater than",
less_than: "less than",
};
return labels[operator] || operator;
}
async function loadRules(): Promise<void> {
const token = localStorage.getItem("token");
if (!token || !collectionId) return;
try {
const response = await fetch(`/api/collections/${collectionId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
renderRules(data.auto_assign_rules || []);
}
} catch (error) {
console.error("Failed to load rules", error);
}
}
function renderRules(rules: CollectionRule[]): void {
const container = document.getElementById("rules-container");
const noRulesDiv = document.getElementById("no-rules");
if (!container || !noRulesDiv) return;
if (rules && rules.length > 0) {
noRulesDiv.classList.add("hidden");
container.innerHTML = rules
.map(
(rule) => `
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<span class="font-semibold" style="color: var(--text-primary)">
${getFieldLabel(rule.field)}
</span>
<span class="px-2 py-1 text-xs rounded" style="background-color: ${rule.enabled ? "var(--accent)" : "var(--text-secondary)"}; color: var(--bg-primary);">
${rule.enabled ? "Enabled" : "Disabled"}
</span>
<span class="px-2 py-1 text-xs rounded" style="background-color: var(--bg-secondary); color: var(--text-primary);">
Priority ${rule.priority}
</span>
</div>
<code class="block text-sm" style="color: var(--text-secondary);">
${getOperatorLabel(rule.operator)} "${rule.value}"
</code>
</div>
<div class="flex space-x-2">
<button data-action="toggle" data-rule-id="${rule.id}" data-enabled="${!rule.enabled}"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
${rule.enabled ? "⏸️" : "▶️"}
</button>
<button data-action="delete" data-rule-id="${rule.id}"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
🗑️
</button>
</div>
</div>
</div>
`,
)
.join("");
} else {
noRulesDiv.classList.remove("hidden");
}
}
async function handleCreateRule(event: Event): Promise<void> {
event.preventDefault();
const token = localStorage.getItem("token");
if (!token || !collectionId) return;
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
const priorityInput = document.querySelector(
'input[name="priority"]:checked',
) as HTMLInputElement;
if (!fieldInput || !operatorInput || !valueInput || !enabledInput || !priorityInput) {
showToast("Missing form fields", "error");
return;
}
const data = {
field: fieldInput.value,
operator: operatorInput.value,
value: valueInput.value,
enabled: enabledInput.checked,
priority: parseInt(priorityInput.value, 10),
};
try {
const response = await fetch(`/api/collections/${collectionId}/rules`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (response.ok) {
showToast("Rule created successfully", "success");
clearForm();
loadRules();
} else {
showToast("Failed to create rule", "error");
}
} catch (error) {
console.error("Failed to create rule", error);
showToast("Failed to create rule", "error");
}
}
async function testRule(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
if (!fieldInput || !operatorInput || !valueInput) {
showToast("Missing form fields", "error");
return;
}
const field = fieldInput.value;
const operator = operatorInput.value;
const value = valueInput.value;
if (!field || !operator || !value) {
showToast("Please fill in all rule fields", "error");
return;
}
const testResultsDiv = document.getElementById("test-results");
const resultsList = document.getElementById("test-results-list");
if (!testResultsDiv || !resultsList) return;
testResultsDiv.classList.remove("hidden");
resultsList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Testing rule...</p>';
const rules = [
{
field: field,
operator: operator,
value: value,
},
];
try {
const response = await fetch("/api/collections/test-rules", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ rules: rules }),
});
if (response.ok) {
const result = await response.json();
if (result.matches && result.matches.length > 0) {
let html =
'<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' +
result.matches.length +
" matching books:</p>";
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
result.matches.slice(0, 20).forEach((book: TestRuleMatch) => {
html +=
'<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: 0.25rem; background-color: var(--bg-secondary);">';
html +=
'<img src="' +
(book.cover_image_path || "/static/placeholder-book.svg") +
'" alt="Cover" style="width: 2rem; height: 3rem; object-fit: cover; border-radius: 0.25rem;">';
html += '<div style="flex: 1;">';
html +=
'<div style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' +
book.title +
"</div>";
if (book.author) {
html +=
'<div style="font-size: 0.75rem; color: var(--text-secondary);">' +
book.author +
"</div>";
}
if (book.match_reason) {
html +=
'<div style="font-size: 0.75rem; color: var(--accent);">' +
book.match_reason +
"</div>";
}
html += "</div>";
html += "</div>";
});
if (result.matches.length > 20) {
html +=
'<p style="color: var(--text-secondary); font-size: 0.875rem;">...and ' +
(result.matches.length - 20) +
" more</p>";
}
html += "</div>";
resultsList.innerHTML = html;
} else {
resultsList.innerHTML =
'<p style="color: var(--text-secondary); font-size: 0.875rem;">No books match this rule</p>';
}
} else {
resultsList.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to test rule</p>';
}
} catch (error) {
console.error("Failed to test rule", error);
resultsList.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to test rule</p>';
}
}
async function toggleRule(ruleId: string, enabled: boolean): Promise<void> {
const token = localStorage.getItem("token");
if (!token || !collectionId) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
showToast("Rule updated", "success");
loadRules();
} else {
showToast("Failed to update rule", "error");
}
} catch (error) {
console.error("Failed to update rule", error);
showToast("Failed to update rule", "error");
}
}
async function deleteRule(ruleId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this rule?")) return;
const token = localStorage.getItem("token");
if (!token || !collectionId) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Rule deleted", "success");
loadRules();
} else {
showToast("Failed to delete rule", "error");
}
} catch (error) {
console.error("Failed to delete rule", error);
showToast("Failed to delete rule", "error");
}
}
function clearForm(): void {
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
const priorityInput = document.querySelector(
'input[name="priority"][value="2"]',
) as HTMLInputElement;
const testResultsDiv = document.getElementById("test-results");
if (fieldInput) fieldInput.value = "";
if (operatorInput) operatorInput.value = "";
if (valueInput) valueInput.value = "";
if (enabledInput) enabledInput.checked = true;
if (priorityInput) priorityInput.checked = true;
if (testResultsDiv) testResultsDiv.classList.add("hidden");
}
// Rules-page sign-out: same contract as the header's — the API clears
// the HttpOnly session cookie and revokes the refresh token; local
// cleanup and redirect are best-effort.
async function logout(): Promise<void> {
try {
await fetch("/api/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: localStorage.getItem("refresh_token") ?? "" }),
});
} catch {
// redirect regardless
}
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
}
// ============================================================
// Exports
// ============================================================
Alpine.data("collectionRules", () => ({
backToCollection,
clearForm,
deleteRule,
getFieldLabel,
getOperatorLabel,
handleCreateRule,
initCollectionRules,
loadRules,
logout,
setMatchMode,
get matchMode() {
return matchMode;
},
renderRules,
setupEventDelegation,
testRule,
toggleRule,
}));
-141
View File
@@ -47,139 +47,6 @@ function initializeCollectionWebSocket(): void {
});
}
async function loadCollectionRules(collectionId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const rules: CollectionRule[] = await response.json();
renderRules(rules);
}
} catch (error) {
console.error("Failed to load rules:", error);
}
}
function renderRules(rules: CollectionRule[]): void {
const container = document.getElementById("rules-list");
if (!container) return;
if (rules.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
return;
}
container.innerHTML = rules
.map(
(rule) => `
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
style="background-color: var(--bg-secondary); border-color: var(--border)">
<div>
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}</p>
</div>
<div class="flex space-x-2">
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
</div>
</div>
`,
)
.join("");
}
async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(rule),
});
if (response.ok) {
showToast("Rule created", "success");
loadCollectionRules(collectionId);
} else {
const error = await response.json();
showToast(error.error || "Failed to create rule", "error");
}
} catch (error) {
console.error("Failed to create rule:", error);
showToast("Failed to create rule", "error");
}
}
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (!confirm("Are you sure you want to delete this rule?")) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Rule deleted", "success");
loadCollectionRules(collectionId);
}
} catch (error) {
console.error("Failed to delete rule:", error);
showToast("Failed to delete rule", "error");
}
}
async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules/test`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(rule),
});
if (response.ok) {
const results = await response.json();
renderTestResults(results);
}
} catch (error) {
console.error("Failed to test rule:", error);
showToast("Failed to test rule", "error");
}
}
function renderTestResults(results: unknown[]): void {
const container = document.getElementById("test-results");
if (!container) return;
if (!results || (Array.isArray(results) && results.length === 0)) {
container.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
return;
}
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
}
function setupHTMXAuth(): void {
document.body.addEventListener("htmx:configRequest", function (evt: Event) {
const token = localStorage.getItem("token");
@@ -476,14 +343,11 @@ function filterCollectionBooks(): void {
export {
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
initColorSelection,
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
navigateToCollection,
populateIconGrid,
selectColor,
@@ -491,21 +355,17 @@ export {
setupHTMXAuth,
setupHTMXModalInit,
showAllIcons,
testRule,
toggleSelection,
moveCollectionBook,
};
Alpine.data("collections", () => ({
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
initColorSelection,
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
moveCollectionBook,
navigateToCollection,
populateIconGrid,
@@ -514,7 +374,6 @@ Alpine.data("collections", () => ({
selectedBooks: [] as string[],
setupHTMXModalInit,
showAllIcons,
testRule,
toggleSelection,
showConfirm: false,
+53 -3
View File
@@ -351,8 +351,10 @@ function filterBuilderData() {
// 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).
scopeKey: "books" as string,
init() {
const raw = (this.$el as HTMLElement | null)?.dataset.filterFields;
const el = this.$el as HTMLElement | null;
const raw = el?.dataset.filterFields;
if (raw && !FIELD_BY_KEY.size) {
try {
ingestFilterFields(JSON.parse(raw));
@@ -361,6 +363,40 @@ function filterBuilderData() {
// source-of-truth; never invent rows client-side.
}
}
// Collection mounts (item 3.4): the page carries the collection's
// scope (row universe for counts/apply) and its PREFILL tree (the
// stored tree, or the legacy rules' conversion).
if (el?.dataset.scope) {
this.scopeKey = el.dataset.scope;
}
const treeRaw = el?.dataset.tree;
if (treeRaw) {
try {
const t = JSON.parse(treeRaw) as FilterGroupState;
if (t && Array.isArray(t.children)) {
this.filterTree = { mode: t.mode === "any" ? "any" : "all", children: t.children };
}
} catch {
// no prefill — start empty
}
}
},
// treeJSON is the APPLY wire for collection mounts: the page's
// scope rides the tree's top-level key (the engine's scope head).
treeJSON(): string {
return JSON.stringify({
scope: this.scopeKey,
mode: this.filterTree.mode,
children: this.filterTree.children,
});
},
// applyCollectionTree delegates to the page-registered hook (the
// collection editor PUTs the tree; All-Books never sets one).
applyCollectionTree(): void {
const hook = (
window.Alpine?.store("collectionTreeApply") as { hook?: (wire: string) => void } | undefined
)?.hook;
if (hook) hook(this.treeJSON());
},
// Rule tree (source of truth; hidden wire inputs are synced after
// every mutation).
@@ -422,7 +458,13 @@ function filterBuilderData() {
},
ensureReadyRule(): void {
if (this.filterTree.children.length === 0) {
this.filterTree.children.push({ field: "author", op: "contains", values: [] });
const first = wireFieldGroups[0]?.fields[0]?.key ?? "author";
const fm = FIELD_BY_KEY.get(first);
this.filterTree.children.push({
field: first,
op: fm?.defaultOp ?? "contains",
values: [],
});
this._touch();
}
},
@@ -712,7 +754,8 @@ function filterBuilderData() {
return;
}
const params = new URLSearchParams();
params.set("qf", JSON.stringify(completed));
const scoped = this.scopedTree();
if (scoped) params.set("qf", JSON.stringify(scoped));
const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement)
?.value;
if (currentLibraryId) params.set("library_id", currentLibraryId);
@@ -776,6 +819,13 @@ function filterBuilderData() {
completedTree(excludePath: number[] | null): FilterGroupState | null {
return pruneIncomplete(this.filterTree as FilterGroupState, excludePath);
},
// scopedTree is the WIRE tree: the mount's scope rides the top
// level (the engine's scope head — counts and applies carry it).
scopedTree(): Record<string, unknown> | null {
const completed = this.completedTree(null);
if (!completed) return null;
return { scope: this.scopeKey, mode: completed.mode, children: completed.children };
},
syncFormInputs(): void {
const form = document.getElementById("filter-form") as HTMLFormElement | null;
if (!form) return;