diora-web/static/js/app/52-book-list.js

856 lines
40 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
// 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) {
if (e.name === 'DioraLocked') {
listEl.innerHTML = '<p class="muted">Deine Bücher sind auf diesem Gerät noch gesperrt. <button class="btn" type="button" data-unlock-books>Entsperren</button></p>';
listEl.querySelector('[data-unlock-books]')?.addEventListener('click', () => {
dioraUnlock({force: true}).then(() => loadBookList(), () => {});
});
return;
}
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) {
PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet) - 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker (_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts- verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel; ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite. - 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen Seite (eine fast leere Seite wird nicht unendlich groß); nächste und vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn' gemeldet (E-Ink-Refresh). - pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf, Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab). - Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten immer „Seite 1“). - Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu, wenn er ruht. - openBook hängt seine Listener über _readerListen an; closeReader nimmt sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und ein zweites Buch geben das alte Dokument frei; der entschlüsselte PDF-Puffer wird nicht mehr gehalten. - Invertieren dreht die Farbtöne zurück (Fotos keine Negative). - e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten, Outline, Links und einer fast leeren Seite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00
const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
try {
const meta = await pdfDoc.getMetadata();
title = meta.info?.Title?.trim() || title;
author = meta.info?.Author?.trim() || '';
} finally {
try { pdfDoc.destroy(); } catch (e) {}
}
} 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 || '',
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
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;
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
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 || '',
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
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,
};
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
// 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 {
PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet) - 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker (_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts- verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel; ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite. - 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen Seite (eine fast leere Seite wird nicht unendlich groß); nächste und vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn' gemeldet (E-Ink-Refresh). - pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf, Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab). - Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten immer „Seite 1“). - Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu, wenn er ruht. - openBook hängt seine Listener über _readerListen an; closeReader nimmt sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und ein zweites Buch geben das alte Dokument frei; der entschlüsselte PDF-Puffer wird nicht mehr gehalten. - Invertieren dreht die Farbtöne zurück (Fotos keine Negative). - e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten, Outline, Links und einer fast leeren Seite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00
const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
try {
const meta = await pdfDoc.getMetadata();
title = meta.info?.Title?.trim() || title;
author = meta.info?.Author?.trim() || '';
} finally {
try { pdfDoc.destroy(); } catch (e) {}
}
} 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;
}
}