diff --git a/books/views.py b/books/views.py index f400531..121a604 100644 --- a/books/views.py +++ b/books/views.py @@ -120,29 +120,112 @@ def _lookup_openlibrary_shelf(isbn): return None +def _cql_phrase(value): + # CQL string literals are quoted; strip embedded quotes rather than escaping them + # (this only feeds a lookup heuristic, not a stored/displayed value). + return value.replace('"', ' ').strip() + + +def _lookup_dnb_shelf_by_title(title, author): + url = 'https://services.dnb.de/sru/dnb' + query = f'dnb.tit="{_cql_phrase(title)}"' + if author: + query += f' and dnb.per="{_cql_phrase(author)}"' + params = { + 'version': '1.1', + 'operation': 'searchRetrieve', + 'query': query, + '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_by_title(title, author): + url = 'https://openlibrary.org/search.json' + params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'} + if author: + params['author'] = author + resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6)) + resp.raise_for_status() + docs = resp.json().get('docs') or [] + if not docs: + return None + doc = docs[0] + ddc = doc.get('ddc') or [] + if ddc: + label = _ddc_label(ddc[0]) + if label: + return label + subjects = doc.get('subject') or [] + if subjects: + return subjects[0] + 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): + isbn_raw = request.GET.get('isbn', '').strip() + title = request.GET.get('title', '').strip() + author = request.GET.get('author', '').strip() + isbn = re.sub(r'[^0-9Xx]', '', isbn_raw) + + if isbn_raw and len(isbn) not in (10, 13): return JsonResponse({'error': 'invalid ISBN'}, status=400) + if not isbn and not title: + return JsonResponse({'error': 'isbn or title required'}, status=400) label = None source = None - try: - label = _lookup_dnb_shelf(isbn) - source = 'dnb' if label else None - except Exception: - pass - if not label: + + # ISBN is the precise path — try it first when we have one. + if isbn: try: - label = _lookup_openlibrary_shelf(isbn) - source = 'openlibrary' if label else None + label = _lookup_dnb_shelf(isbn) + if label: + source = 'dnb' except Exception: pass + if not label: + try: + label = _lookup_openlibrary_shelf(isbn) + if label: + source = 'openlibrary' + except Exception: + pass + + # No ISBN (or it drew a blank) — fall back to a title/author text search. Less + # precise (wrong edition/translation is possible), so the source is tagged + # distinctly for the client to hint at that if it wants to. + if not label and title: + try: + label = _lookup_dnb_shelf_by_title(title, author) + if label: + source = 'dnb-title' + except Exception: + pass + if not label: + try: + label = _lookup_openlibrary_shelf_by_title(title, author) + if label: + source = 'openlibrary-title' + except Exception: + pass return JsonResponse({'label': label, 'source': source}) diff --git a/static/js/app.js b/static/js/app.js index 30c3917..1baa782 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3647,40 +3647,66 @@ async function _extractIsbnFromBookFile(bookId) { } } +async function _fetchShelfLookup(isbn, title, author) { + const params = new URLSearchParams(); + if (isbn) params.set('isbn', isbn); + if (title) params.set('title', title); + if (author) params.set('author', author); + const res = await fetch(`/books/metadata-lookup/?${params.toString()}`); + return res.json(); +} + // Manually triggered (never automatic) — looks up which library shelf/DDC category this // book falls under via the server-side DNB→Open Library proxy, purely as an organizational // hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is -// allowed to briefly touch the server with a plaintext ISBN. +// allowed to briefly touch the server with a plaintext ISBN/title/author. +// +// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g. +// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for +// EPUBs, (2) server-side title/author text search (less precise — could match the wrong +// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN +// by hand (from the book's cover/an online store) and retry once. async function lookupBookMetadata(bookId) { const book = _lastBookListData.find(b => b.id === bookId); if (!book) return; const cached = bookMetaCache[bookId] || {}; let isbn = cached.isbn || book.isbn || ''; const isPdf = (cached.type || book.type) === 'pdf'; + const title = cached.title || book.title || ''; + const author = cached.author || book.author || ''; - if (!isbn) { - if (isPdf) { - await customAlert('Keine ISBN gespeichert — bei PDFs wird sie nicht automatisch erkannt.'); - return; - } + if (!isbn && !isPdf) { // Older upload without a stored ISBN — extract it from the book file itself (may take // a moment for large books, since it downloads/decrypts the full file if not cached). isbn = await _extractIsbnFromBookFile(bookId); - if (!isbn) { - await customAlert('Keine ISBN in diesem Buch gefunden.'); - return; - } } try { - const res = await fetch(`/books/metadata-lookup/?isbn=${encodeURIComponent(isbn)}`); - const data = await res.json(); - // Persist the (possibly newly-found) ISBN either way, so a repeat lookup never needs - // to re-download/decrypt the book file again. - await _updateBookMeta(bookId, data.label ? {isbn, shelfTag: data.label} : {isbn}); - renderBookList(_lastBookListData); + let data = await _fetchShelfLookup(isbn, title, author); + if (!data.label) { - await customAlert('Keine Regal-Kategorie gefunden (weder bei der DNB noch bei Open Library). Die ISBN wurde für spätere Versuche gespeichert.'); + const manual = await customPrompt( + 'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', '' + ); + const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, ''); + if (manualIsbn) { + isbn = manualIsbn; + data = await _fetchShelfLookup(isbn, title, author); + } + } + + // Persist whatever we ended up with either way, so a repeat lookup never needs to + // re-extract/re-search from scratch. + const overrides = {}; + if (isbn) overrides.isbn = isbn; + if (data.label) overrides.shelfTag = data.label; + if (Object.keys(overrides).length) { + await _updateBookMeta(bookId, overrides); + renderBookList(_lastBookListData); + } + + if (!data.label) { + await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).'); } } catch (e) { await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message); diff --git a/static/js/sw.js b/static/js/sw.js index a683b55..a445d84 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-v38'; +const CACHE = 'diora-v39'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css',