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 = [
|
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'),
|
||||||
|
|
|
||||||
131
books/views.py
131
books/views.py
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
149
static/js/app.js
149
static/js/app.js
|
|
@ -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>
|
||||||
|
<span class="book-item-meta-row">
|
||||||
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
${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 & 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 & 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),
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue