Bücher: Regal-Metadaten-Lookup (DNB → Open Library) + Drei-Punkte-Menü (SW v37)
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:
parent
7d99e58286
commit
3e301b6f61
6 changed files with 307 additions and 35 deletions
|
|
@ -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('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
||||
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
||||
path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
|
||||
|
|
|
|||
131
books/views.py
131
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
151
static/js/app.js
151
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) {
|
|||
<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>
|
||||
${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 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 & 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 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 & 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>`;
|
||||
}
|
||||
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue