631 lines
28 KiB
JavaScript
631 lines
28 KiB
JavaScript
|
|
'use strict';
|
|||
|
|
|
|||
|
|
async function loadBookList() {
|
|||
|
|
if (!IS_AUTHENTICATED) return;
|
|||
|
|
const listEl = $('book-list');
|
|||
|
|
if (!listEl) return;
|
|||
|
|
listEl.innerHTML = '<p class="muted">Bücher werden geladen …</p>';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
listEl.innerHTML = '<p class="muted">Bücherliste wird vom Server geholt …</p>';
|
|||
|
|
_flushProgressOutbox(); // own positions the server doesn't have yet
|
|||
|
|
const res = await fetch('/books/', {cache: 'no-store'});
|
|||
|
|
_noteServerNowHeader(res);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
listEl.innerHTML = `<p class="muted">Serverfehler ${res.status} beim Laden der Bücher.</p>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const books = await res.json();
|
|||
|
|
_evictBookCache(books); // fire-and-forget
|
|||
|
|
if (!Array.isArray(books)) {
|
|||
|
|
listEl.innerHTML = `<p class="muted">Unerwartete Antwort vom Server.</p>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!books.length) {
|
|||
|
|
listEl.innerHTML = '<p class="muted">Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
listEl.innerHTML = `<p class="muted">${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …</p>`;
|
|||
|
|
|
|||
|
|
let key;
|
|||
|
|
try {
|
|||
|
|
key = await getOrCreateEncKey();
|
|||
|
|
} catch (e) {
|
|||
|
|
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const decrypted = [];
|
|||
|
|
for (const b of books) {
|
|||
|
|
try {
|
|||
|
|
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
|||
|
|
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
|||
|
|
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0};
|
|||
|
|
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
|||
|
|
} catch (e) {
|
|||
|
|
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
|
|||
|
|
decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// An own position the server doesn't have yet (sent above) shows if it's the newer one.
|
|||
|
|
for (let i = 0; i < decrypted.length; i++) decrypted[i] = _withPendingProgress(decrypted[i]);
|
|||
|
|
|
|||
|
|
decrypted.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || b.uploaded_at.localeCompare(a.uploaded_at));
|
|||
|
|
_saveBookMeta(decrypted); // persist for offline
|
|||
|
|
renderBookList(decrypted);
|
|||
|
|
} catch (e) {
|
|||
|
|
// Network error — try offline fallback
|
|||
|
|
const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress);
|
|||
|
|
if (cachedBooks.length) {
|
|||
|
|
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || ''));
|
|||
|
|
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0};
|
|||
|
|
const uploadArea = $('book-upload-area');
|
|||
|
|
if (uploadArea) uploadArea.style.display = 'none';
|
|||
|
|
renderBookList(cachedBooks);
|
|||
|
|
const notice = document.createElement('p');
|
|||
|
|
notice.className = 'muted offline-notice';
|
|||
|
|
notice.textContent = 'Offline — gespeicherte Bücher. Fortschritt wird beim nächsten Öffnen synchronisiert.';
|
|||
|
|
listEl.prepend(notice);
|
|||
|
|
} else {
|
|||
|
|
if (listEl) listEl.innerHTML = `<p class="muted">Fehler beim Laden der Bücher: ${e.message}</p>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const _brokenBooks = new Set();
|
|||
|
|
|
|||
|
|
function markBookBroken(bookId) {
|
|||
|
|
_brokenBooks.add(bookId);
|
|||
|
|
const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`);
|
|||
|
|
if (!item) return;
|
|||
|
|
if (!item.querySelector('.book-broken-btn')) {
|
|||
|
|
const btn = document.createElement('button');
|
|||
|
|
btn.className = 'btn btn-sm btn-danger book-broken-btn';
|
|||
|
|
btn.title = 'Buch konnte nicht geöffnet werden — Datei erneut hochladen';
|
|||
|
|
btn.textContent = '!';
|
|||
|
|
btn.onclick = () => repairBook(bookId);
|
|||
|
|
item.querySelector('.book-item-actions').prepend(btn);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _extractIsbnFromOpf(opfDoc) {
|
|||
|
|
const identifiers = opfDoc.querySelectorAll('metadata > identifier, metadata > *|identifier');
|
|||
|
|
for (const el of identifiers) {
|
|||
|
|
const digits = (el.textContent || '').replace(/[^0-9Xx]/g, '');
|
|||
|
|
if (digits.length === 10 || digits.length === 13) return digits;
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function repairBook(bookId) {
|
|||
|
|
const input = document.createElement('input');
|
|||
|
|
input.type = 'file';
|
|||
|
|
input.accept = '.epub,.pdf';
|
|||
|
|
input.onchange = async () => {
|
|||
|
|
const file = input.files[0];
|
|||
|
|
if (!file) return;
|
|||
|
|
const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`);
|
|||
|
|
const btn = item?.querySelector('.book-broken-btn');
|
|||
|
|
if (btn) btn.textContent = '…';
|
|||
|
|
try {
|
|||
|
|
const key = await getOrCreateEncKey();
|
|||
|
|
const buf = await file.arrayBuffer();
|
|||
|
|
const isPdf = /\.pdf$/i.test(file.name);
|
|||
|
|
const type = isPdf ? 'pdf' : 'epub';
|
|||
|
|
|
|||
|
|
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
|
|||
|
|
try {
|
|||
|
|
if (isPdf) {
|
|||
|
|
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
|||
|
|
const meta = await pdfDoc.getMetadata();
|
|||
|
|
title = meta.info?.Title?.trim() || title;
|
|||
|
|
author = meta.info?.Author?.trim() || '';
|
|||
|
|
} else {
|
|||
|
|
const zip = await JSZip.loadAsync(buf.slice(0));
|
|||
|
|
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
|||
|
|
const opfPath = new DOMParser()
|
|||
|
|
.parseFromString(containerXml, 'application/xml')
|
|||
|
|
.querySelector('rootfile')?.getAttribute('full-path');
|
|||
|
|
if (opfPath) {
|
|||
|
|
const opfDoc = new DOMParser().parseFromString(
|
|||
|
|
await zip.file(opfPath).async('text'), 'application/xml');
|
|||
|
|
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
|
|||
|
|
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
|
|||
|
|
isbn = _extractIsbnFromOpf(opfDoc);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (e) { /* keep filename as title */ }
|
|||
|
|
|
|||
|
|
// Preserve folder/shelfTag assigned before this repair — replacing the book's
|
|||
|
|
// data shouldn't silently wipe organization the user already set up.
|
|||
|
|
const prevMeta = bookMetaCache[bookId] || {};
|
|||
|
|
const metaJson = new TextEncoder().encode(JSON.stringify({
|
|||
|
|
title, author, filename: file.name, type,
|
|||
|
|
isbn: isbn || prevMeta.isbn || '',
|
|||
|
|
folder: prevMeta.folder || '',
|
|||
|
|
shelfTag: prevMeta.shelfTag || '',
|
|||
|
|
}));
|
|||
|
|
const [metaEnc, dataEnc] = await Promise.all([
|
|||
|
|
encryptBytes(key, metaJson),
|
|||
|
|
encryptBytes(key, buf),
|
|||
|
|
]);
|
|||
|
|
const res = await fetch(`/books/${bookId}/replace-data/`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
data_ct: dataEnc.ciphertext, data_iv: dataEnc.iv,
|
|||
|
|
meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
if (!(await res.json()).ok) throw new Error('Serverfehler');
|
|||
|
|
await _evictCachedBook(bookId);
|
|||
|
|
_brokenBooks.delete(bookId);
|
|||
|
|
if (btn) btn.remove();
|
|||
|
|
openBook(bookId);
|
|||
|
|
} catch (e) {
|
|||
|
|
if (btn) btn.textContent = '!';
|
|||
|
|
await customAlert('Reparatur fehlgeschlagen: ' + e.message);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
input.click();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let _lastBookListData = [];
|
|||
|
|
const _bookShowReadKey = 'diora_book_show_read';
|
|||
|
|
let _bookShowRead = localStorage.getItem(_bookShowReadKey) === '1';
|
|||
|
|
|
|||
|
|
function _onBookShowReadToggle(checked) {
|
|||
|
|
_bookShowRead = checked;
|
|||
|
|
localStorage.setItem(_bookShowReadKey, checked ? '1' : '0');
|
|||
|
|
renderBookList(_lastBookListData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleBookRead(bookId, currentlyRead) {
|
|||
|
|
const verb = currentlyRead ? 'als ungelesen markieren' : 'als gelesen markieren';
|
|||
|
|
if (!await customConfirm(`Dieses Buch ${verb}?`)) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/books/${bookId}/read/`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|||
|
|
body: JSON.stringify({is_read: !currentlyRead}),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!data.ok) throw new Error('Serverfehler');
|
|||
|
|
const book = _lastBookListData.find(b => b.id === bookId);
|
|||
|
|
if (book) book.is_read = data.is_read;
|
|||
|
|
renderBookList(_lastBookListData);
|
|||
|
|
} catch (e) {
|
|||
|
|
await customAlert('Konnte Lesestatus nicht ändern: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the
|
|||
|
|
// server never sees plaintext folder names — no schema change needed, and it rides along for
|
|||
|
|
// free in /api/sync/. One level only: folders don't nest.
|
|||
|
|
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
|
|||
|
|
|
|||
|
|
function _recentlyReadBooks(books) {
|
|||
|
|
return books
|
|||
|
|
.filter(b => b.last_read)
|
|||
|
|
.sort((a, b) => b.last_read.localeCompare(a.last_read))
|
|||
|
|
.slice(0, 7);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _renderBookItemHtml(b) {
|
|||
|
|
const pct = Math.round((b.scroll_fraction || 0) * 100);
|
|||
|
|
const keyWarning = b.keyOk === false ? '<span title="Falscher Schlüssel – importiere den richtigen Schlüssel, um dieses Buch zu öffnen" style="color:var(--accent,#e63946);margin-left:4px;">⚠ falscher Schlüssel</span>' : '';
|
|||
|
|
const broken = _brokenBooks.has(b.id);
|
|||
|
|
return `<div class="book-item" data-book-id="${b.id}">
|
|||
|
|
<div class="book-item-info">
|
|||
|
|
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
|
|||
|
|
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
|||
|
|
<span class="book-item-meta-row">
|
|||
|
|
${pct > 0 ? `<span class="muted book-progress">${pct} % gelesen${b.pages ? ` (≈ ${Math.max(1, Math.round(b.pages * pct / 100))} von ${b.pages} Seiten)` : ''}</span>` : (b.pages ? `<span class="muted book-progress">ca. ${b.pages} Seiten</span>` : '')}
|
|||
|
|
${b.shelfTag ? `<span class="book-shelf-badge" title="Regal-Kategorie">${escapeHtml(b.shelfTag)}</span>` : ''}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="book-item-actions">
|
|||
|
|
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Erst den richtigen Schlüssel importieren"' : ''}>Öffnen</button>
|
|||
|
|
<div class="book-item-menu">
|
|||
|
|
<button class="btn btn-sm book-menu-toggle" title="Weitere Optionen" onclick="toggleBookMenu(this)">⋮</button>
|
|||
|
|
<div class="book-item-menu-list">
|
|||
|
|
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
|
|||
|
|
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen & Notizen herunterladen</button>` : ''}
|
|||
|
|
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
|
|||
|
|
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷️ Metadaten abrufen (Regal)</button>
|
|||
|
|
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
|
|||
|
|
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toggleBookMenu(toggleBtn) {
|
|||
|
|
// Looked up relative to the clicked button (not by id) because the same book — and
|
|||
|
|
// thus the same rendered menu — can appear twice at once: once in the "Zuletzt
|
|||
|
|
// gelesen" section and again further down in the folder/unfiled list.
|
|||
|
|
const menu = toggleBtn.closest('.book-item-menu')?.querySelector('.book-item-menu-list');
|
|||
|
|
if (!menu) return;
|
|||
|
|
const willOpen = !menu.classList.contains('open');
|
|||
|
|
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
|
|||
|
|
if (willOpen) menu.classList.add('open');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Close any open book menu on an outside click, or right after an item inside it is
|
|||
|
|
// clicked (the toggle button manages its own open/close state above, so it's excluded).
|
|||
|
|
document.addEventListener('click', (e) => {
|
|||
|
|
if (e.target.closest('.book-menu-toggle')) return;
|
|||
|
|
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function _openBookFolder(name) {
|
|||
|
|
_currentBookFolder = name;
|
|||
|
|
renderBookList(_lastBookListData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderBookList(books) {
|
|||
|
|
const listEl = $('book-list');
|
|||
|
|
if (!listEl) return;
|
|||
|
|
_lastBookListData = books;
|
|||
|
|
|
|||
|
|
if (!books.length) {
|
|||
|
|
listEl.innerHTML = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let html = '';
|
|||
|
|
|
|||
|
|
// Always-visible "recently read" section, independent of folder navigation and the
|
|||
|
|
// read/unread filter below — it's a shortcut back into whatever was open last. Same
|
|||
|
|
// vertical list-item look as the rest of the list (not a card shelf).
|
|||
|
|
const recent = _recentlyReadBooks(books);
|
|||
|
|
if (recent.length) {
|
|||
|
|
html += '<div class="book-recent-section">'
|
|||
|
|
+ '<h3 class="book-recent-title">Zuletzt gelesen</h3>'
|
|||
|
|
+ recent.map(_renderBookItemHtml).join('')
|
|||
|
|
+ '</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
|
|||
|
|
if (!visible.length) {
|
|||
|
|
html += books.length
|
|||
|
|
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
|
|||
|
|
: '';
|
|||
|
|
listEl.innerHTML = html;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const grouped = new Map();
|
|||
|
|
for (const b of visible) {
|
|||
|
|
const key = b.folder || '';
|
|||
|
|
if (!grouped.has(key)) grouped.set(key, []);
|
|||
|
|
grouped.get(key).push(b);
|
|||
|
|
}
|
|||
|
|
// Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root.
|
|||
|
|
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
|
|||
|
|
_currentBookFolder = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (_currentBookFolder === null) {
|
|||
|
|
const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de'));
|
|||
|
|
if (folderNames.length) {
|
|||
|
|
html += '<div class="book-folder-grid">'
|
|||
|
|
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
|
|||
|
|
<span class="book-folder-tile-icon">📁</span>
|
|||
|
|
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
|
|||
|
|
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
|
|||
|
|
</button>`).join('')
|
|||
|
|
+ '</div>';
|
|||
|
|
}
|
|||
|
|
html += (grouped.get('') || []).map(_renderBookItemHtml).join('');
|
|||
|
|
} else {
|
|||
|
|
html += `<div class="book-folder-header">
|
|||
|
|
<button class="btn btn-sm" onclick="_openBookFolder(null)">← Alle Ordner</button>
|
|||
|
|
<strong>${escapeHtml(_currentBookFolder)}</strong>
|
|||
|
|
</div>`;
|
|||
|
|
html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
listEl.innerHTML = html;
|
|||
|
|
listEl.querySelectorAll('.book-folder-tile').forEach(tile => {
|
|||
|
|
tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given
|
|||
|
|
// field overrides applied on top of whatever is already cached, and pushes it to the server.
|
|||
|
|
async function _updateBookMeta(bookId, overrides) {
|
|||
|
|
const book = _lastBookListData.find(b => b.id === bookId);
|
|||
|
|
if (!book) return false;
|
|||
|
|
const cached = bookMetaCache[bookId] || {};
|
|||
|
|
const merged = {
|
|||
|
|
title: cached.title || book.title,
|
|||
|
|
author: cached.author || book.author,
|
|||
|
|
filename: cached.filename || '',
|
|||
|
|
type: cached.type || book.type || 'epub',
|
|||
|
|
isbn: cached.isbn || book.isbn || '',
|
|||
|
|
folder: cached.folder || book.folder || '',
|
|||
|
|
shelfTag: cached.shelfTag || book.shelfTag || '',
|
|||
|
|
pages: cached.pages || book.pages || 0,
|
|||
|
|
...overrides,
|
|||
|
|
};
|
|||
|
|
const key = await getOrCreateEncKey();
|
|||
|
|
const metaJson = new TextEncoder().encode(JSON.stringify(merged));
|
|||
|
|
const metaEnc = await encryptBytes(key, metaJson);
|
|||
|
|
const res = await fetch(`/books/${bookId}/meta/`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|||
|
|
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!data.ok) throw new Error('Serverfehler');
|
|||
|
|
Object.assign(book, overrides);
|
|||
|
|
bookMetaCache[bookId] = merged;
|
|||
|
|
_saveBookMeta(_lastBookListData);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function assignBookFolder(bookId) {
|
|||
|
|
const book = _lastBookListData.find(b => b.id === bookId);
|
|||
|
|
if (!book) return;
|
|||
|
|
const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'));
|
|||
|
|
const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : '';
|
|||
|
|
const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || '');
|
|||
|
|
if (result === null) return;
|
|||
|
|
const folder = result.trim();
|
|||
|
|
if (folder === (book.folder || '')) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await _updateBookMeta(bookId, {folder});
|
|||
|
|
if (folder) _currentBookFolder = folder;
|
|||
|
|
renderBookList(_lastBookListData);
|
|||
|
|
} catch (e) {
|
|||
|
|
await customAlert('Konnte Ordner nicht ändern: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback for books uploaded before ISBN extraction existed (or repaired/re-encrypted
|
|||
|
|
// without it): pulls the already-accessible encrypted book bytes, decrypts and unzips just
|
|||
|
|
// far enough to read the OPF identifiers — same source openBook() itself parses — without
|
|||
|
|
// doing a full reader render. EPUB only; PDFs never carried a reliable ISBN via pdf.js.
|
|||
|
|
async function _extractIsbnFromBookFile(bookId) {
|
|||
|
|
try {
|
|||
|
|
const key = await getOrCreateEncKey();
|
|||
|
|
let data_ct, data_iv;
|
|||
|
|
const cached = await _getCachedBook(bookId);
|
|||
|
|
if (cached) {
|
|||
|
|
({data_ct, data_iv} = cached);
|
|||
|
|
} else {
|
|||
|
|
const res = await fetch(`/books/${bookId}/data/`);
|
|||
|
|
({data_ct, data_iv} = await res.json());
|
|||
|
|
}
|
|||
|
|
const plain = await decryptBytes(key, data_iv, data_ct);
|
|||
|
|
const zip = await JSZip.loadAsync(plain);
|
|||
|
|
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
|||
|
|
const opfPath = new DOMParser()
|
|||
|
|
.parseFromString(containerXml, 'application/xml')
|
|||
|
|
.querySelector('rootfile')?.getAttribute('full-path');
|
|||
|
|
if (!opfPath) return '';
|
|||
|
|
const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
|
|||
|
|
return _extractIsbnFromOpf(opfDoc);
|
|||
|
|
} catch (e) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function _fetchShelfLookup(isbn, title, author) {
|
|||
|
|
const params = new URLSearchParams();
|
|||
|
|
if (isbn) params.set('isbn', isbn);
|
|||
|
|
if (title) params.set('title', title);
|
|||
|
|
if (author) params.set('author', author);
|
|||
|
|
const res = await fetch(`/books/metadata-lookup/?${params.toString()}`);
|
|||
|
|
return res.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Manually triggered (never automatic) — looks up which library shelf/DDC category this
|
|||
|
|
// book falls under via the server-side DNB→Open Library proxy, purely as an organizational
|
|||
|
|
// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is
|
|||
|
|
// allowed to briefly touch the server with a plaintext ISBN/title/author.
|
|||
|
|
//
|
|||
|
|
// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g.
|
|||
|
|
// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for
|
|||
|
|
// EPUBs, (2) server-side title/author text search (less precise — could match the wrong
|
|||
|
|
// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN
|
|||
|
|
// by hand (from the book's cover/an online store) and retry once.
|
|||
|
|
async function lookupBookMetadata(bookId) {
|
|||
|
|
const book = _lastBookListData.find(b => b.id === bookId);
|
|||
|
|
if (!book) return;
|
|||
|
|
const cached = bookMetaCache[bookId] || {};
|
|||
|
|
let isbn = cached.isbn || book.isbn || '';
|
|||
|
|
const isPdf = (cached.type || book.type) === 'pdf';
|
|||
|
|
const title = cached.title || book.title || '';
|
|||
|
|
const author = cached.author || book.author || '';
|
|||
|
|
|
|||
|
|
if (!isbn && !isPdf) {
|
|||
|
|
// Older upload without a stored ISBN — extract it from the book file itself (may take
|
|||
|
|
// a moment for large books, since it downloads/decrypts the full file if not cached).
|
|||
|
|
isbn = await _extractIsbnFromBookFile(bookId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
let data = await _fetchShelfLookup(isbn, title, author);
|
|||
|
|
|
|||
|
|
if (!data.label) {
|
|||
|
|
const manual = await customPrompt(
|
|||
|
|
'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', ''
|
|||
|
|
);
|
|||
|
|
const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, '');
|
|||
|
|
if (manualIsbn) {
|
|||
|
|
isbn = manualIsbn;
|
|||
|
|
data = await _fetchShelfLookup(isbn, title, author);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Persist whatever we ended up with either way, so a repeat lookup never needs to
|
|||
|
|
// re-extract/re-search from scratch.
|
|||
|
|
const overrides = {};
|
|||
|
|
if (isbn) overrides.isbn = isbn;
|
|||
|
|
if (data.label) overrides.shelfTag = data.label;
|
|||
|
|
if (Object.keys(overrides).length) {
|
|||
|
|
await _updateBookMeta(bookId, overrides);
|
|||
|
|
renderBookList(_lastBookListData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!data.label) {
|
|||
|
|
await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bookFileSelected(input) {
|
|||
|
|
const file = input.files[0];
|
|||
|
|
if (!file) return;
|
|||
|
|
uploadEbook(file);
|
|||
|
|
input.value = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initBookDropZone() {
|
|||
|
|
// Prevent Firefox from opening dragged files when dropped outside the zone
|
|||
|
|
document.addEventListener('dragover', e => e.preventDefault());
|
|||
|
|
document.addEventListener('drop', e => {
|
|||
|
|
const zone = $('book-drop-zone');
|
|||
|
|
if (!zone || !zone.contains(e.target)) e.preventDefault();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const zone = $('book-drop-zone');
|
|||
|
|
if (!zone) return;
|
|||
|
|
|
|||
|
|
zone.addEventListener('dragover', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
zone.classList.add('drag-over');
|
|||
|
|
});
|
|||
|
|
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
|||
|
|
zone.addEventListener('drop', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
zone.classList.remove('drag-over');
|
|||
|
|
const file = e.dataTransfer.files[0];
|
|||
|
|
if (file) uploadEbook(file);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deriveAndStoreKey() {
|
|||
|
|
const pwInput = document.getElementById('enc-key-password');
|
|||
|
|
const statusEl = $('enc-key-status');
|
|||
|
|
const pw = pwInput ? pwInput.value : '';
|
|||
|
|
if (!pw) { if (statusEl) statusEl.textContent = 'Bitte gib dein Passwort ein.'; return; }
|
|||
|
|
|
|||
|
|
if (statusEl) statusEl.textContent = 'Schlüssel wird abgeleitet …';
|
|||
|
|
try {
|
|||
|
|
const enc = new TextEncoder();
|
|||
|
|
const username = document.querySelector('meta[name="username"]')?.content || '';
|
|||
|
|
const mat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']);
|
|||
|
|
const key = await crypto.subtle.deriveKey(
|
|||
|
|
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
|
|||
|
|
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
|
|||
|
|
);
|
|||
|
|
const raw = await crypto.subtle.exportKey('raw', key);
|
|||
|
|
const storageKey = `diora_enc_key_${window.USER_ID || 0}`;
|
|||
|
|
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
|||
|
|
_encKey = null; // reset cached key
|
|||
|
|
if (statusEl) statusEl.textContent = '✓ Entsperrt';
|
|||
|
|
const prompt = $('enc-key-prompt');
|
|||
|
|
const uploadArea = $('book-upload-area');
|
|||
|
|
if (prompt) prompt.style.display = 'none';
|
|||
|
|
if (uploadArea) uploadArea.style.display = '';
|
|||
|
|
loadBookList();
|
|||
|
|
} catch (err) {
|
|||
|
|
if (statusEl) statusEl.textContent = 'Fehler: ' + err.message;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function uploadEbook(file) {
|
|||
|
|
const statusEl = $('book-upload-status');
|
|||
|
|
if (file.size > DIORA_CONFIG.ebookMaxBytes) {
|
|||
|
|
if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Shared tail of every import path (local file, cloud): parse metadata, encrypt
|
|||
|
|
// with the user's key and POST ciphertext. Plaintext never leaves the browser.
|
|||
|
|
async function _importEbookBuffer(buf, filename, statusEl) {
|
|||
|
|
const isPdf = /\.pdf$/i.test(filename);
|
|||
|
|
const isEpub = /\.epub$/i.test(filename);
|
|||
|
|
if (!isPdf && !isEpub) {
|
|||
|
|
if (statusEl) statusEl.textContent = 'Nur .epub- und .pdf-Dateien werden unterstützt.';
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) {
|
|||
|
|
if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (statusEl) statusEl.textContent = 'Verschlüssele …';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
let title = filename.replace(/\.(epub|pdf)$/i, '');
|
|||
|
|
let author = '';
|
|||
|
|
let isbn = '';
|
|||
|
|
const type = isPdf ? 'pdf' : 'epub';
|
|||
|
|
|
|||
|
|
if (isPdf) {
|
|||
|
|
try {
|
|||
|
|
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
|||
|
|
const meta = await pdfDoc.getMetadata();
|
|||
|
|
title = meta.info?.Title?.trim() || title;
|
|||
|
|
author = meta.info?.Author?.trim() || '';
|
|||
|
|
} catch (e) { /* use filename as title */ }
|
|||
|
|
} else {
|
|||
|
|
try {
|
|||
|
|
const zip = await JSZip.loadAsync(buf.slice(0));
|
|||
|
|
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
|||
|
|
const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml');
|
|||
|
|
const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path');
|
|||
|
|
if (opfPath) {
|
|||
|
|
const opfText = await zip.file(opfPath).async('text');
|
|||
|
|
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
|
|||
|
|
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
|
|||
|
|
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
|
|||
|
|
isbn = _extractIsbnFromOpf(opfDoc);
|
|||
|
|
}
|
|||
|
|
} catch (e) { /* use filename as title */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const key = await getOrCreateEncKey();
|
|||
|
|
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''}));
|
|||
|
|
const [metaEnc, dataEnc] = await Promise.all([
|
|||
|
|
encryptBytes(key, metaJson),
|
|||
|
|
encryptBytes(key, buf),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
if (statusEl) statusEl.textContent = 'Lade hoch …';
|
|||
|
|
|
|||
|
|
const res = await fetch('/books/upload/', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
meta_ct: metaEnc.ciphertext,
|
|||
|
|
meta_iv: metaEnc.iv,
|
|||
|
|
data_ct: dataEnc.ciphertext,
|
|||
|
|
data_iv: dataEnc.iv,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.ok) {
|
|||
|
|
if (statusEl) statusEl.textContent = `✓ „${title}“ hochgeladen`;
|
|||
|
|
loadBookList();
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
if (statusEl) statusEl.textContent = 'Fehler: ' + (data.error ? _germanError(data.error) : 'Hochladen fehlgeschlagen');
|
|||
|
|
return false;
|
|||
|
|
} catch (e) {
|
|||
|
|
if (statusEl) statusEl.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|