Bücher: Regal-Metadaten-Lookup (DNB → Open Library) + Drei-Punkte-Menü (SW v37)
All checks were successful
Build and push Docker image / build (push) Successful in 20s
Test / test (push) Successful in 34s

Manuell per neuem "Metadaten"-Menüpunkt ausgelöst (nie automatisch): der Server
schlägt die aus dem EPUB extrahierte ISBN zuerst bei der DNB (SRU, DDC-Sachgruppe),
dann bei Open Library nach und gibt nur ein kurzes Label zurück, ohne etwas zu
speichern — das Label landet als Badge nur im ohnehin verschlüsselten Meta-Blob des
Clients. Das ist eine bewusste, eng begrenzte Ausnahme vom "Server sieht nie
Buchinhalte"-Prinzip: die Verschlüsselung dient vor allem der Absicherung gegen
Piraterie-Vorwürfe, nicht striktem Zero-Knowledge gegenüber dem eigenen Server.

Da pro Buch jetzt Reparieren/Herunterladen/Ordner/Metadaten/Lesestatus/Löschen
zusammenkommen, wandern alle Aktionen außer "Open" in ein Drei-Punkte-Menü.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
This commit is contained in:
marwin 2026-08-17 11:27:16 +02:00
parent 7d99e58286
commit 3e301b6f61
6 changed files with 307 additions and 35 deletions

View file

@ -4,6 +4,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path('', views.book_list, name='book_list'), path('', views.book_list, name='book_list'),
path('upload/', views.upload_book, name='upload_book'), path('upload/', views.upload_book, name='upload_book'),
path('metadata-lookup/', views.lookup_book_metadata, name='lookup_book_metadata'),
path('<int:pk>/data/', views.get_book_data, name='get_book_data'), path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
path('<int:pk>/delete/', views.delete_book, name='delete_book'), path('<int:pk>/delete/', views.delete_book, name='delete_book'),
path('<int:pk>/read/', views.set_book_read, name='set_book_read'), path('<int:pk>/read/', views.set_book_read, name='set_book_read'),

View file

