diora-web/static/js/app/52-book-list.js
mrwnslz 6e8e5a0f7b
Some checks failed
Test / e2e (push) Waiting to run
Test / test (push) Waiting to run
Build and push Docker image / build (push) Has been cancelled
Bibliothek: nach Länge sortieren (kürzeste/längste zuerst); Seitenzahl beim Hochladen, für ältere Bücher einmal auf dem Gerät ermittelt; sw v150
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:28:41 +01:00

840 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// A book's folders: the list, or the single folder of older metas.
function _foldersOfMeta(meta) {
if (Array.isArray(meta.folders)) return meta.folders.filter(f => typeof f === 'string' && f.trim());
return meta.folder ? [meta.folder] : [];
}
window._foldersOfMeta = _foldersOfMeta;
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) {
// Drawn like any list: the page's header (with +) is how a first book gets in.
if (window.__dioraLibraryV2) { renderBookList([]); return; }
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 || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, meta_iv: b.meta_iv, has_cover: !!b.has_cover, 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 || '', folders: _foldersOfMeta(b), isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine, cover: b.cover, series: b.series, seriesIndex: b.seriesIndex, work: b.work, issue: b.issue, issueRead: b.issueRead, issueAt: b.issueAt};
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 || '',
folders: prevMeta.folders || _foldersOfMeta(prevMeta),
shelfTag: prevMeta.shelfTag || '',
magazine: prevMeta.magazine,
cover: prevMeta.cover,
series: prevMeta.series,
seriesIndex: prevMeta.seriesIndex,
work: prevMeta.work,
// A new file: its articles are read anew; what was read of them stays.
issueRead: prevMeta.issueRead,
issueAt: prevMeta.issueAt,
}));
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)
// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: a
// magazine is an issue marked as one (⋮ → Als Zeitschrift, kept in the encrypted meta),
// or, unmarked, a book in a folder called "Zeitschriften" (or "Magazine").
let _bookSection = 'books';
const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i;
const _isMagazine = b => b.magazine === true ||
(b.magazine !== false && _foldersOfMeta(b).some(f => _MAGAZINE_FOLDER.test(f.trim())));
window._isMagazine = _isMagazine;
// The periodical an issue belongs to: its title up to the issue's number ("Jacobin #24",
// "New Left Review 157", "LuXemburg 1/2026"), else its author ("Vol 9 – Issue 2–3" by Catalyst).
function _periodicalOf(b) {
const m = /^(.*?)\s*(?:#|\bno\.|\bnr\.|\bheft\b|\bausgabe\b|\bissue\b|\bvol\b|\d)/i.exec(b.title || '');
const name = (m ? m[1] : '').replace(/[\s–—:,-]+$/, '').trim();
return name.length >= 3 ? name : (b.author || '').trim() || (b.title || '').trim();
}
async function toggleBookMagazine(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
try {
await _updateBookMeta(bookId, {magazine: !_isMagazine(book)});
renderBookList(_lastBookListData);
} catch (e) {
customAlert('Das ließ sich nicht speichern: ' + e.message);
}
}
window.toggleBookMagazine = toggleBookMagazine;
// The generated cover instead of the book's own (or back): a choice per book.
async function toggleBookCover(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
try {
await _updateBookMeta(bookId, {cover: book.cover === 'generated' ? '' : 'generated'});
renderBookList(_lastBookListData);
} catch (e) {
customAlert('Das ließ sich nicht speichern: ' + e.message);
}
}
window.toggleBookCover = toggleBookCover;
// The books of the section shown (all of them where there are no sections).
function _bookSectionFilter(books) {
if (!window.DioraNav) return books;
return books.filter(b => (_bookSection === 'magazines') === _isMagazine(b));
}
window._bookSectionFilter = _bookSectionFilter;
window.__dioraBooksView = function (view) {
const next = view === 'magazines' ? 'magazines' : 'books';
if (next === _bookSection) return;
_bookSection = next;
_currentBookFolder = null;
if (_lastBookListData) renderBookList(_lastBookListData);
};
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 &amp; Notizen herunterladen</button>` : ''}
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
<button class="book-menu-item" onclick="toggleBookMagazine(${b.id})">${_isMagazine(b) ? '▤ Als Buch führen' : '📰 Als Zeitschrift führen'}</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(allBooks) {
const listEl = $('book-list');
if (!listEl) return;
_lastBookListData = allBooks;
const books = _bookSectionFilter(allBooks);
if (!books.length) {
listEl.innerHTML = window.DioraNav && allBooks.length
? (_bookSection === 'magazines'
? '<p class="muted">Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.</p>'
: '<p class="muted">Alle Bücher liegen bei den Zeitschriften.</p>')
: '';
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();
const magazines = _bookSection === 'magazines' && !!window.DioraNav;
for (const b of visible) {
const key = magazines ? _periodicalOf(b) : (b.folder || '');
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(b);
}
if (magazines) {
// A periodical with one issue is no tile; issues newest first.
const loose = grouped.get('') || [];
for (const [k, list] of [...grouped]) {
if (k !== '' && list.length < 2) { loose.push(...list); grouped.delete(k); }
}
grouped.set('', loose);
const issue = (a, b) => b.title.localeCompare(a.title, 'de', {numeric: true});
for (const list of grouped.values()) list.sort(issue);
}
// 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">${magazines ? '📰' : '📁'}</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 || '',
folders: cached.folders || book.folders || _foldersOfMeta(book),
shelfTag: cached.shelfTag || book.shelfTag || '',
pages: cached.pages || book.pages || 0,
magazine: cached.magazine !== undefined ? cached.magazine : book.magazine,
cover: cached.cover !== undefined ? cached.cover : book.cover,
series: cached.series !== undefined ? cached.series : book.series,
seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex,
// Editions of one work (53-library.js): the same key on each.
work: cached.work !== undefined ? cached.work : book.work,
// An issue's articles and what was read of them (54-issues.js).
issue: cached.issue !== undefined ? cached.issue : book.issue,
issueRead: cached.issueRead !== undefined ? cached.issueRead : book.issueRead,
issueAt: cached.issueAt !== undefined ? cached.issueAt : book.issueAt,
...overrides,
};
// The list decides; `folder` follows it (the first), for older clients.
if (overrides.folders) merged.folder = overrides.folders[0] || '';
else if ('folder' in overrides) merged.folders = overrides.folder ? [overrides.folder] : [];
const key = await getOrCreateEncKey();
// base_iv: the meta this change starts from. Changed elsewhere since (the server says 409
// and sends the current one): the change goes onto that one instead, once.
let toWrite = merged;
let base = book.meta_iv;
let data;
for (let attempt = 0; attempt < 2; attempt++) {
const metaEnc = await encryptBytes(key, new TextEncoder().encode(JSON.stringify(toWrite)));
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, ...(base ? {base_iv: base} : {})}),
});
data = await res.json().catch(() => ({}));
if (res.status === 409 && data.meta_ct && attempt === 0) {
const now = JSON.parse(new TextDecoder().decode(await decryptBytes(key, data.meta_iv, data.meta_ct)));
toWrite = {...now, ...overrides};
if (overrides.folders) toWrite.folder = overrides.folders[0] || '';
else if ('folder' in overrides) toWrite.folders = overrides.folder ? [overrides.folder] : [];
base = data.meta_iv;
continue;
}
if (data.ok) { book.meta_iv = metaEnc.iv; break; }
throw new Error(res.status === 409 ? 'inzwischen anderswo geändert' : 'Serverfehler');
}
// What the server has now, all of it (from the rebase too: a title set elsewhere shows).
Object.assign(book, toWrite, {folder: toWrite.folder || '', folders: _foldersOfMeta(toWrite)});
bookMetaCache[bookId] = toWrite;
_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 files = Array.from(input.files || []);
input.value = '';
if (files.length === 1) uploadEbook(files[0]);
else if (files.length) _importMany(files.map(f => ({name: f.name, size: f.size, buf: () => f.arrayBuffer()})));
}
// Several books, one after the other (a picked set of files, a cloud folder): each is
// read, encrypted and sent like a single one; a book already in the library (same title
// and author) is left out. statusEl shows how far it is and, at the end, what came of it.
async function _importMany(items, statusEl) {
statusEl = statusEl || $('book-upload-status');
const done = {ok: 0, dup: 0, failed: [], big: []};
const retry = [];
const wait = ms => new Promise(r => setTimeout(r, ms));
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.size && it.size > DIORA_CONFIG.ebookMaxBytes) { done.big.push(it.name); continue; }
// A failure is tried again, a little later each time: the server may be restarting
// (an update) or the connection gone for a moment — not a reason to lose the rest.
let r = false;
for (let attempt = 0; attempt < 4 && r === false; attempt++) {
if (attempt) {
if (statusEl) statusEl.textContent = `Buch ${i + 1} von ${items.length}: „${it.name}“ – neuer Versuch …`;
await wait([0, 3000, 8000, 20000][attempt]);
}
if (statusEl && !attempt) statusEl.textContent = `Buch ${i + 1} von ${items.length}: „${it.name}“ …`;
try {
r = await _importEbookBuffer(await it.buf(), it.name, null, {reload: false, skipKnown: true});
} catch (e) {
r = false;
}
}
if (r === 'dup') done.dup++;
else if (r) done.ok++;
else { done.failed.push(it.name); retry.push(it); }
}
const mb = Math.round(DIORA_CONFIG.ebookMaxBytes / 1024 / 1024);
const parts = [`✓ ${done.ok} ${done.ok === 1 ? 'Buch' : 'Bücher'} hinzugefügt`];
if (done.dup) parts.push(`${done.dup} schon da`);
if (done.big.length) parts.push(`zu groß (über ${mb} MB): ${done.big.join(', ')}`);
if (done.failed.length) parts.push(`nicht geklappt: ${done.failed.join(', ')}`);
if (statusEl) {
statusEl.textContent = parts.join(' · ');
if (retry.length) {
const again = document.createElement('button');
again.type = 'button';
again.className = 'btn btn-sm';
again.style.marginLeft = '8px';
again.textContent = `${retry.length} erneut versuchen`;
again.onclick = () => _importMany(retry, statusEl);
statusEl.appendChild(again);
}
}
if (done.ok) loadBookList();
return done;
}
// The series an EPUB names itself: calibre's <meta name="calibre:series" content> with
// calibre:series_index, or EPUB 3's belongs-to-collection with group-position. Or null.
function _seriesFromOpf(opf) {
const metas = Array.from(opf.getElementsByTagName('meta'));
const named = n => metas.filter(m => m.getAttribute('name') === n)[0];
const cs = named('calibre:series');
if (cs && cs.getAttribute('content')) {
const ix = named('calibre:series_index');
const n = ix ? parseFloat(ix.getAttribute('content')) : NaN;
return {name: cs.getAttribute('content').trim(), index: isFinite(n) ? n : null};
}
const coll = metas.filter(m => m.getAttribute('property') === 'belongs-to-collection')[0];
if (coll && coll.textContent.trim()) {
const id = coll.getAttribute('id');
const pos = id ? metas.filter(m => m.getAttribute('refines') === '#' + id && m.getAttribute('property') === 'group-position')[0] : null;
const n = pos ? parseFloat(pos.textContent) : NaN;
return {name: coll.textContent.trim(), index: isFinite(n) ? n : null};
}
return null;
}
// Is a book with this title and author in the library already?
function _knownBook(title, author) {
const n = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
return (_lastBookListData || []).some(b => n(b.title) === n(title) && n(b.author) === n(author));
}
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 files = Array.from(e.dataTransfer.files || []);
if (files.length === 1) uploadEbook(files[0]);
else if (files.length) _importMany(files.map(f => ({name: f.name, size: f.size, buf: () => f.arrayBuffer()})));
});
}
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, opts) {
opts = opts || {};
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 = '';
let series, seriesIndex;
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);
const sr = _seriesFromOpf(opfDoc);
if (sr) { series = sr.name; seriesIndex = sr.index; }
}
} catch (e) { /* use filename as title */ }
}
if (opts.skipKnown && _knownBook(title, author)) return 'dup';
const key = await getOrCreateEncKey();
const meta = {title, author, filename, type, isbn, folder: '', shelfTag: ''};
try { meta.pages = await bookPagesOfFile(buf.slice(0), type) || undefined; } catch (e) { /* the reader fills it in */ }
if (series) { meta.series = series; if (seriesIndex != null) meta.seriesIndex = seriesIndex; }
const metaJson = new TextEncoder().encode(JSON.stringify(meta));
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`;
if (opts.reload !== false) loadBookList();
else _lastBookListData.push({title, author}); // known for the next of a set
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;
}
}