Since main.js has 'defer' attribute, the DOM is guaranteed to be ready when modules execute. These wrappers are unnecessary. dashboard.ts: - Removed DOMContentLoaded wrapper, code runs directly - Event delegation setup runs immediately custom-section-builder.ts: - Removed DOMContentLoaded wrapper - initCustomSectionBuilder() called directly toast.ts: - Removed DOMContentLoaded wrapper - initializeToastSystem() called directly at top level - Removed dead Alpine.data registration (unused) search.ts: - Removed DOMContentLoaded wrapper - initializeSearch exported for use in header theme.ts: - Removed DOMContentLoaded wrapper - Functions now exported for use in header Alpine component
657 lines
20 KiB
TypeScript
657 lines
20 KiB
TypeScript
import { apiPost } from "./api";
|
||
import { showToast } from "./toast";
|
||
|
||
interface FilterField {
|
||
id: string;
|
||
label: string;
|
||
operators: Operator[];
|
||
valueType: "text" | "number" | "date" | "select" | "multiselect";
|
||
options?: string[];
|
||
}
|
||
|
||
interface Operator {
|
||
id: string;
|
||
label: string;
|
||
requiresValue: boolean;
|
||
}
|
||
|
||
interface FilterRule {
|
||
id: string;
|
||
field: string;
|
||
operator: string;
|
||
value: string | string[];
|
||
priority: number;
|
||
}
|
||
|
||
const FILTER_FIELDS: FilterField[] = [
|
||
{
|
||
id: "title",
|
||
label: "Title",
|
||
operators: [
|
||
{ id: "contains", label: "Contains", requiresValue: true },
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "starts_with", label: "Starts With", requiresValue: true },
|
||
{ id: "ends_with", label: "Ends With", requiresValue: true },
|
||
{ id: "regex", label: "Matches Regex", requiresValue: true },
|
||
],
|
||
valueType: "text",
|
||
},
|
||
{
|
||
id: "author",
|
||
label: "Author",
|
||
operators: [
|
||
{ id: "contains", label: "Contains", requiresValue: true },
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
],
|
||
valueType: "text",
|
||
},
|
||
{
|
||
id: "genre",
|
||
label: "Genre",
|
||
operators: [
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||
{ id: "in", label: "In", requiresValue: true },
|
||
{ id: "not_in", label: "Not In", requiresValue: true },
|
||
],
|
||
valueType: "select",
|
||
options: [
|
||
"Fiction",
|
||
"Non-Fiction",
|
||
"Sci-Fi",
|
||
"Fantasy",
|
||
"Mystery",
|
||
"Romance",
|
||
"Thriller",
|
||
"Biography",
|
||
"History",
|
||
"Self-Help",
|
||
],
|
||
},
|
||
{
|
||
id: "series",
|
||
label: "Series",
|
||
operators: [
|
||
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "contains", label: "Contains", requiresValue: true },
|
||
],
|
||
valueType: "text",
|
||
},
|
||
{
|
||
id: "progress",
|
||
label: "Reading Progress",
|
||
operators: [
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||
{ id: "greater_than", label: "Greater Than", requiresValue: true },
|
||
{ id: "less_than", label: "Less Than", requiresValue: true },
|
||
{ id: "between", label: "Between", requiresValue: true },
|
||
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||
],
|
||
valueType: "number",
|
||
},
|
||
{
|
||
id: "rating",
|
||
label: "Rating",
|
||
operators: [
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||
{ id: "greater_than", label: "Greater Than", requiresValue: true },
|
||
{ id: "less_than", label: "Less Than", requiresValue: true },
|
||
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||
],
|
||
valueType: "number",
|
||
},
|
||
{
|
||
id: "date_added",
|
||
label: "Date Added",
|
||
operators: [
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||
{ id: "before", label: "Before", requiresValue: true },
|
||
{ id: "after", label: "After", requiresValue: true },
|
||
{ id: "between", label: "Between", requiresValue: true },
|
||
{ id: "last_x_days", label: "Last X Days", requiresValue: true },
|
||
],
|
||
valueType: "date",
|
||
},
|
||
{
|
||
id: "last_read",
|
||
label: "Last Read Date",
|
||
operators: [
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "before", label: "Before", requiresValue: true },
|
||
{ id: "after", label: "After", requiresValue: true },
|
||
{ id: "between", label: "Between", requiresValue: true },
|
||
{ id: "last_x_days", label: "Last X Days", requiresValue: true },
|
||
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||
],
|
||
valueType: "date",
|
||
},
|
||
{
|
||
id: "publisher",
|
||
label: "Publisher",
|
||
operators: [
|
||
{ id: "contains", label: "Contains", requiresValue: true },
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
],
|
||
valueType: "text",
|
||
},
|
||
{
|
||
id: "language",
|
||
label: "Language",
|
||
operators: [
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||
{ id: "in", label: "In", requiresValue: true },
|
||
],
|
||
valueType: "select",
|
||
options: [
|
||
"English",
|
||
"Spanish",
|
||
"French",
|
||
"German",
|
||
"Japanese",
|
||
"Chinese",
|
||
"Russian",
|
||
"Other",
|
||
],
|
||
},
|
||
{
|
||
id: "format",
|
||
label: "Format",
|
||
operators: [
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "in", label: "In", requiresValue: true },
|
||
],
|
||
valueType: "select",
|
||
options: ["Ebook", "Audiobook", "Comic", "Manga", "Magazine"],
|
||
},
|
||
{
|
||
id: "tags",
|
||
label: "Tags",
|
||
operators: [
|
||
{ id: "contains", label: "Contains", requiresValue: true },
|
||
{ id: "not_contains", label: "Does Not Contain", requiresValue: true },
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
],
|
||
valueType: "text",
|
||
},
|
||
{
|
||
id: "narrators",
|
||
label: "Narrators (Audiobooks)",
|
||
operators: [
|
||
{ id: "contains", label: "Contains", requiresValue: true },
|
||
{ id: "equals", label: "Equals", requiresValue: true },
|
||
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||
],
|
||
valueType: "text",
|
||
},
|
||
];
|
||
|
||
let ruleCounter = 0;
|
||
let selectedBooks: Map<string, BookInfo> = new Map();
|
||
let customSectionTimeout: number | null = null;
|
||
|
||
function initCustomSectionBuilder(): void {
|
||
const addRuleBtn = document.getElementById("add-rule-btn");
|
||
const previewBtn = document.getElementById("preview-btn");
|
||
const searchBtn = document.getElementById("search-books-btn");
|
||
const bookSearchInput = document.getElementById("book-search");
|
||
const cancelBtn = document.getElementById("cancel-btn");
|
||
const form = document.getElementById("custom-section-form");
|
||
|
||
if (addRuleBtn) {
|
||
addRuleBtn.addEventListener("click", addFilterRule);
|
||
}
|
||
|
||
if (previewBtn) {
|
||
previewBtn.addEventListener("click", loadPreview);
|
||
}
|
||
|
||
if (searchBtn) {
|
||
searchBtn.addEventListener("click", searchBooks);
|
||
}
|
||
|
||
if (bookSearchInput) {
|
||
bookSearchInput.addEventListener("input", onBookSearchInput);
|
||
bookSearchInput.addEventListener("keypress", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
searchBooks();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (cancelBtn) {
|
||
cancelBtn.addEventListener("click", () => {
|
||
window.location.href = "/dashboard";
|
||
});
|
||
}
|
||
|
||
if (form) {
|
||
form.addEventListener("submit", saveCustomSection);
|
||
}
|
||
}
|
||
|
||
function addFilterRule(): void {
|
||
const container = document.getElementById("rules-container");
|
||
if (!container) return;
|
||
|
||
ruleCounter++;
|
||
const ruleId = `rule-${ruleCounter}`;
|
||
|
||
const ruleElement = document.createElement("div");
|
||
ruleElement.className = "rule-item p-3 rounded border";
|
||
ruleElement.dataset.ruleId = ruleId;
|
||
ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
|
||
|
||
ruleElement.innerHTML = `
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<select class="field-select flex-1 px-3 py-1 rounded border"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
|
||
<option value="">Select field...</option>
|
||
${FILTER_FIELDS.map((field) => `<option value="${field.id}">${field.label}</option>`).join("")}
|
||
</select>
|
||
<button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<select class="operator-select flex-1 px-3 py-1 rounded border"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||
disabled>
|
||
<option value="">Select field first...</option>
|
||
</select>
|
||
<input type="text" class="value-input flex-1 px-3 py-1 rounded border hidden"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||
placeholder="Enter value...">
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(ruleElement);
|
||
|
||
const fieldSelect = ruleElement.querySelector(
|
||
".field-select",
|
||
) as HTMLSelectElement;
|
||
const removeBtn = ruleElement.querySelector(
|
||
".remove-rule-btn",
|
||
) as HTMLButtonElement;
|
||
|
||
fieldSelect.addEventListener("change", () => onFieldChange(ruleElement));
|
||
removeBtn.addEventListener("click", () => removeFilterRule(ruleId));
|
||
}
|
||
|
||
function onFieldChange(ruleElement: HTMLElement): void {
|
||
const fieldSelect = ruleElement.querySelector(
|
||
".field-select",
|
||
) as HTMLSelectElement;
|
||
const operatorSelect = ruleElement.querySelector(
|
||
".operator-select",
|
||
) as HTMLSelectElement;
|
||
const valueInput = ruleElement.querySelector(
|
||
".value-input",
|
||
) as HTMLInputElement;
|
||
|
||
const fieldId = fieldSelect.value;
|
||
const field = FILTER_FIELDS.find((f) => f.id === fieldId);
|
||
|
||
operatorSelect.innerHTML = field
|
||
? field.operators
|
||
.map((op) => `<option value="${op.id}">${op.label}</option>`)
|
||
.join("")
|
||
: '<option value="">Select field first...</option>';
|
||
|
||
operatorSelect.disabled = !field;
|
||
|
||
if (
|
||
field &&
|
||
field.operators.some(
|
||
(op) => op.id === operatorSelect.value && op.requiresValue,
|
||
)
|
||
) {
|
||
valueInput.classList.remove("hidden");
|
||
|
||
if (field.valueType === "select" && field.options) {
|
||
valueInput.type = "select";
|
||
} else if (field.valueType === "number") {
|
||
valueInput.type = "number";
|
||
valueInput.step = "0.01";
|
||
} else if (field.valueType === "date") {
|
||
valueInput.type = "date";
|
||
} else {
|
||
valueInput.type = "text";
|
||
}
|
||
} else {
|
||
valueInput.classList.add("hidden");
|
||
}
|
||
|
||
operatorSelect.addEventListener("change", () => {
|
||
const selectedOp = field?.operators.find(
|
||
(op) => op.id === operatorSelect.value,
|
||
);
|
||
if (selectedOp?.requiresValue) {
|
||
valueInput.classList.remove("hidden");
|
||
} else {
|
||
valueInput.classList.add("hidden");
|
||
}
|
||
});
|
||
}
|
||
|
||
function removeFilterRule(ruleId: string): void {
|
||
const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`);
|
||
if (ruleElement) {
|
||
ruleElement.remove();
|
||
}
|
||
}
|
||
|
||
function onBookSearchInput(): void {
|
||
if (customSectionTimeout) {
|
||
clearTimeout(customSectionTimeout);
|
||
}
|
||
customSectionTimeout = window.setTimeout(() => {
|
||
searchBooks();
|
||
}, 300);
|
||
}
|
||
|
||
async function searchBooks(): Promise<void> {
|
||
const searchInput = document.getElementById(
|
||
"book-search",
|
||
) as HTMLInputElement;
|
||
const librarySelect = document.getElementById(
|
||
"section-library",
|
||
) as HTMLSelectElement;
|
||
const resultsContainer = document.getElementById(
|
||
"search-results",
|
||
) as HTMLElement;
|
||
|
||
const query = searchInput?.value.trim();
|
||
const libraryId = librarySelect?.value;
|
||
|
||
if (!query || !libraryId) {
|
||
if (resultsContainer) resultsContainer.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!response.ok) {
|
||
throw new Error("Failed to search books");
|
||
}
|
||
|
||
const data = await response.json();
|
||
displaySearchResults(data.books || []);
|
||
} catch (error) {
|
||
console.error("Search books error:", error);
|
||
showToast("Failed to search books", "error");
|
||
}
|
||
}
|
||
|
||
function displaySearchResults(books: BookInfo[]): void {
|
||
const resultsContainer = document.getElementById(
|
||
"search-results",
|
||
) as HTMLElement;
|
||
if (!resultsContainer) return;
|
||
|
||
if (books.length === 0) {
|
||
resultsContainer.innerHTML =
|
||
'<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
|
||
} else {
|
||
resultsContainer.innerHTML = books
|
||
.map(
|
||
(book) => `
|
||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||
data-book-id="${book.media_item_id}"
|
||
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')">
|
||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
|
||
alt="${builderEscapeHtml(book.title)}"
|
||
class="w-10 h-15 object-cover rounded">
|
||
<div class="flex-1">
|
||
<p class="text-sm font-medium" style="color: var(--text-primary);">${builderEscapeHtml(book.title)}</p>
|
||
<p class="text-xs" style="color: var(--text-secondary);">${builderEscapeHtml(book.author)}</p>
|
||
</div>
|
||
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
resultsContainer.classList.remove("hidden");
|
||
}
|
||
|
||
const addBookToSelection = (
|
||
bookId: string,
|
||
title: string,
|
||
author: string,
|
||
): void => {
|
||
if (selectedBooks.has(bookId)) {
|
||
showToast("Book already selected", "error");
|
||
return;
|
||
}
|
||
|
||
selectedBooks.set(bookId, {
|
||
media_item_id: bookId,
|
||
title: title,
|
||
author: author,
|
||
cover_image_path: "",
|
||
});
|
||
|
||
updateSelectedBooksDisplay();
|
||
};
|
||
|
||
const removeBookFromSelection = (bookId: string): void => {
|
||
selectedBooks.delete(bookId);
|
||
updateSelectedBooksDisplay();
|
||
};
|
||
|
||
function updateSelectedBooksDisplay(): void {
|
||
const container = document.getElementById("selected-books") as HTMLElement;
|
||
if (!container) return;
|
||
|
||
if (selectedBooks.size === 0) {
|
||
container.innerHTML =
|
||
'<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = Array.from(selectedBooks.values())
|
||
.map(
|
||
(book) => `
|
||
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||
style="background-color: var(--accent);">
|
||
<span>${builderEscapeHtml(book.title)}</span>
|
||
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
||
class="hover:opacity-70">×</button>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
async function loadPreview(): Promise<void> {
|
||
const previewContainer = document.getElementById(
|
||
"preview-container",
|
||
) as HTMLElement;
|
||
const librarySelect = document.getElementById(
|
||
"section-library",
|
||
) as HTMLSelectElement;
|
||
const libraryId = librarySelect?.value;
|
||
|
||
if (!libraryId) {
|
||
showToast("Please select a library first", "error");
|
||
return;
|
||
}
|
||
|
||
const rules = gatherFilterRules();
|
||
const manualBookIds = Array.from(selectedBooks.keys());
|
||
|
||
previewContainer.innerHTML =
|
||
'<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';
|
||
|
||
try {
|
||
const response = await apiPost("/collections/preview", {
|
||
library_id: libraryId,
|
||
rules: rules,
|
||
manual_book_ids: manualBookIds,
|
||
limit: 20,
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
displayPreview(data.items || []);
|
||
} else {
|
||
throw new Error("Failed to load preview");
|
||
}
|
||
} catch (error) {
|
||
console.error("Preview error:", error);
|
||
previewContainer.innerHTML =
|
||
'<p class="text-center text-red-500">Failed to load preview</p>';
|
||
}
|
||
}
|
||
|
||
function gatherFilterRules(): FilterRule[] {
|
||
const container = document.getElementById("rules-container") as HTMLElement;
|
||
if (!container) return [];
|
||
|
||
const ruleElements = container.querySelectorAll(".rule-item");
|
||
const rules: FilterRule[] = [];
|
||
|
||
ruleElements.forEach((element, index) => {
|
||
const fieldSelect = element.querySelector(
|
||
".field-select",
|
||
) as HTMLSelectElement;
|
||
const operatorSelect = element.querySelector(
|
||
".operator-select",
|
||
) as HTMLSelectElement;
|
||
const valueInput = element.querySelector(
|
||
".value-input",
|
||
) as HTMLInputElement;
|
||
|
||
if (fieldSelect.value && operatorSelect.value) {
|
||
rules.push({
|
||
id: `rule-${index}`,
|
||
field: fieldSelect.value,
|
||
operator: operatorSelect.value,
|
||
value: valueInput.value,
|
||
priority: index,
|
||
});
|
||
}
|
||
});
|
||
|
||
return rules;
|
||
}
|
||
|
||
function displayPreview(items: BookInfo[]): void {
|
||
const previewContainer = document.getElementById(
|
||
"preview-container",
|
||
) as HTMLElement;
|
||
if (!previewContainer) return;
|
||
|
||
if (items.length === 0) {
|
||
previewContainer.innerHTML =
|
||
'<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
|
||
return;
|
||
}
|
||
|
||
previewContainer.innerHTML = `
|
||
<div class="flex gap-4 overflow-x-auto pb-4">
|
||
${items
|
||
.map(
|
||
(item) => `
|
||
<div class="flex-shrink-0 w-32">
|
||
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
|
||
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}"
|
||
alt="${builderEscapeHtml(item.title)}"
|
||
class="w-full h-full object-cover">
|
||
</div>
|
||
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
||
${builderEscapeHtml(item.title)}
|
||
</h3>
|
||
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${builderEscapeHtml(item.author)}</p>` : ""}
|
||
</div>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
<p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
|
||
${items.length} item${items.length !== 1 ? "s" : ""} will be shown
|
||
</p>
|
||
`;
|
||
}
|
||
|
||
async function saveCustomSection(event: Event): Promise<void> {
|
||
event.preventDefault();
|
||
|
||
const formData = new FormData(event.target as HTMLFormElement);
|
||
const libraryId = formData.get("library_id") as string;
|
||
const name = formData.get("name") as string;
|
||
const icon = formData.get("icon") as string;
|
||
const description = formData.get("description") as string;
|
||
const matchType = (document.getElementById("match-type") as HTMLSelectElement)
|
||
.value;
|
||
|
||
if (!libraryId || !name) {
|
||
showToast("Please fill in required fields", "error");
|
||
return;
|
||
}
|
||
|
||
const rules = gatherFilterRules();
|
||
const manualBookIds = Array.from(selectedBooks.keys());
|
||
|
||
if (rules.length === 0 && manualBookIds.length === 0) {
|
||
showToast("Please add filter rules or select books", "error");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiPost("/collections", {
|
||
library_id: libraryId,
|
||
name: name,
|
||
icon: icon,
|
||
description: description,
|
||
show_on_dashboard: true,
|
||
auto_assign_rules: JSON.stringify(rules),
|
||
manual_book_ids: manualBookIds,
|
||
match_type: matchType,
|
||
});
|
||
|
||
if (response.ok) {
|
||
showToast("Custom section created successfully", "success");
|
||
setTimeout(() => {
|
||
window.location.href = "/dashboard";
|
||
}, 1000);
|
||
} else {
|
||
throw new Error("Failed to save custom section");
|
||
}
|
||
} catch (error) {
|
||
console.error("Save custom section error:", error);
|
||
showToast("Failed to save custom section", "error");
|
||
}
|
||
}
|
||
|
||
function builderEscapeHtml(text: string): string {
|
||
const div = document.createElement("div");
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
initCustomSectionBuilder();
|
||
|
||
export { addBookToSelection, removeBookFromSelection };
|