Files
bookhoard/web/src/collection-rules.ts
T
John O'Keefe 8631aa00bf refactor: remove dead code across server, templ pages, and client modules
Deadcode-verified removals (RTA reachability from main, cross-checked
with -test and textual xrefs):

Go server:
- whole files: middleware/transaction.go, middleware/error_handler.go,
  middleware/password_validator.go (live registration machinery kept),
  handlers/sync.go, sync/offline.go
- unrouted endpoints: auth UpdateUsername/UpdateEmail, timezone
  settings handler, OPDS RegisterOPDS, Kobo LibrarySync handler,
  ReaderHandler.ShowReader, JSON-data twins on collections/library
- duplicate book-matching handlers (book_matching.go Handler twins,
  matching.go MatchingHandler twins), superseded helpers
  (router.parseUUID, handlers.ValidateDeviceToken, normalizePath,
  userExists, generateLockID)

Templ/client:
- never-routed pages: api_explorer.templ, queue.templ and their
  client modules api-explorer.ts, api-explorer-docs.ts, queue.ts
- fully orphaned wrapper modules events.ts, dom.ts
- broken clearSyncQueue (called a nonexistent endpoint) and the
  hidden, never-populated sync-queue section on the devices page
- 166+ unused exports demoted to private (knip-verified); ~85 truly
  dead declarations removed via tsc noUnusedLocals fixpoint
- stale 'interactive API Explorer' claims dropped from docs

The docs drift-gate package (internal/docs), templ-bound modules
(dashboard, stats, header, index, login, profile*, bookshelf,
collections, toast-error), sqlc-generated code, and web/static are
intentionally untouched.
2026-10-25 11:06:08 -04:00

393 lines
12 KiB
TypeScript

import { Alpine } from "./alpine";
import { getSelectedLibrary } from "./storage";
import { showToast } from "./toast";
// ============================================================
// Collection Rules Page - Auto-Assign Rule Management
// ============================================================
let collectionId = "";
function initCollectionRules(id: string): void {
collectionId = id;
loadRules();
setupEventDelegation();
}
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);
}
});
}
function backToCollection(): void {
if (!collectionId) return;
const libraryId = getSelectedLibrary();
const url = libraryId
? `/collections/${collectionId}?library_id=${libraryId}`
: `/collections/${collectionId}`;
window.location.href = url;
}
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");
}
function logout(): void {
localStorage.removeItem("token");
window.location.href = "/login";
}
// ============================================================
// Exports
// ============================================================
Alpine.data("collectionRules", () => ({
backToCollection,
clearForm,
deleteRule,
getFieldLabel,
getOperatorLabel,
handleCreateRule,
initCollectionRules,
loadRules,
logout,
renderRules,
setupEventDelegation,
testRule,
toggleRule,
}));