'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 = '

Bücher werden geladen …

'; try { listEl.innerHTML = '

Bücherliste wird vom Server geholt …

'; _flushProgressOutbox(); // own positions the server doesn't have yet const res = await fetch('/books/', {cache: 'no-store'}); _noteServerNowHeader(res); if (!res.ok) { listEl.innerHTML = `

Serverfehler ${res.status} beim Laden der Bücher.

`; return; } const books = await res.json(); _evictBookCache(books); // fire-and-forget if (!Array.isArray(books)) { listEl.innerHTML = `

Unerwartete Antwort vom Server.

`; 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 = '

Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.

'; return; } listEl.innerHTML = `

${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …

`; let key; try { key = await getOrCreateEncKey(); } catch (e) { listEl.innerHTML = `

Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.

`; 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, 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, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, 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, 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 = `

Fehler beim Laden der Bücher: ${e.message}

`; } } } 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, // 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 ? '⚠ falscher Schlüssel' : ''; const broken = _brokenBooks.has(b.id); return `
${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} ${escapeHtml(b.author)} ${pct > 0 ? `${pct} % gelesen${b.pages ? ` (≈ ${Math.max(1, Math.round(b.pages * pct / 100))} von ${b.pages} Seiten)` : ''}` : (b.pages ? `ca. ${b.pages} Seiten` : '')} ${b.shelfTag ? `${escapeHtml(b.shelfTag)}` : ''}
${broken ? `` : ''} ${b.has_highlights ? `` : ''}
`; } 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' ? '

Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.

' : '

Alle Bücher liegen bei den Zeitschriften.

') : ''; 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 += '
' + '

Zuletzt gelesen

' + recent.map(_renderBookItemHtml).join('') + '
'; } const visible = _bookShowRead ? books : books.filter(b => !b.is_read); if (!visible.length) { html += books.length ? '

Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.

' : ''; 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 += '
' + folderNames.map(name => ``).join('') + '
'; } html += (grouped.get('') || []).map(_renderBookItemHtml).join(''); } else { html += `
${escapeHtml(_currentBookFolder)}
`; 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, // 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(); 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, {folder: merged.folder, folders: merged.folders}); 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 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 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: ''}; 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; } }