@ -1,7 +1,9 @@
import base64 import base64
import json import json
import re import re
import xml.etree.ElementTree as ET
import requests
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -16,6 +18,135 @@ def _require_auth(request):
return None return None
# ---------------------------------------------------------------------------
# Library-shelf metadata lookup (DNB, falling back to Open Library)
#
# Only ever called on explicit user request (the "Metadaten" book action), never
# automatically — the server briefly sees the plaintext ISBN for this one proxied
# request, which is a deliberate, narrow exception to the "server never sees book
# content" rule (see CLAUDE.md), made because the encryption's real purpose here is
# to keep the operator from being able to see what's on the platform (piracy
# liability), not strict user privacy — an ISBN lookup against public library
# catalogs doesn't undermine that. Nothing from this lookup is persisted server-side;
# the resulting label is stored only in the client's encrypted meta blob.
# ---------------------------------------------------------------------------
# DNB "Sachgruppen der Deutschen Nationalbibliografie" / DDC divisions (hundreds -> tens),
# used to turn a raw DDC notation like "833.92" into a short shelf label.
_DDC_DIVISIONS = {
'000': 'Allgemeines, Informatik', '010': 'Bibliografien', '020': 'Bibliotheks- und Informationswissenschaft',
'030': 'Enzyklopädien', '050': 'Zeitschriften, fortlaufende Sammelwerke', '060': 'Organisationen, Museumswissenschaft',
'070': 'Nachrichtenmedien, Journalismus, Verlagswesen', '090': 'Handschriften, seltene Bücher',
'100': 'Philosophie', '130': 'Parapsychologie, Okkultismus', '150': 'Psychologie',
'200': 'Religion', '230': 'Christentum, Christliche Theologie', '290': 'Andere Religionen',
'300': 'Sozialwissenschaften, Soziologie', '310': 'Statistiken', '320': 'Politikwissenschaft',
'330': 'Wirtschaft', '340': 'Recht', '350': 'Öffentliche Verwaltung, Militärwissenschaft',
'360': 'Soziale Probleme, Sozialdienste, Versicherungen', '370': 'Erziehung, Schul- und Bildungswesen',
'380': 'Handel, Kommunikation, Verkehr', '390': 'Gebräuche, Etikette, Folklore',
'400': 'Sprache, Linguistik', '420': 'Englisch', '430': 'Deutsch, Germanische Sprachen',
'440': 'Französisch, Romanische Sprachen', '450': 'Italienisch, Rumänisch, Rätoromanisch',
'460': 'Spanisch, Portugiesisch', '470': 'Latein, Italische Sprachen', '480': 'Griechisch', '490': 'Andere Sprachen',
'500': 'Naturwissenschaften', '510': 'Mathematik', '520': 'Astronomie', '530': 'Physik', '540': 'Chemie',
'550': 'Geowissenschaften', '560': 'Paläontologie', '570': 'Biowissenschaften, Biologie',
'580': 'Pflanzen (Botanik)', '590': 'Tiere (Zoologie)',
'600': 'Technik', '610': 'Medizin, Gesundheit', '620': 'Ingenieurwissenschaften', '630': 'Landwirtschaft',
'640': 'Hauswirtschaft', '650': 'Management', '660': 'Chemische Technik', '670': 'Industrielle Fertigung',
'680': 'Fertigung für spezielle Zwecke', '690': 'Hausbau, Bauhandwerk',
'700': 'Künste', '710': 'Landschaftsgestaltung, Raumplanung', '720': 'Architektur',
'730': 'Plastik, Keramik, Metallkunst', '740': 'Zeichnung, angewandte Kunst', '750': 'Malerei',
'760': 'Grafik, Druckgrafik, Fotografie', '780': 'Musik', '790': 'Freizeit, Darstellende Kunst, Sport',
'800': 'Literatur', '810': 'Amerikanische Literatur', '820': 'Englische Literatur',
'830': 'Deutsche Literatur', '840': 'Französische Literatur', '850': 'Italienische Literatur',
'860': 'Spanische, Portugiesische Literatur', '870': 'Lateinische Literatur', '880': 'Griechische Literatur',
'890': 'Literaturen in anderen Sprachen',
'900': 'Geschichte', '910': 'Geografie, Reisen', '920': 'Biografie, Genealogie',
'930': 'Geschichte des Altertums', '940': 'Geschichte Europas', '950': 'Geschichte Asiens',
'960': 'Geschichte Afrikas', '970': 'Geschichte Nordamerikas', '980': 'Geschichte Südamerikas',
'990': 'Geschichte der übrigen Welt',
}
def _ddc_label(ddc_raw):
"""Coarsen a DDC notation ('833.92') down to its nearest known division/class label."""
digits = re.sub(r'\D', '', ddc_raw or '')
if len(digits) < 3:
return None
tens = digits[:2] + '0'
if tens in _DDC_DIVISIONS:
return _DDC_DIVISIONS[tens]
return _DDC_DIVISIONS.get(digits[0] + '00')
_MARC_NS = '{http://www.loc.gov/MARC21/slim}'
def _lookup_dnb_shelf(isbn):
url = 'https://services.dnb.de/sru/dnb'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': f'dnb.num={isbn}',
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf(isbn):
url = 'https://openlibrary.org/api/books'
params = {'bibkeys': f'ISBN:{isbn}', 'jscmd': 'data', 'format': 'json'}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
data = resp.json().get(f'ISBN:{isbn}', {})
ddc = (data.get('classifications') or {}).get('dewey_decimal_class') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = data.get('subjects') or []
if subjects:
return subjects[0].get('name')
return None
@require_http_methods(['GET'])
def lookup_book_metadata(request):
err = _require_auth(request)
if err:
return err
isbn = re.sub(r'[^0-9Xx]', '', request.GET.get('isbn', ''))
if len(isbn) not in (10, 13):
return JsonResponse({'error': 'invalid ISBN'}, status=400)
label = None
source = None
try:
label = _lookup_dnb_shelf(isbn)
source = 'dnb' if label else None
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf(isbn)
source = 'openlibrary' if label else None
except Exception:
pass
return JsonResponse({'label': label, 'source': source})
def _anchor_parts(anchor): def _anchor_parts(anchor):
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner). """Split a position anchor 'blockIndex:innerFraction' into (block, inner).

View file

@ -108,6 +108,7 @@ BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
VOLUME_DEFAULT = 204 # out of 255 VOLUME_DEFAULT = 204 # out of 255
ITUNES_TIMEOUT = 6 # seconds ITUNES_TIMEOUT = 6 # seconds
BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup)
PODCAST_INBOX_PAGE_SIZE = 200 PODCAST_INBOX_PAGE_SIZE = 200
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View file

@ -1583,8 +1583,14 @@ body.dnd-mode .timer-display {
.book-progress { .book-progress {
font-size: 12px; font-size: 12px;
} }
.book-item-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.book-item-actions { .book-item-actions {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0; flex-shrink: 0;
} }
@ -1592,6 +1598,56 @@ body.dnd-mode .timer-display {
font-size: 12px; font-size: 12px;
font-weight: normal; font-weight: normal;
} }
.book-shelf-badge {
display: inline-block;
font-size: 11px;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--muted, #888);
white-space: nowrap;
}
/* --- Per-book "⋮" menu (everything except Open) --- */
.book-item-menu {
position: relative;
}
.book-item-menu-list {
display: none;
flex-direction: column;
gap: 2px;
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 20;
min-width: 220px;
background: var(--surface, #111);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.book-item-menu-list.open {
display: flex;
}
.book-menu-item {
background: none;
border: none;
color: var(--fg);
text-align: left;
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
font: inherit;
font-size: 13px;
white-space: nowrap;
}
.book-menu-item:hover {
background: var(--bg-row, rgba(255, 255, 255, 0.08));
}
.book-menu-item--danger {
color: var(--accent, #e63946);
}
.book-list-filter { .book-list-filter {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -3249,11 +3249,11 @@ async function loadBookList() {
try { try {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf)); 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 || ''}; bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || ''};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_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) { } catch (e) {
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: ''}; bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', folder: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
} }
} }
// If local cache is further ahead than the server, push it and use it for display. // If local cache is further ahead than the server, push it and use it for display.
@ -3292,7 +3292,7 @@ async function loadBookList() {
if (b.last_read) return 1; if (b.last_read) return 1;
return (b.uploaded_at || '').localeCompare(a.uploaded_at || ''); return (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 || ''}; for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || ''};
const uploadArea = $('book-upload-area'); const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none'; if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks); renderBookList(cachedBooks);
@ -3322,6 +3322,15 @@ function markBookBroken(bookId) {
} }
} }
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) { function repairBook(bookId) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
@ -3338,7 +3347,7 @@ function repairBook(bookId) {
const isPdf = /\.pdf$/i.test(file.name); const isPdf = /\.pdf$/i.test(file.name);
const type = isPdf ? 'pdf' : 'epub'; const type = isPdf ? 'pdf' : 'epub';
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = ''; let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
try { try {
if (isPdf) { if (isPdf) {
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
@ -3356,11 +3365,20 @@ function repairBook(bookId) {
await zip.file(opfPath).async('text'), 'application/xml'); await zip.file(opfPath).async('text'), 'application/xml');
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
isbn = _extractIsbnFromOpf(opfDoc);
} }
} }
} catch (e) { /* keep filename as title */ } } catch (e) { /* keep filename as title */ }
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type})); // Preserve folder/shelfTag assigned before this repair — replacing the book's
// data shouldn't silently wipe organization the user already set up.
const prevMeta = bookMetaCache[bookId] || {};
const metaJson = new TextEncoder().encode(JSON.stringify({
title, author, filename: file.name, type,
isbn: isbn || prevMeta.isbn || '',
folder: prevMeta.folder || '',
shelfTag: prevMeta.shelfTag || '',
}));
const [metaEnc, dataEnc] = await Promise.all([ const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson), encryptBytes(key, metaJson),
encryptBytes(key, buf), encryptBytes(key, buf),
@ -3435,19 +3453,43 @@ function _renderBookItemHtml(b) {
<div class="book-item-info"> <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> <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="muted book-author">${escapeHtml(b.author)}</span>
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''} <span class="book-item-meta-row">
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
${b.shelfTag ? `<span class="book-shelf-badge" title="Regal-Kategorie">${escapeHtml(b.shelfTag)}</span>` : ''}
</span>
</div> </div>
<div class="book-item-actions"> <div class="book-item-actions">
${broken ? `<button class="btn btn-sm btn-danger book-broken-btn" title="Buch konnte nicht geöffnet werden — Datei erneut hochladen" onclick="repairBook(${b.id})">!</button>` : ''}
${b.has_highlights ? `<button class="btn btn-sm" title="Markierungen &amp; Notizen herunterladen" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳</button>` : ''}
<button class="btn btn-sm" title="Ordner zuweisen" onclick="assignBookFolder(${b.id})">📁</button>
<button class="btn btn-sm" title="${b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren'}" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺' : '✓'}</button>
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button> <button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button> <div class="book-item-menu">
<button class="btn btn-sm book-menu-toggle" title="Weitere Optionen" onclick="toggleBookMenu(${b.id})"></button>
<div class="book-item-menu-list" id="book-menu-${b.id}">
${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="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>
</div>`; </div>`;
} }
function toggleBookMenu(bookId) {
const menu = document.getElementById(`book-menu-${bookId}`);
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) { function _openBookFolder(name) {
_currentBookFolder = name; _currentBookFolder = name;
renderBookList(_lastBookListData); renderBookList(_lastBookListData);
@ -3525,6 +3567,38 @@ function renderBookList(books) {
}); });
} }
// Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given
// field overrides applied on top of whatever is already cached, and pushes it to the server.
async function _updateBookMeta(bookId, overrides) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return false;
const cached = bookMetaCache[bookId] || {};
const merged = {
title: cached.title || book.title,
author: cached.author || book.author,
filename: cached.filename || '',
type: cached.type || book.type || 'epub',
isbn: cached.isbn || book.isbn || '',
folder: cached.folder || book.folder || '',
shelfTag: cached.shelfTag || book.shelfTag || '',
...overrides,
};
const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify(merged));
const metaEnc = await encryptBytes(key, metaJson);
const res = await fetch(`/books/${bookId}/meta/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}),
});
const data = await res.json();
if (!data.ok) throw new Error('Server error');
Object.assign(book, overrides);
bookMetaCache[bookId] = merged;
_saveBookMeta(_lastBookListData);
return true;
}
async function assignBookFolder(bookId) { async function assignBookFolder(bookId) {
const book = _lastBookListData.find(b => b.id === bookId); const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return; if (!book) return;
@ -3536,26 +3610,7 @@ async function assignBookFolder(bookId) {
if (folder === (book.folder || '')) return; if (folder === (book.folder || '')) return;
try { try {
const key = await getOrCreateEncKey(); await _updateBookMeta(bookId, {folder});
const cached = bookMetaCache[bookId] || {};
const metaJson = new TextEncoder().encode(JSON.stringify({
title: cached.title || book.title,
author: cached.author || book.author,
filename: cached.filename || '',
type: cached.type || book.type || 'epub',
folder,
}));
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('Server error');
book.folder = folder;
bookMetaCache[bookId] = {...cached, folder};
_saveBookMeta(_lastBookListData);
if (folder) _currentBookFolder = folder; if (folder) _currentBookFolder = folder;
renderBookList(_lastBookListData); renderBookList(_lastBookListData);
} catch (e) { } catch (e) {
@ -3563,6 +3618,32 @@ async function assignBookFolder(bookId) {
} }
} }
// 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.
async function lookupBookMetadata(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
const isbn = (bookMetaCache[bookId] || {}).isbn || book.isbn || '';
if (!isbn) {
await customAlert('Keine ISBN in den Metadaten dieses Buchs gefunden (nur bei neueren Uploads automatisch erkannt).');
return;
}
try {
const res = await fetch(`/books/metadata-lookup/?isbn=${encodeURIComponent(isbn)}`);
const data = await res.json();
if (!data.label) {
await customAlert('Keine Regal-Kategorie gefunden (weder bei der DNB noch bei Open Library).');
return;
}
await _updateBookMeta(bookId, {shelfTag: data.label});
renderBookList(_lastBookListData);
} catch (e) {
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);
}
}
function bookFileSelected(input) { function bookFileSelected(input) {
const file = input.files[0]; const file = input.files[0];
if (!file) return; if (!file) return;
@ -3644,6 +3725,7 @@ async function uploadEbook(file) {
let title = file.name.replace(/\.(epub|pdf)$/i, ''); let title = file.name.replace(/\.(epub|pdf)$/i, '');
let author = ''; let author = '';
let isbn = '';
const type = isPdf ? 'pdf' : 'epub'; const type = isPdf ? 'pdf' : 'epub';
if (isPdf) { if (isPdf) {
@ -3664,12 +3746,13 @@ async function uploadEbook(file) {
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
isbn = _extractIsbnFromOpf(opfDoc);
} }
} catch (e) { /* use filename as title */ } } catch (e) { /* use filename as title */ }
} }
const key = await getOrCreateEncKey(); const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type})); const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type, isbn, folder: '', shelfTag: ''}));
const [metaEnc, dataEnc] = await Promise.all([ const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson), encryptBytes(key, metaJson),
encryptBytes(key, buf), encryptBytes(key, buf),

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * diora service worker caches the app shell for offline use.
*/ */
const CACHE = 'diora-v36'; const CACHE = 'diora-v37';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',