feat: add book metadata editor with Google Books and OpenLibrary integration

This commit is contained in:
knight 2025-05-17 15:49:26 -04:00
parent f4f227b24d
commit d2b88f4e59
11 changed files with 981 additions and 30 deletions

View File

@ -3,7 +3,13 @@ const axios = require('axios');
// Fetch book from the local database by ISBN // Fetch book from the local database by ISBN
const fetchBookFromLocalDatabase = async (isbn) => { const fetchBookFromLocalDatabase = async (isbn) => {
return await Book.findOne({ where: { isbn } }); return await Book.findOne({
where: { isbn },
include: [{
model: Location,
as: 'Location'
}]
});
}; };
// Fetch book from Google Books API // Fetch book from Google Books API

BIN
books.db

Binary file not shown.

183
index.js
View File

@ -130,6 +130,16 @@ app.get('/api/books-with-images', async (req, res) => {
}, },
attributes: ['isbn', 'title', 'cover_small', 'cover_medium', 'cover_large', 'authors'], attributes: ['isbn', 'title', 'cover_small', 'cover_medium', 'cover_large', 'authors'],
}); });
// CORRECTED DEBUG LOGGING ON SERVER
if (books && books.length > 0) {
// Log a specific book if its ID is known, e.g., ID 207 for Artemis Fowl
const specificBookForDebug = books.find(b => b.id === 207);
if (specificBookForDebug) {
console.log("Server-side specific book from /api/books (ID 207):", JSON.stringify(specificBookForDebug, null, 2));
} else {
console.log("Server-side book ID 207 not found in /api/books results, logging first book instead:", JSON.stringify(books[0], null, 2));
}
}
res.json(books); res.json(books);
} catch (error) { } catch (error) {
console.error('Failed to fetch books with images:', error); console.error('Failed to fetch books with images:', error);
@ -301,23 +311,25 @@ function delay(ms) {
// Confirm book is in the library by ISBN // Confirm book is in the library by ISBN
app.get('/book/confirm/:isbn', async (req, res) => { app.get('/book/confirm/:isbn', async (req, res) => {
const { isbn } = req.params; const { isbn } = req.params;
console.log(`Fetching book data for ISBN: ${isbn}`); console.log(`Cascade: /book/confirm/:isbn - Confirming ISBN: ${isbn}`);
try { try {
// Check if the book is in the local database // Check if the book is in the local database
const localBook = await fetchBookFromLocalDatabase(isbn); const localBook = await fetchBookFromLocalDatabase(isbn);
if (localBook) { if (localBook) {
console.log('Book found in the local database'); console.log('Cascade: /book/confirm/:isbn - Book found in local database.');
return res.json({ source: 'local', data: localBook }); return res.json({ source: 'local', data: localBook });
} else {
console.log('Cascade: /book/confirm/:isbn - Book NOT found in local database.');
return res.status(404).json({ error: 'Book not found in local library' });
} }
} catch (error) { } catch (error) {
console.error(error); console.error('Cascade: /book/confirm/:isbn - Error during lookup:', error);
return res.status(500).json({ error: 'Failed to fetch book data' }); return res.status(500).json({ error: 'Failed to confirm book in local library' });
} }
}); });
app.get('/search-title', async (req, res) => { app.get('/search-title', async (req, res) => {
const { title, internalOnly = false } = req.query; const { title, internalOnly = false } = req.query;
console.log(`Searching for books by title or related fields: ${title}`); console.log(`Searching for books by title or related fields: ${title}`);
@ -393,9 +405,14 @@ app.delete('/book/:id', async (req, res) => {
} }
}); });
app.get('/locations', async (req, res) => { app.get('/api/locations', async (req, res) => {
try { try {
const locations = await Location.findAll(); const locations = await Location.findAll({
order: [
['name', 'ASC'],
['shelf', 'ASC']
]
});
res.json(locations); res.json(locations);
} catch (error) { } catch (error) {
console.error('Failed to fetch locations:', error); console.error('Failed to fetch locations:', error);
@ -657,6 +674,156 @@ function requestCheckout(isbn) {
}); });
} }
// API endpoint to get all books
app.get('/api/books', async (req, res) => {
try {
const books = await Book.findAll({
include: [{
model: Location,
as: 'Location' // Corrected alias based on model definition
}],
order: [['title', 'ASC']]
});
res.json(books.map(book => ({
id: book.id,
title: book.title,
authors: book.authors,
isbn: book.isbn,
publishedDate: book.publishedDate,
description: book.description,
cover_small: book.cover_small,
number_of_pages: book.number_of_pages,
publishers: book.publishers,
subjects: book.subjects,
location_id: book.location_id,
Location: book.Location ? { id: book.Location.id, name: book.Location.name, room: book.Location.room, shelf: book.Location.shelf } : null
})));
} catch (error) {
console.error('Failed to fetch books:', error);
res.status(500).json({ error: 'Failed to fetch books' });
}
});
// API endpoint to add a new book
app.post('/api/books', async (req, res) => {
try {
const {
title,
authors,
publishedDate,
description,
isbn,
number_of_pages,
publishers,
subjects,
cover_small,
cover_medium,
cover_large,
location_id
} = req.body;
// Basic validation (e.g., title is required)
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const newBookData = {
title,
authors: Array.isArray(authors) ? authors.join(', ') : authors,
publishedDate,
description,
isbn,
number_of_pages,
publishers: Array.isArray(publishers) ? publishers.join(', ') : publishers,
subjects: Array.isArray(subjects) ? subjects.join(', ') : subjects,
cover_small,
cover_medium,
cover_large,
location_id: location_id === '' ? null : location_id // Handle empty string as null
};
const book = await Book.create(newBookData);
// Fetch the newly created book with its location to send back in the response
const createdBookWithLocation = await Book.findByPk(book.id, {
include: [{
model: Location,
as: 'Location'
}]
});
res.status(201).json({ success: true, book: createdBookWithLocation });
} catch (error) {
console.error('Failed to add new book:', error);
if (error.name === 'SequelizeUniqueConstraintError') {
let errorMessage = 'Failed to add new book due to a conflict.';
// Assuming 'isbn' is a unique field in your Book model
if (error.fields && typeof error.fields === 'object' && 'isbn' in error.fields) {
errorMessage = 'A book with this ISBN already exists. Please use the edit feature if you want to update it.';
}
return res.status(409).json({ error: errorMessage });
}
res.status(500).json({ error: 'Failed to add new book' });
}
});
// API endpoint to update a book's metadata by its primary key ID
app.put('/api/books/:id', async (req, res) => {
try {
const bookId = req.params.id;
const book = await Book.findByPk(bookId);
if (!book) {
return res.status(404).json({ error: 'Book not found' });
}
const {
title,
authors,
publishedDate,
description,
isbn,
number_of_pages,
publishers,
subjects,
cover_small,
cover_medium,
cover_large,
location_id // Added to handle location updates
} = req.body;
const updateData = {};
if (title !== undefined) updateData.title = title;
if (authors !== undefined) updateData.authors = Array.isArray(authors) ? authors.join(', ') : authors;
if (publishedDate !== undefined) updateData.publishedDate = publishedDate;
if (description !== undefined) updateData.description = description;
if (isbn !== undefined) updateData.isbn = isbn === '' ? null : isbn;
if (number_of_pages !== undefined) updateData.number_of_pages = number_of_pages;
if (publishers !== undefined) updateData.publishers = Array.isArray(publishers) ? publishers.join(', ') : publishers;
if (subjects !== undefined) updateData.subjects = Array.isArray(subjects) ? subjects.join(', ') : subjects;
if (cover_small !== undefined) updateData.cover_small = cover_small;
if (cover_medium !== undefined) updateData.cover_medium = cover_medium;
if (cover_large !== undefined) updateData.cover_large = cover_large;
if (location_id !== undefined) updateData.location_id = location_id === '' ? null : location_id; // Handle empty string as null
await book.update(updateData);
console.log(`Book data for ID ${bookId} updated successfully.`);
// Fetch the updated book with its location to send back in the response
const updatedBookWithLocation = await Book.findByPk(bookId, {
include: [{
model: Location,
as: 'Location'
}]
});
res.json({ success: true, book: updatedBookWithLocation });
} catch (error) {
console.error(`Failed to update book with ID ${req.params.id}:`, error);
res.status(500).json({ error: 'Failed to update book' });
}
});
const httpsServer = https.createServer(credentials, app); const httpsServer = https.createServer(credentials, app);
@ -698,7 +865,7 @@ app.get('/api/books-on-loan', async (req, res) => {
} }
}); });
app.post('/book/update/:isbn', async (req, res) => { app.post('/book/update/:isbn', authMiddleware, async (req, res) => { // Added authMiddleware for consistency
const { isbn } = req.params; const { isbn } = req.params;
const googleBooksApiKey = process.env.GOOGLE_BOOKS_API_KEY; const googleBooksApiKey = process.env.GOOGLE_BOOKS_API_KEY;

53
populate_locations.js Normal file
View File

@ -0,0 +1,53 @@
import { sequelize, Location } from './models.js';
const locationsToCreate = [];
// Cubes 1-10
for (let i = 1; i <= 10; i++) {
locationsToCreate.push({ name: 'Cube', shelf: i.toString() });
}
// Office Shelves 1-6
for (let i = 1; i <= 6; i++) {
locationsToCreate.push({ name: 'Office Shelf', shelf: i.toString() });
}
// Miscellaneous placements
locationsToCreate.push({ name: 'Upstairs', shelf: 'Misc' });
locationsToCreate.push({ name: 'Downstairs', shelf: 'Misc' });
async function populateLocations() {
try {
// Optional: Sync database to ensure tables are created
await sequelize.sync(); // Often done in your main app setup
// Check if locations already exist to avoid duplicates if script is run multiple times
// This is a simple check; more robust checks might be needed for production
const existingLocations = await Location.findAll();
const newLocations = locationsToCreate.filter(ltc =>
!existingLocations.some(el => el.name === ltc.name && el.shelf === ltc.shelf)
);
if (newLocations.length > 0) {
await Location.bulkCreate(newLocations);
console.log(`${newLocations.length} locations have been successfully added.`);
} else {
console.log('All specified locations already exist in the database. No new locations added.');
}
// Select all locations from the database
const [locationsFromQuery] = await sequelize.query('SELECT * FROM locations'); // sequelize.query returns [results, metadata]
console.log('All locations:', locationsFromQuery);
} catch (error) {
console.error('Error populating locations:', error);
} finally {
// Close the database connection if the script is standalone and not part of a larger app run
if (sequelize && typeof sequelize.close === 'function') {
await sequelize.close();
console.log('Database connection closed.');
}
}
}
populateLocations();

59
public/edit_books.html Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Book Metadata</title>
<link rel="stylesheet" href="style.css"> </head>
<body>
<h1>Edit Book Metadata</h1>
<button id="add-new-book-button" style="margin-bottom: 10px;">Add New Book</button>
<div id="book-list-container">
<!-- Books will be listed here by JavaScript -->
</div>
<!-- Modal for editing metadata -->
<div id="edit-modal" class="modal" style="display:none;">
<div class="modal-content">
<span class="close-button">&times;</span>
<h2>Edit Metadata for <span id="modal-book-title"></span></h2>
<input type="hidden" id="modal-book-id">
<div>
<label for="modal-search-query">Search Google Books:</label>
<input type="text" id="modal-search-query">
<button id="modal-search-button">Search</button>
</div>
<div id="modal-search-results">
<!-- Google Books API search results will appear here -->
</div>
<hr>
<h3>Selected Book Details (Editable)</h3>
<div><label>Title: <input type="text" id="modal-edit-title"></label></div>
<div><label>Authors: <input type="text" id="modal-edit-authors"></label></div>
<div><label>Published Date: <input type="text" id="modal-edit-publishedDate"></label></div>
<div><label>ISBN: <input type="text" id="modal-edit-isbn"></label></div>
<div><label>Description: <textarea id="modal-edit-description"></textarea></label></div>
<div><label>Pages: <input type="number" id="modal-edit-pages"></label></div>
<div><label>Publishers: <input type="text" id="modal-edit-publishers"></label></div>
<div><label>Subjects: <input type="text" id="modal-edit-subjects"></label></div>
<div>
<label for="modal-edit-location">Location:</label>
<select id="modal-edit-location" name="location_id">
<option value="">-- No Location --</option>
<!-- Location options will be populated by JavaScript -->
</select>
</div>
<div><label>Cover (Small): <input type="text" id="modal-edit-cover-small"></label></div>
<div><label>Cover (Medium): <input type="text" id="modal-edit-cover-medium"></label></div>
<button id="modal-save-button">Save Changes</button>
</div>
</div>
<script src="edit_books.js"></script>
</body>
</html>

405
public/edit_books.js Normal file
View File

@ -0,0 +1,405 @@
let allLocations = []; // To store fetched locations globally - ADDED HERE
document.addEventListener('DOMContentLoaded', async () => {
await fetchLocations(); // Fetch locations on page load
const bookListContainer = document.getElementById('book-list-container');
const modal = document.getElementById('edit-modal');
const closeModalButton = modal.querySelector('.close-button');
const modalBookTitle = document.getElementById('modal-book-title');
const modalBookIdInput = document.getElementById('modal-book-id');
const modalSearchQueryInput = document.getElementById('modal-search-query');
const modalSearchButton = document.getElementById('modal-search-button');
const modalSearchResultsContainer = document.getElementById('modal-search-results');
const modalEditLocationSelect = document.getElementById('modal-edit-location');
const modalEditFields = {
title: document.getElementById('modal-edit-title'),
authors: document.getElementById('modal-edit-authors'),
publishedDate: document.getElementById('modal-edit-publishedDate'),
isbn: document.getElementById('modal-edit-isbn'),
description: document.getElementById('modal-edit-description'),
pages: document.getElementById('modal-edit-pages'),
publishers: document.getElementById('modal-edit-publishers'),
subjects: document.getElementById('modal-edit-subjects'),
cover_small: document.getElementById('modal-edit-cover-small'),
cover_medium: document.getElementById('modal-edit-cover-medium')
};
const modalSaveButton = document.getElementById('modal-save-button');
let currentBooks = []; // To store the fetched books globally
// let allLocations = []; // To store fetched locations globally - REMOVED FROM HERE
let currentlyEditingBookId = null;
const addNewBookButton = document.getElementById('add-new-book-button');
addNewBookButton.addEventListener('click', () => {
currentlyEditingBookId = null; // Signal "add" mode
modalBookIdInput.value = ''; // Clear hidden book ID input
modalBookTitle.textContent = 'Add New Book'; // Update modal title for adding
// Clear all form fields
Object.values(modalEditFields).forEach(field => field.value = '');
modalEditLocationSelect.value = ''; // Reset location dropdown
modalSearchResultsContainer.innerHTML = ''; // Clear any previous search results
document.getElementById('modal-search-query').value = ''; // Clear search query too
openModal(); // Open the modal for new entry
});
// Authentication has been disabled for book updates
// --- Fetch and Display Books ---
async function fetchAndDisplayBooks() {
try {
const response = await fetch('/api/books');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
currentBooks = await response.json();
renderBookList(currentBooks);
} catch (error) {
console.error('Error fetching books:', error);
bookListContainer.innerHTML = '<p>Error loading books. See console for details.</p>';
}
}
function renderBookList(books) {
bookListContainer.innerHTML = ''; // Clear existing list
if (books.length === 0) {
bookListContainer.innerHTML = '<p>No books found in the library.</p>';
return;
}
const ul = document.createElement('ul');
ul.className = 'book-list'; // Add a class for styling if needed
books.forEach(book => {
const li = document.createElement('li');
const locationName = book.Location ? `${book.Location.name}${book.Location.shelf ? ' (Shelf: ' + book.Location.shelf + ')' : ''}` : 'No Location'; // Reverted to book.Location (capital L)
li.innerHTML = `
<div class="book-entry">
<img src="${book.cover_small || 'placeholder.png'}" alt="Cover for ${book.title || 'N/A'}" style="width:50px; height:auto; margin-right:10px; flex-shrink: 0;">
<div style="flex-grow: 1;">
<p><strong>Title:</strong> ${book.title || 'N/A'}</p>
<p><strong>Author(s):</strong> ${book.authors || 'N/A'}</p>
<p><strong>Published:</strong> ${book.publishedDate || 'N/A'}</p>
<p><strong>ISBN:</strong> ${book.isbn || 'N/A'}</p>
<p><strong>Publisher(s):</strong> ${book.publishers || 'N/A'}</p>
<p><strong>Location:</strong> ${locationName}</p>
</div>
<button class="edit-metadata-button" data-book-id="${book.id}" style="margin-left: 10px; align-self: center;">Edit Metadata</button>
</div>
`;
ul.appendChild(li);
});
bookListContainer.appendChild(ul);
// Add event listeners to new edit buttons
document.querySelectorAll('.edit-metadata-button').forEach(button => {
button.addEventListener('click', handleEditMetadataClick);
});
}
// --- Modal Logic ---
function openModal() {
modal.style.display = 'block';
}
function closeModal() {
modal.style.display = 'none';
modalSearchResultsContainer.innerHTML = ''; // Clear search results
// Clear form fields
Object.values(modalEditFields).forEach(input => input.value = '');
modalSearchQueryInput.value = '';
}
closeModalButton.onclick = closeModal;
window.onclick = (event) => {
if (event.target == modal) {
closeModal();
}
};
async function handleEditMetadataClick(event) {
event.preventDefault(); // Prevent default action
const bookId = event.target.dataset.bookId;
const bookToEdit = currentBooks.find(b => b.id.toString() === bookId);
if (!bookToEdit) {
alert('Book not found!');
return;
}
modalBookTitle.textContent = bookToEdit.title;
modalBookIdInput.value = bookToEdit.id;
currentlyEditingBookId = bookToEdit.id; // Ensure this is set for edit mode
// Pre-fill search query and existing data
modalSearchQueryInput.value = bookToEdit.title || '';
if (bookToEdit.isbn) {
modalSearchQueryInput.value += ` ISBN: ${bookToEdit.isbn}`;
}
modalEditFields.title.value = bookToEdit.title || '';
modalEditFields.authors.value = bookToEdit.authors || '';
modalEditFields.publishedDate.value = bookToEdit.publishedDate || '';
modalEditFields.isbn.value = bookToEdit.isbn || '';
modalEditFields.description.value = bookToEdit.description || '';
modalEditFields.pages.value = bookToEdit.number_of_pages || '';
modalEditFields.publishers.value = bookToEdit.publishers || '';
modalEditFields.subjects.value = bookToEdit.subjects || '';
modalEditFields.cover_small.value = bookToEdit.cover_small || '';
modalEditFields.cover_medium.value = bookToEdit.cover_medium || '';
// Populate and set location dropdown
modalEditLocationSelect.innerHTML = '<option value="">-- No Location --</option>'; // Clear existing options but keep default
allLocations.forEach(loc => {
const option = document.createElement('option');
option.value = loc.id;
option.textContent = `${loc.name}` + (loc.shelf ? ` (Shelf: ${loc.shelf})` : '');
modalEditLocationSelect.appendChild(option);
});
modalEditLocationSelect.value = bookToEdit.location_id || (bookToEdit.Location ? bookToEdit.Location.id : '');
openModal();
}
// --- Google Books API Search in Modal ---
modalSearchButton.addEventListener('click', handleModalSearch);
async function handleModalSearch() {
let originalQuery = modalSearchQueryInput.value.trim();
if (!originalQuery) {
alert('Please enter a search query.');
return;
}
let searchQuery = originalQuery;
const potentialIsbn = originalQuery.replace(/-/g, '');
let isIsbnQuery = false;
if (/^(\d{9}[\dX]|\d{13})$/.test(potentialIsbn)) {
searchQuery = potentialIsbn; // Use the cleaned ISBN for OpenLibrary
isIsbnQuery = true;
console.log(`Detected ISBN-like string "${originalQuery}", searching as ISBN "${searchQuery}"`);
} else if (originalQuery.toLowerCase().startsWith('isbn:')) {
searchQuery = originalQuery.substring(5).replace(/-/g, ''); // Remove 'isbn:' and hyphens
isIsbnQuery = true;
console.log(`Query "${originalQuery}" formatted for ISBN search, using "${searchQuery}"`);
} else {
console.log(`Performing general search for "${originalQuery}"`);
}
modalSearchResultsContainer.innerHTML = '<p>Searching...</p>';
const searchButton = document.getElementById('modal-search-button');
if (searchButton) searchButton.disabled = true;
let combinedResults = [];
try {
const googleQuery = isIsbnQuery ? `isbn:${searchQuery}` : searchQuery;
const googleUrl = `https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(googleQuery)}&maxResults=5`;
const openLibFields = 'key,title,author_name,first_publish_year,isbn,cover_i,publisher,subject,number_of_pages_median,subtitle,first_sentence_value';
const openLibUrl = `https://openlibrary.org/search.json?q=${encodeURIComponent(searchQuery)}&limit=5&fields=${openLibFields}`;
console.log("Searching Google Books API with URL:", googleUrl);
console.log("Searching OpenLibrary API with URL:", openLibUrl);
const [googleResult, openLibResult] = await Promise.allSettled([
fetch(googleUrl).then(res => res.ok ? res.json() : Promise.reject(new Error(`Google Books API request failed: ${res.status} ${res.statusText}`))),
fetch(openLibUrl).then(res => res.ok ? res.json() : Promise.reject(new Error(`OpenLibrary API request failed: ${res.status} ${res.statusText}`)))
]);
if (googleResult.status === 'fulfilled' && googleResult.value.items) {
const googleBooks = googleResult.value.items.map(item => {
const volumeInfo = item.volumeInfo;
let isbn13 = '', isbn10 = '';
if (volumeInfo.industryIdentifiers) {
volumeInfo.industryIdentifiers.forEach(id => {
if (id.type === 'ISBN_13') isbn13 = id.identifier;
if (id.type === 'ISBN_10') isbn10 = id.identifier;
});
}
return {
title: volumeInfo.title,
authors: volumeInfo.authors ? volumeInfo.authors.join(', ') : '',
publishedDate: volumeInfo.publishedDate,
isbn: isbn13 || isbn10,
description: volumeInfo.description,
pageCount: volumeInfo.pageCount,
publishers: volumeInfo.publisher ? (Array.isArray(volumeInfo.publisher) ? volumeInfo.publisher.join(', ') : volumeInfo.publisher) : '',
subjects: volumeInfo.categories ? volumeInfo.categories.join(', ') : '',
cover_small: volumeInfo.imageLinks?.smallThumbnail,
cover_medium: volumeInfo.imageLinks?.thumbnail,
source: 'Google Books',
rawData: volumeInfo
};
});
combinedResults.push(...googleBooks);
} else if (googleResult.status === 'rejected') {
console.error('Error fetching from Google Books:', googleResult.reason);
}
if (openLibResult.status === 'fulfilled' && openLibResult.value.docs) {
const openLibBooks = openLibResult.value.docs.map(doc => {
return {
title: doc.title,
authors: doc.author_name ? doc.author_name.join(', ') : '',
publishedDate: doc.first_publish_year ? String(doc.first_publish_year) : '',
isbn: doc.isbn ? doc.isbn[0] : '', // Take first ISBN
description: doc.first_sentence_value || doc.subtitle || '',
pageCount: doc.number_of_pages_median,
publishers: doc.publisher ? doc.publisher.join(', ') : '',
subjects: doc.subject ? doc.subject.join(', ') : '',
cover_small: doc.cover_i ? `https://covers.openlibrary.org/b/id/${doc.cover_i}-S.jpg` : '',
cover_medium: doc.cover_i ? `https://covers.openlibrary.org/b/id/${doc.cover_i}-M.jpg` : '',
source: 'OpenLibrary',
rawData: doc
};
});
combinedResults.push(...openLibBooks);
}
else if (openLibResult.status === 'rejected') {
console.error('Error fetching from OpenLibrary:', openLibResult.reason);
}
displayModalSearchResults(combinedResults);
} catch (error) { // Catch any unexpected errors during the setup or Promise.allSettled itself
console.error('Error in handleModalSearch:', error);
modalSearchResultsContainer.innerHTML = `<p>An unexpected error occurred during search: ${error.message}. Please check console.</p>`;
} finally {
if (searchButton) searchButton.disabled = false;
}
}
function displayModalSearchResults(items) {
modalSearchResultsContainer.innerHTML = '';
if (!items || items.length === 0) {
modalSearchResultsContainer.innerHTML = '<p>No results found from any source.</p>';
return;
}
const ul = document.createElement('ul');
items.forEach(bookItem => {
const li = document.createElement('li');
li.innerHTML = `
<strong>${bookItem.title || 'N/A'}</strong> by ${bookItem.authors || 'N/A'} (<em>${bookItem.source}</em>)
<br>
Published: ${bookItem.publishedDate || 'N/A'}, ISBN: ${bookItem.isbn || 'N/A'}
<button class="select-book-result">Use this</button>
`;
li.querySelector('.select-book-result').addEventListener('click', () => {
populateFormWithSelectedBook(bookItem);
});
ul.appendChild(li);
});
modalSearchResultsContainer.appendChild(ul);
}
function populateFormWithSelectedBook(bookItem) {
modalEditFields.title.value = bookItem.title || '';
modalEditFields.authors.value = bookItem.authors || '';
modalEditFields.publishedDate.value = bookItem.publishedDate || '';
modalEditFields.isbn.value = bookItem.isbn || '';
modalEditFields.description.value = bookItem.description || '';
modalEditFields.pages.value = bookItem.pageCount || '';
modalEditFields.publishers.value = bookItem.publishers || '';
modalEditFields.subjects.value = bookItem.subjects || '';
modalEditFields.cover_small.value = bookItem.cover_small || '';
modalEditFields.cover_medium.value = bookItem.cover_medium || '';
}
// --- Save Changes ---
modalSaveButton.addEventListener('click', async () => {
const bookData = {
title: modalEditFields.title.value.trim(),
authors: modalEditFields.authors.value.trim(),
publishedDate: modalEditFields.publishedDate.value.trim(),
isbn: modalEditFields.isbn.value.trim(),
description: modalEditFields.description.value.trim(),
number_of_pages: modalEditFields.pages.value ? parseInt(modalEditFields.pages.value, 10) : null,
publishers: modalEditFields.publishers.value.trim(),
subjects: modalEditFields.subjects.value.trim(),
cover_small: modalEditFields.cover_small.value.trim(),
cover_medium: modalEditFields.cover_medium.value.trim(),
location_id: modalEditLocationSelect.value || null,
};
if (!bookData.title) {
alert('Title is required.');
return;
}
let url;
let method;
const headers = {
'Content-Type': 'application/json'
};
if (currentlyEditingBookId) { // Edit mode
url = `/api/books/${currentlyEditingBookId}`;
method = 'PUT';
// Basic Auth not currently implemented for PUT as per prior setup
} else { // Add mode
url = '/api/books';
method = 'POST';
// POST endpoint requires Basic Auth
const adminPassword = prompt("Enter admin password to add a new book:");
if (adminPassword === null) { // User cancelled prompt
return;
}
headers['Authorization'] = 'Basic ' + btoa('admin:' + adminPassword);
}
try {
const response = await fetch(url, {
method: method,
headers: headers,
body: JSON.stringify(bookData)
});
const responseBody = await response.text(); // Read body as text first to handle potential non-JSON errors
let result;
try {
result = JSON.parse(responseBody);
} catch (e) {
// If parsing fails, the response was not valid JSON
console.error('Failed to parse server response as JSON:', responseBody);
throw new Error(`Server returned non-JSON response (status: ${response.status}). Check console for details.`);
}
if (!response.ok) {
const errorMessage = result && result.error ? result.error : `HTTP error! Status: ${response.status}`;
throw new Error(errorMessage);
}
if (result.success) {
alert(`Book ${method === 'POST' ? 'added' : 'updated'} successfully!`);
closeModal();
fetchAndDisplayBooks(); // Refresh the list
} else {
alert(`Failed to ${method === 'POST' ? 'add' : 'update'} book: ${result.error || 'Unknown server error'}`);
}
} catch (error) {
console.error(`Error ${method === 'POST' ? 'adding' : 'saving'} book data:`, error);
alert(`Error ${method === 'POST' ? 'adding' : 'saving'}: ${error.message}`);
}
});
// Initial load
fetchAndDisplayBooks();
});
async function fetchLocations() {
try {
const response = await fetch('/api/locations');
if (!response.ok) {
throw new Error(`Failed to fetch locations: ${response.status}`);
}
allLocations = await response.json();
console.log('Locations fetched:', allLocations);
} catch (error) {
console.error('Error fetching locations:', error);
// Optionally, inform the user that locations could not be loaded
}
}

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Google Books API Tester</title>
<link rel="stylesheet" href="style.css"> </head>
<body>
<h1>Google Books API Tester</h1>
<div>
<label for="search-query">Search Query:</label>
<input type="text" id="search-query" placeholder="Enter book title, author, ISBN, etc.">
<button id="search-button">Search</button>
</div>
<h2>API Response:</h2>
<pre id="api-response"></pre>
<script src="google_api_tester.js"></script>
</body>
</html>

View File

@ -0,0 +1,30 @@
document.addEventListener('DOMContentLoaded', () => {
const searchButton = document.getElementById('search-button');
const searchQueryInput = document.getElementById('search-query');
const apiResponseOutput = document.getElementById('api-response');
searchButton.addEventListener('click', async () => {
const query = searchQueryInput.value.trim();
if (!query) {
apiResponseOutput.textContent = 'Please enter a search query.';
return;
}
apiResponseOutput.textContent = 'Searching...';
try {
// Note: For extensive use, you should use an API key.
// Add &key=YOUR_API_KEY to the URL if you have one.
const response = await fetch(`https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(query)}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
const data = await response.json();
apiResponseOutput.textContent = JSON.stringify(data, null, 2);
} catch (error) {
console.error('Error fetching from Google Books API:', error);
apiResponseOutput.textContent = `Error: ${error.message}`;
}
});
});

View File

@ -13,7 +13,9 @@
<select id="camera-select"></select> <select id="camera-select"></select>
<!-- Confirm mode toggle --> <!-- Confirm mode toggle -->
<input type="checkbox" id="confirm-mode"> <input type="checkbox" id="confirm-mode">
<label for="confirm-mode">Confirm mode</label>s <label for="confirm-mode">Confirm mode</label>
<input type="checkbox" id="flash-toggle">
<label for="flash-toggle">Flash</label>
</div> </div>
<div id="interactive" class="viewport"></div> <div id="interactive" class="viewport"></div>
<div> <div>

View File

@ -50,9 +50,8 @@ function startScanner() {
return; return;
} }
if (quaggaInitialized) { const initLogic = () => {
Quagga.start(); console.log("startScanner/initLogic: Proceeding with Quagga.init().");
} else {
Quagga.init({ Quagga.init({
inputStream: { inputStream: {
name: "Live", name: "Live",
@ -61,34 +60,93 @@ function startScanner() {
constraints: { constraints: {
deviceId: selectedDeviceId, deviceId: selectedDeviceId,
facingMode: "environment", facingMode: "environment",
// Remove advanced constraints if not needed advanced: [{torch: document.getElementById('flash-toggle') ? document.getElementById('flash-toggle').checked : false}]
}, },
}, },
decoder: { decoder: {
readers: ["ean_reader", "ean_8_reader"] readers: ["ean_reader", "ean_8_reader"]
} }
}, function (err) { }, function(err) {
if (err) { if (err) {
console.log(err); console.error("Quagga.init() callback: Failed with error:", err);
quaggaInitialized = false;
alert('Error initializing scanner: ' + err.name + ' - ' + err.message);
return; return;
} }
console.log("Initialization finished. Ready to start"); console.log("Quagga.init() callback: Successful. Starting stream...");
Quagga.start(); Quagga.start();
console.log("Quagga.init() callback: Quagga.start() called.");
quaggaInitialized = true; quaggaInitialized = true;
Quagga.offDetected(processBarcode); // Remove before adding to prevent duplicates
// Set up the onDetected handler
Quagga.onDetected(processBarcode); Quagga.onDetected(processBarcode);
console.log("Cascade: startScanner/initLogic - Quagga.onDetected(processBarcode) RE-ATTACHED.");
console.log("Quagga.init() callback: Stream started. onDetected handler active.");
// Explicitly re-apply flash if toggle is on, after stream has started
const flashToggleEl = document.getElementById('flash-toggle');
if (flashToggleEl && flashToggleEl.checked) {
console.log("Cascade: startScanner/initLogic - Flash toggle is checked. Attempting to re-apply torch constraint post-start.");
if (Quagga.CameraAccess && Quagga.CameraAccess.getActiveTrack()) {
const track = Quagga.CameraAccess.getActiveTrack();
track.applyConstraints({ advanced: [{ torch: true }] })
.then(() => {
console.log("Cascade: startScanner/initLogic - Torch constraint explicitly re-applied successfully post-start.");
})
.catch(constraintErr => {
console.error("Cascade: startScanner/initLogic - Error explicitly re-applying torch constraint post-start:", constraintErr);
});
} else {
console.warn("Cascade: startScanner/initLogic - Flash toggle checked, but cannot get active track to re-apply torch constraint post-start.");
}
}
}); });
};
if (quaggaInitialized) {
console.log("startScanner: Restart requested. Stopping current Quagga instance.");
try {
Quagga.stop();
console.log("startScanner: Quagga.stop() called successfully during restart.");
} catch (e) {
console.warn("startScanner: Error during Quagga.stop(), proceeding with re-init attempt:", e);
}
quaggaInitialized = false; // Force re-init
console.log("startScanner: Delaying for 100ms before re-initializing Quagga for restart.");
setTimeout(() => {
console.log("startScanner: Delay finished. Calling initLogic for restart.");
initLogic();
}, 100); // 100ms delay
} else {
console.log("startScanner: Initial Quagga setup or forced re-init without prior true state.");
initLogic();
} }
} }
async function processBarcode(data) { async function processBarcode(data) {
if (isScanning) return; // Prevent further scans while a scan is being processed console.log("Cascade: processBarcode - Entered. Current isScanning state:", isScanning);
if (isScanning) {
console.log("Cascade: processBarcode - Exiting early because isScanning is true.");
return; // Prevent further scans while a scan is being processed
}
isScanning = true; // Set the scanning flag isScanning = true; // Set the scanning flag
console.log("Cascade: processBarcode - Set isScanning to true.");
const isbn = data.codeResult.code; const isbn = data.codeResult.code;
console.log("Detected ISBN:", isbn); console.log("Detected code:", isbn);
Quagga.stop(); // Stop the scanner once an ISBN is detected
// Validate ISBN length (10 or 13 digits)
if (!isbn || (isbn.length !== 10 && isbn.length !== 13)) {
console.log(`Cascade: processBarcode - Invalid code length: ${isbn.length}. Code: ${isbn}. Resuming scan.`);
// isScanning = false; // Reset isScanning to allow immediate re-scan by Quagga's internal loop if needed
// No need to explicitly set isScanning to false here if we don't stop Quagga.
// The initial check `if (isScanning)` at the top of processBarcode should handle concurrent calls.
// We want Quagga to continue, so we don't call Quagga.stop() or startScanner() here.
return; // Continue scanning
}
console.log("Validated ISBN:", isbn);
Quagga.stop(); // Stop the scanner once a VALID ISBN is detected
if (document.getElementById('confirm-mode').checked) { if (document.getElementById('confirm-mode').checked) {
// Confirm book in library // Confirm book in library
@ -110,20 +168,25 @@ async function fetchBookInfo(isbn) {
bookData.isbn2 = isbn; // Add the ISBN to the book data bookData.isbn2 = isbn; // Add the ISBN to the book data
promptUserWithBook(bookData); promptUserWithBook(bookData);
} else { } else {
console.log("No book data found. Restarting scanner..."); console.log("Cascade: fetchBookInfo - No book data. Resetting isScanning.");
isScanning = false; // Reset flag BEFORE restarting scanner
console.log("Cascade: fetchBookInfo - isScanning is now false. Calling startScanner...");
startScanner(); // Restart the scanner if no book information is found startScanner(); // Restart the scanner if no book information is found
} }
} catch (error) { } catch (error) {
console.error('Error fetching book data:', error); console.error('Error fetching book data:', error);
console.log("Cascade: fetchBookInfo - Error fetching. Resetting isScanning.");
isScanning = false; // Reset flag BEFORE restarting scanner
console.log("Cascade: fetchBookInfo - isScanning is now false (error path). Calling startScanner...");
startScanner(); // Restart the scanner on error as well startScanner(); // Restart the scanner on error as well
} }
} }
function promptUserWithBook(bookData) { function promptUserWithBook(bookData) {
// Prepare the information to display in the confirm dialog // Prepare the information to display in the confirm dialog
const title = bookData.title; const title = bookData.data.title;
const authors = bookData.authors ? bookData.authors.join(', ') : 'Unknown Author'; const authors = bookData.data.authors ? (Array.isArray(bookData.data.authors) ? bookData.data.authors.join(', ') : bookData.data.authors) : 'Unknown Author';
const description = bookData.description || 'No description available'; const description = bookData.data.description || 'No description available';
// Combine the information into a single message // Combine the information into a single message
const message = `Title: ${title}\nAuthor(s): ${authors}\nDescription: ${description}\n\nDo you want to add this book to the database?`; const message = `Title: ${title}\nAuthor(s): ${authors}\nDescription: ${description}\n\nDo you want to add this book to the database?`;
@ -184,12 +247,13 @@ async function confirmBookInLibrary(isbn) {
try { try {
const response = await fetch(`/book/confirm/${isbn}`); const response = await fetch(`/book/confirm/${isbn}`);
const bookData = await response.json(); const bookData = await response.json();
console.log("Cascade: confirmBookInLibrary - Response from /book/confirm:", bookData);
if (bookData.title) { if (bookData.data && bookData.data.title) {
// Display the book information and a success message // Display the book information and a success message
const title = bookData.title; const title = bookData.data.title;
const authors = bookData.authors ? bookData.authors.join(', ') : 'Unknown Author'; const authors = bookData.data.authors ? (Array.isArray(bookData.data.authors) ? bookData.data.authors.join(', ') : bookData.data.authors) : 'Unknown Author';
const description = bookData.description || 'No description available'; const description = bookData.data.description || 'No description available';
const message = `Title: ${title}\nAuthor(s): ${authors}\nDescription: ${description}\n\nBook found in the library!`; const message = `Title: ${title}\nAuthor(s): ${authors}\nDescription: ${description}\n\nBook found in the library!`;
@ -201,6 +265,9 @@ async function confirmBookInLibrary(isbn) {
console.error('Error confirming book in library:', error); console.error('Error confirming book in library:', error);
alert('An error occurred while confirming the book in the library.'); alert('An error occurred while confirming the book in the library.');
} finally { } finally {
console.log("Cascade: confirmBookInLibrary - finally block. Resetting isScanning.");
isScanning = false; // Reset flag BEFORE restarting scanner
console.log("Cascade: confirmBookInLibrary - isScanning is now false. Calling startScanner...");
startScanner(); // Restart the scanner after processing startScanner(); // Restart the scanner after processing
} }
} }
@ -295,4 +362,34 @@ function selectBook(index) {
window.onload = function() { window.onload = function() {
getCameras(); getCameras();
searchByTitle(''); searchByTitle('');
const flashToggle = document.getElementById('flash-toggle');
if (flashToggle) {
flashToggle.addEventListener('change', function() {
if (quaggaInitialized && Quagga.CameraAccess && Quagga.CameraAccess.getActiveTrack()) {
const track = Quagga.CameraAccess.getActiveTrack();
const constraints = { advanced: [{ torch: this.checked }] };
track.applyConstraints(constraints)
.then(() => {
console.log("Flash toggled via applyConstraints:", this.checked);
})
.catch(err => {
console.error("Error applying flash constraint dynamically:", err, ". Falling back to scanner restart.");
// Fallback: restart scanner
if (quaggaInitialized) {
Quagga.stop();
quaggaInitialized = false;
startScanner(); // This will pick up the new toggle state from the checkbox
}
});
} else if (quaggaInitialized) {
// If getActiveTrack is not available or Quagga is initialized but track isn't, restart scanner.
console.log("Flash toggled, getActiveTrack not available or other issue, restarting scanner for change to take effect.");
Quagga.stop();
quaggaInitialized = false; // Force re-init
startScanner(); // This will pick up the new toggle state
}
// If Quagga is not yet initialized, startScanner() will pick up the state when it's called.
});
}
}; };

110
public/style.css Normal file
View File

@ -0,0 +1,110 @@
/* Basic Modal Styling */
.modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1000; /* Sit on top */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity (backdrop) */
}
.modal-content {
background-color: #fefefe;
margin: 10% auto; /* 10% from the top and centered horizontally */
padding: 20px;
border: 1px solid #888;
width: 80%; /* Could be more or less, depending on screen size */
max-width: 700px; /* Max width for larger screens */
border-radius: 8px;
position: relative; /* For positioning the close button */
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);
}
/* Close Button */
.close-button {
color: #aaa;
float: right; /* Position to the top-right */
font-size: 28px;
font-weight: bold;
position: absolute; /* Position relative to modal-content */
top: 10px;
right: 20px;
}
.close-button:hover,
.close-button:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
/* Style for the book list entries on edit_books.html for better layout */
#book-list-container ul {
list-style-type: none;
padding: 0;
}
#book-list-container li {
border: 1px solid #ddd;
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
}
.book-entry {
display: flex; /* Use flexbox for alignment */
align-items: flex-start; /* Align items to the start of the cross axis */
}
.book-entry img {
margin-right: 15px;
flex-shrink: 0; /* Prevent image from shrinking */
}
.book-entry div {
flex-grow: 1; /* Allow text content to take up remaining space */
}
.book-entry button {
margin-left: 10px;
align-self: center; /* Center button vertically within the flex container */
}
/* Ensure modal input fields are a bit more organized */
.modal-content div {
margin-bottom: 10px;
}
.modal-content label {
display: block;
margin-bottom: 5px;
}
.modal-content input[type="text"],
.modal-content input[type="number"],
.modal-content textarea {
width: calc(100% - 22px); /* Adjust width to account for padding/border */
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box; /* Include padding and border in the element's total width and height */
}
.modal-content textarea {
resize: vertical;
min-height: 80px;
}
.modal-content button {
padding: 10px 15px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.modal-content button:hover {
background-color: #0056b3;
}
#modal-search-button {
margin-left: 5px;
}