From 3e301b6f61f00c8770e922a46e6ca9badb915745 Mon Sep 17 00:00:00 2001 From: marwin Date: Mon, 17 Aug 2026 11:27:16 +0200 Subject: [PATCH] =?UTF-8?q?B=C3=BCcher:=20Regal-Metadaten-Lookup=20(DNB=20?= =?UTF-8?q?=E2=86=92=20Open=20Library)=20+=20Drei-Punkte-Men=C3=BC=20(SW?= =?UTF-8?q?=20v37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj --- books/urls.py | 1 + books/views.py | 131 +++++++++++++++++++++++++++++++++++++++ diora/settings.py | 1 + static/css/app.css | 56 +++++++++++++++++ static/js/app.js | 151 +++++++++++++++++++++++++++++++++++---------- static/js/sw.js | 2 +- 6 files changed, 307 insertions(+), 35 deletions(-) diff --git a/books/urls.py b/books/urls.py index 0d3b782..e17e99f 100644 --- a/books/urls.py +++ b/books/urls.py @@ -4,6 +4,7 @@ from . import views urlpatterns = [ path('', views.book_list, name='book_list'), path('upload/', views.upload_book, name='upload_book'), + path('metadata-lookup/', views.lookup_book_metadata, name='lookup_book_metadata'), path('/data/', views.get_book_data, name='get_book_data'), path('/delete/', views.delete_book, name='delete_book'), path('/read/', views.set_book_read, name='set_book_read'), diff --git a/books/views.py b/books/views.py index 15d1445..f400531 100644 --- a/books/views.py +++ b/books/views.py @@ -1,7 +1,9 @@ import base64 import json import re +import xml.etree.ElementTree as ET +import requests from django.conf import settings from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt @@ -16,6 +18,135 @@ def _require_auth(request): 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): """Split a position anchor 'blockIndex:innerFraction' into (block, inner). diff --git a/diora/settings.py b/diora/settings.py index f1d0571..53f6905 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -108,6 +108,7 @@ BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB VOLUME_DEFAULT = 204 # out of 255 ITUNES_TIMEOUT = 6 # seconds +BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup) PODCAST_INBOX_PAGE_SIZE = 200 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/static/css/app.css b/static/css/app.css index 3ed7abd..a2f0264 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1583,8 +1583,14 @@ body.dnd-mode .timer-display { .book-progress { font-size: 12px; } +.book-item-meta-row { + display: flex; + align-items: center; + gap: 8px; +} .book-item-actions { display: flex; + align-items: center; gap: 6px; flex-shrink: 0; } @@ -1592,6 +1598,56 @@ body.dnd-mode .timer-display { font-size: 12px; 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 { display: flex; align-items: center; diff --git a/static/js/app.js b/static/js/app.js index b7157a7..dacfef4 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3249,11 +3249,11 @@ async function loadBookList() { 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 || ''}; - 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}); + 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 || '', 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) { - bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: ''}; - 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}); + 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: '', 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. @@ -3292,7 +3292,7 @@ async function loadBookList() { if (b.last_read) return 1; 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'); if (uploadArea) uploadArea.style.display = 'none'; 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) { const input = document.createElement('input'); input.type = 'file'; @@ -3338,7 +3347,7 @@ function repairBook(bookId) { const isPdf = /\.pdf$/i.test(file.name); 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 { if (isPdf) { 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'); 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 */ } - 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([ encryptBytes(key, metaJson), encryptBytes(key, buf), @@ -3435,19 +3453,43 @@ function _renderBookItemHtml(b) {
${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} ${escapeHtml(b.author)} - ${pct > 0 ? `${pct}% read` : ''} + + ${pct > 0 ? `${pct}% read` : ''} + ${b.shelfTag ? `${escapeHtml(b.shelfTag)}` : ''} +
- ${broken ? `` : ''} - ${b.has_highlights ? `` : ''} - - - +
+ +
+ ${broken ? `` : ''} + ${b.has_highlights ? `` : ''} + + + + +
+
`; } +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) { _currentBookFolder = name; 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) { const book = _lastBookListData.find(b => b.id === bookId); if (!book) return; @@ -3536,26 +3610,7 @@ async function assignBookFolder(bookId) { if (folder === (book.folder || '')) return; try { - const key = await getOrCreateEncKey(); - 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); + await _updateBookMeta(bookId, {folder}); if (folder) _currentBookFolder = folder; renderBookList(_lastBookListData); } 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) { const file = input.files[0]; if (!file) return; @@ -3644,6 +3725,7 @@ async function uploadEbook(file) { let title = file.name.replace(/\.(epub|pdf)$/i, ''); let author = ''; + let isbn = ''; const type = isPdf ? 'pdf' : 'epub'; if (isPdf) { @@ -3664,12 +3746,13 @@ async function uploadEbook(file) { const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; + isbn = _extractIsbnFromOpf(opfDoc); } } catch (e) { /* use filename as title */ } } const key = await getOrCreateEncKey(); - const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: 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([ encryptBytes(key, metaJson), encryptBytes(key, buf), diff --git a/static/js/sw.js b/static/js/sw.js index 31d3d26..5017b17 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * 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 SHELL = [ '/static/css/app.css',