426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
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;
|
|
window.location.href = `/collections/${collectionId}`;
|
|
}
|
|
|
|
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
|
|
// ============================================================
|
|
|
|
export {
|
|
backToCollection,
|
|
clearForm,
|
|
deleteRule,
|
|
getFieldLabel,
|
|
getOperatorLabel,
|
|
handleCreateRule,
|
|
initCollectionRules,
|
|
loadRules,
|
|
logout,
|
|
renderRules,
|
|
setupEventDelegation,
|
|
testRule,
|
|
toggleRule,
|
|
};
|
|
|
|
Alpine.store("collectionRules", {
|
|
backToCollection,
|
|
clearForm,
|
|
deleteRule,
|
|
getFieldLabel,
|
|
getOperatorLabel,
|
|
handleCreateRule,
|
|
initCollectionRules,
|
|
loadRules,
|
|
logout,
|
|
renderRules,
|
|
setupEventDelegation,
|
|
testRule,
|
|
toggleRule,
|
|
});
|