diff --git a/.env.example b/.env.example index 721d9679..adde01c1 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,9 @@ GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false # acceptable in your environment. All other sources use HTTPS. LIBRARY_OF_CONGRESS_IMPORT_SEARCH_ENABLED=false +# Deutsche Nationalbibliothek (German National Library) - no API key needed, HTTPS +DNB_IMPORT_SEARCH_ENABLED=true + # Cover scraper settings THALIA_COVER_SEARCH_ENABLED=false # For research only. Do not use in production without understanding the legal implications of scraping. diff --git a/.gitignore b/.gitignore index 703e72d3..3bf39411 100644 --- a/.gitignore +++ b/.gitignore @@ -234,6 +234,7 @@ node_modules/ *-snapshot.md # AI tools +/AGENTS.md /.memsearch /.playwright-mcp /.sverklo diff --git a/README.md b/README.md index 6e475f7a..c9f0be61 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Open **http://localhost:8001** and create your account. ## Why LibrisLog? - **Your data, your rules.** Fully self-hosted under MIT license — no ads, no user tracking, no vendor lock-in. A single SQLite file you can back up anytime. -- **No API keys required.** Four sources work out of the box: Open Library, BnF, Inventaire.io, and the Library of Congress. BnF and Inventaire also find books that Open Library misses, which matters for non-English editions. Add Google Books or Hardcover.app tokens optionally (both are off in `.env.example`; set their key and their `*_IMPORT_SEARCH_ENABLED=true` to use them), and switch any source on or off individually. The Library of Congress endpoint is only reachable over plain HTTP, so it sends queries unencrypted unless you disable it — see [Configuration](https://docs.librislog.app/guide/configuration). +- **No API keys required.** Five sources work out of the box: Open Library, BnF, Inventaire.io, the Library of Congress, and the German National Library. BnF, Inventaire and the German National Library also find books that Open Library misses, which matters for non-English editions. Add Google Books or Hardcover.app tokens optionally (both are off in `.env.example`; set their key and their `*_IMPORT_SEARCH_ENABLED=true` to use them), and switch any source on or off individually. The Library of Congress endpoint is only reachable over plain HTTP, so it sends queries unencrypted unless you disable it — see [Configuration](https://docs.librislog.app/guide/configuration). - **Rich insights from day one.** Calendar heatmap, language/status/page distribution charts, books finished per month/year, top authors — all on your hardware. - **Multi-user from the start.** User roles (admin/user), optional OIDC SSO, per-user libraries. One instance works for your whole household or small group. - **Import any format you have.** Goodreads or Bookstats exports with automatic field mapping, generic CSV or Excel (XLSX) with per-field Python transforms, JSON, ZIP with covers. @@ -81,7 +81,7 @@ Open **http://localhost:8001** and create your account. - **Reading progress** — Page-level slider, full progress timeline per book with edit/history - **Statistics dashboard** — Calendar heatmap, distribution charts, books finished per period, top authors - **Reading streaks & goals** — Current and all-time longest reading streak, plus optional daily, monthly, and yearly goals -- **Book import** — Search Open Library, BnF, Inventaire.io, the Library of Congress, Hardcover.app, and Google Books in parallel, each source individually switchable. Results describing the same book are grouped so you can compare editions side by side before importing. Scan ISBN barcodes on mobile, collect candidates in a basket, or enter manually +- **Book import** — Search Open Library, BnF, Inventaire.io, the Library of Congress, the German National Library, Hardcover.app, and Google Books in parallel, each source individually switchable. Results describing the same book are grouped so you can compare editions side by side before importing. Scan ISBN barcodes on mobile, collect candidates in a basket, or enter manually - **Data portability** — Export as JSON, CSV, or ZIP with covers. Import from the Goodreads or Bookstats presets, or any CSV/Excel file with custom field mapping - **Cover management** — Automatic multi-source cover search with manual override, URL paste, or file upload - **Data hygiene** — Find and fix missing metadata (covers, page counts, authors) in bulk diff --git a/backend/app/config.py b/backend/app/config.py index 76bae68c..ea95b4ed 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -51,6 +51,7 @@ class Settings(BaseSettings): bnf_import_search_enabled: bool = True inventaire_import_search_enabled: bool = True library_of_congress_import_search_enabled: bool = True + dnb_import_search_enabled: bool = True embed_enabled: bool = True mail_server: str = "" mail_port: int = 587 diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 51431797..1f4a114b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -116,7 +116,7 @@ class BookImportCandidate(SQLModel): language: Optional[str] = None tags: Optional[str] = None blurb: Optional[str] = None - source: str # "open_library" | "google_books" | "hardcover" | "bnf" | "inventaire" | "library_of_congress" + source: str # "open_library" | "google_books" | "hardcover" | "bnf" | "inventaire" | "library_of_congress" | "dnb" class CoverCandidate(SQLModel): diff --git a/backend/app/services/book_import.py b/backend/app/services/book_import.py index 062ba965..9ae9dbeb 100644 --- a/backend/app/services/book_import.py +++ b/backend/app/services/book_import.py @@ -39,6 +39,7 @@ BNF_SRU_URL: str = "https://catalogue.bnf.fr/api/SRU" INVENTAIRE_ENTITIES_URL: str = "https://inventaire.io/api/entities/by-uris" LOC_SRU_URL: str = "http://lx2.loc.gov:210/LCDB" +DNB_SRU_URL: str = "https://services.dnb.de/sru/dnb" INVENTAIRE_COVER_BASE_URL: str = "https://inventaire.io/img/entities" @@ -103,7 +104,8 @@ class SourceBackendError(Exception): def __init__( self, source: Literal[ - "open_library", "google_books", "hardcover", "bnf", "inventaire", "library_of_congress" + "open_library", "google_books", "hardcover", "bnf", "inventaire", + "library_of_congress", "dnb" ], status_code: int | None = None, ) -> None: @@ -140,6 +142,10 @@ def _loc_enabled() -> bool: return settings.library_of_congress_import_search_enabled +def _dnb_enabled() -> bool: + return settings.dnb_import_search_enabled + + def _hc_enabled() -> bool: return settings.hardcover_import_search_enabled @@ -205,6 +211,12 @@ def _build_sources(hardcover_api_token: str) -> tuple[SourceSpec, ...]: enabled=_loc_enabled, search=_dispatch_loc, ), + SourceSpec( + stage="dnb", + display_name="Deutsche Nationalbibliothek", + enabled=_dnb_enabled, + search=_dispatch_dnb, + ), SourceSpec( stage="hardcover", display_name="Hardcover", @@ -253,6 +265,12 @@ async def _dispatch_loc( return await _search_loc(query, search_type, client) +async def _dispatch_dnb( + query: str, search_type: str, client: httpx.AsyncClient +) -> list[BookImportCandidate]: + return await _search_dnb(query, search_type, client) + + # ── Public API ──────────────────────────────────────────────────────────────── @@ -1789,6 +1807,394 @@ def _reverse_loc_author(raw: str) -> str: return name or raw.strip() +# ── Deutsche Nationalbibliothek (DNB) ───────────────────────────────────────── + +# The DNB SRU envelope puts the SRU namespace on the root as the default namespace, +# and each MARC21 record declares its own default namespace, so both have to be +# named explicitly to select them. +async def _search_dnb( + query: str, + search_type: str, + client: httpx.AsyncClient, +) -> list[BookImportCandidate]: + """Search the German National Library via its SRU interface. + + Raises: + SourceBackendError: On HTTP errors from the DNB. + """ + if search_type == "isbn": + try: + isbn13 = normalize_isbn(query) + except ValueError: + logger.warning("DNB invalid ISBN: %s", query) + return [] + query_str = f"num={isbn13}" + else: + term = _sanitize_cql_term(query) + if not term: + logger.debug("DNB: title query is empty after sanitizing") + return [] + # Quoted so a title containing "and" or "or" is not read as a CQL operator. + query_str = f'tit="{term}"' + + params: dict = { + "version": "1.1", + "operation": "searchRetrieve", + "query": query_str, + "recordSchema": "MARC21-xml", + "maximumRecords": 10, + } + + logger.debug("DNB request — url=%s params=%s", DNB_SRU_URL, params) + + try: + resp = await client.get(DNB_SRU_URL, params=params, headers={"User-Agent": _USER_AGENT}) + logger.debug("DNB response — status=%d body_size=%d bytes", + resp.status_code, len(resp.content)) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning("DNB HTTP error: %s %s", exc.response.status_code, exc.response.text[:200]) + raise SourceBackendError("dnb", exc.response.status_code) from exc + except httpx.HTTPError as exc: + logger.warning("DNB request failed: %s", exc) + raise SourceBackendError("dnb") from exc + + candidates = map_dnb_response(resp.text) + for c in candidates: + logger.debug(" DNB candidate: title=%r isbn=%r", c.title, c.isbn) + return candidates + + +def map_dnb_response(xml_text: str) -> list[BookImportCandidate]: + """Parse a DNB SRU MARC21 response into a BookImportCandidate list.""" + try: + # The payload comes from a remote server, so parse it with entity expansion and + # external entity resolution disabled. + root = SafeET.fromstring(xml_text) + except (ET.ParseError, DefusedXmlException) as exc: + logger.warning("DNB XML parse error: %s", exc) + return [] + + candidates: list[BookImportCandidate] = [] + for record in root.findall(".//srw:recordData/marc:record", _DNB_NAMESPACES): + candidate = map_dnb_record(record) + if candidate is not None: + candidates.append(candidate) + return candidates + + +def map_dnb_record(record: ET.Element) -> BookImportCandidate | None: + """Map a single DNB MARC21 record to a BookImportCandidate.""" + title, subtitle = _dnb_title(record) + if not title: + return None + + # 264 second indicator 1 is the publication statement; 4 is the copyright statement, + # which carries a different (often reprint) date and must not be used as the + # publication year. Older records use 260, which has no publication indicator. + publication = None + for tag in ("264", "260"): + for field in record.findall(f"marc:datafield[@tag='{tag}']", _DNB_NAMESPACES): + # A missing indicator is "no information", which in practice is a publication + # statement; an explicit " " is accepted as a last resort. + ind2 = field.get("ind2") + if tag == "264" and ind2 not in ("1", " ", None): + continue + publication = field + break + if publication is not None: + break + + publisher = None + if publication is not None: + publisher = _strip_dnb_brackets(_dnb_first(publication, "b")) + published_year = _dnb_publication_year( + _dnb_first(publication, "c") if publication is not None else None + ) + + page_count = None + extent = record.find("marc:datafield[@tag='300']", _DNB_NAMESPACES) + extent_text = _dnb_first(extent, "a") if extent is not None else None + if extent_text: + page_count = _parse_dnb_page_count(extent_text) + + # 041$a is the language of the text; $h names the language it was translated from. + language = None + language_field = record.find("marc:datafield[@tag='041']", _DNB_NAMESPACES) + if language_field is not None: + for value in _dnb_subfields(language_field).get("a", []): + language = _normalize_language_code(value.split()[0] if value else None) + if language: + break + if not language: + fixed = record.find("marc:controlfield[@tag='008']", _DNB_NAMESPACES) + if fixed is not None and fixed.text and len(fixed.text) >= 38: + language = _normalize_language_code(fixed.text[35:38]) + + authors = _dnb_authors(record) + blurb_field = record.find("marc:datafield[@tag='520']", _DNB_NAMESPACES) + + # The DNB SRU endpoint serves no cover images, so there is nothing to set here. + return BookImportCandidate( + title=title, + subtitle=subtitle, + author=authors[0] if authors else None, + authors=authors or None, + isbn=_extract_dnb_isbn(record), + cover_url=None, + publisher=publisher, + published_year=published_year, + page_count=page_count, + language=language, + tags=_dnb_subjects(record), + blurb=_dnb_first(blurb_field, "a") if blurb_field is not None else None, + source="dnb", + ) + + +_DNB_NAMESPACES: dict[str, str] = { + "srw": "http://www.loc.gov/zing/srw/", + "marc": "http://www.loc.gov/MARC21/slim", +} + +# DNB records wrap non-sorting characters in U+0098 (string start) and U+009C +# (string terminator) instead of using the MARC nonfiling indicator. Leaving them in +# would show up in the title as stray control characters. The whole C1 range is +# stripped so a stray marker anywhere in a subfield is handled, not just the +# documented pair. +_DNB_NONFILING_RE = re.compile("[\u0098-\u009f]") + +# MARC relator term codes for "this person wrote the book". A 1XX field that codes any +# other role (editor, translator, illustrator) is a contributor, not the author. +_DNB_AUTHOR_ROLES: frozenset[str] = frozenset({"aut", "cre"}) + +# Trailing punctuation on a catalogued name: the period in "Mann, Thomas." is noise, but +# the one in "Karl R." is a real initial. Only drop it when the final word is long enough +# not to be an initial. +_DNB_TRAILING_PERIOD_RE = re.compile(r"(?<=\S)\.\s*$") + + +def _drop_dnb_trailing_period(text: str) -> str: + """Remove cataloguing noise from the forename half of an inverted name. + + Applied only to the given side of a "Surname, Forename." entry, where a trailing + period is inversion punctuation rather than part of the name. A single-letter token + is an initial and keeps its period ("Karl R." -> "Karl R. Popper"); anything longer is + a full forename whose period is cataloguing noise ("Thomas." -> "Thomas Mann"). + + Never applied to a name that is not inverted: "Dr.", "Prof.", "MSc." and "BSc." are + titles and abbreviations, not inversion artifacts, and stripping them would mangle the + name. + """ + if not text.endswith("."): + return text + if len(text.rsplit(" ", 1)[-1].rstrip(".")) <= 1: + return text + return _DNB_TRAILING_PERIOD_RE.sub("", text) + + +# "1184 Seiten", "1.184 Seiten", "926 pages", "XXXIX, 601 S.". The number is either a +# plain digit run or digit groups joined by a single grouping separator, and must not sit +# directly behind another digit or separator. Three consequences, all deliberate: +# * no lazy quantifier next to \s*, so a long non-matching run cannot backtrack +# quadratically the way `[0-9][0-9.,\u00a0 ]*?` did; +# * a group is exactly three digits, so German "1.184" stays 1184 while a decimal +# "1.5" or a short group "1,23" is rejected instead of yielding a wrong page count; +# * the lookbehind stops the tail of "1.5" being read as a count of 5. +# Note this provider-local parser is deliberately separate from the shared +# `_parse_page_count`, which handles the "265 p." style used by the other sources. +_DNB_EXTENT_RE = re.compile( + r"(? str: + """Remove DNB non-sorting control markers and collapse the whitespace they leave.""" + return re.sub(r"\s+", " ", _DNB_NONFILING_RE.sub("", text)).strip() + + +def _dnb_subfields(field: ET.Element) -> dict[str, list[str]]: + """Group a MARC21 datafield's subfields by code, in document order.""" + grouped: dict[str, list[str]] = {} + for subfield in field.findall("marc:subfield", _DNB_NAMESPACES): + text = _strip_dnb_markers(subfield.text or "") + if text: + grouped.setdefault(subfield.get("code", ""), []).append(text) + return grouped + + +def _dnb_first(field: ET.Element, *codes: str) -> str | None: + """Return the first non-empty subfield value among the given codes. + + The field must not be None: every caller has already checked whether the MARC field + is present before reading a subfield from it. + """ + grouped = _dnb_subfields(field) + for code in codes: + for value in grouped.get(code, []): + if value: + return value + return None + + +def _strip_dnb_brackets(value: str | None) -> str | None: + """Unwrap a catalogued value the DNB flagged as supplied rather than transcribed. + + "[2026]" and "[Lisboa]" mean the cataloguer inferred these, and the brackets should + not reach the user. Applied to the publisher and the publication year. + + Deliberately NOT applied to the title: the Library of Congress documents titles such + as `24500$a[Man smoking at window]`, where the brackets are part of the title itself. + """ + if value is None: + return None + cleaned = value.strip() + if cleaned.startswith("[") and cleaned.endswith("]"): + cleaned = cleaned[1:-1].strip() + return cleaned or None + + +def _reverse_dnb_name(raw: str) -> str: + """Turn a catalogued "Surname, Forename" into "Forename Surname". + + The DNB indexes personal names inverted (MARC 1XX first indicator 1), which is the + one part of the format that is pure cataloguing convention rather than data. + """ + name = _strip_dnb_markers(raw).strip(" ,;") + if "," not in name: + # Not an inverted name, so there is no inversion punctuation to remove. Titles and + # abbreviations such as "Dr." or "MSc." must survive intact. + return name + surname, _, given = name.partition(",") + # strip(" ,;") above guarantees neither side is empty once trimmed again here. + surname = surname.strip().rstrip(".").strip() + given = _drop_dnb_trailing_period(given.strip()).strip() + return f"{given} {surname}".strip() or name + + +def _parse_dnb_page_count(text: str) -> Optional[int]: + """Pull the page count out of a DNB 300$a extent statement.""" + match = _DNB_EXTENT_RE.search(_strip_dnb_markers(text)) + if not match: + return None + # The pattern only ever captures digits and grouping separators, so the strip below + # always leaves a plain digit string. + count = int(re.sub(r"[.,\u00a0 ]", "", match.group(1))) + return count if count > 0 else None + + +def _dnb_publication_year(date: str | None) -> Optional[int]: + """Extract a four-digit publication year, tolerating catalogued ranges and suffixes.""" + if not date: + return None + match = re.search(r"(\d{4})", _strip_dnb_brackets(date) or "") + if not match: + return None + year = int(match.group(1)) + return year if 1000 <= year <= 2999 else None + + +def _dnb_authors(record: ET.Element) -> list[str]: + """Collect book authors from MARC 1XX and 700 fields, main entry first. + + The DNB puts the lead author in 1XX and co-authors in 700 added entries. Every 700 + it records carries a relator code, and those codes are mostly *not* authorship (edt, + trl, ill and art outnumber aut several times over), so a 700 is only taken when it + explicitly codes an author. The role is read from $4 alone: where $e is present it + simply restates $4 in German (edt/Herausgeber, aut/Verfasser). + """ + authors: list[str] = [] + + def _add(field: ET.Element, *, strict: bool) -> None: + grouped = _dnb_subfields(field) + roles = {role.lower() for role in grouped.get("4", [])} + # strict: only an explicit author relator qualifies. Loose (1XX main entry): + # an uncoded field is still the record's main entry, so accept it. + if roles and not (roles & _DNB_AUTHOR_ROLES): + return + if strict and not roles: + return + raw = grouped.get("a", [None])[0] + if not raw: + return + name = _reverse_dnb_name(raw) + if name and name not in authors: + authors.append(name) + + # Main entry first: a personal name, else a corporate or meeting main entry. + for tag in ("100", "110", "111"): + for field in record.findall(f"marc:datafield[@tag='{tag}']", _DNB_NAMESPACES): + _add(field, strict=False) + if authors: + break + + # Then author-coded co-authors from the added entries. + for field in record.findall("marc:datafield[@tag='700']", _DNB_NAMESPACES): + _add(field, strict=True) + + return authors + + +def _dnb_title(record: ET.Element) -> tuple[str | None, str | None]: + """Return the title and subtitle from the record's MARC 245 field. + + 245 is non-repeating and its first indicator only records whether a title added + entry is made (0 or 1), not which of several titles is "main", so there is nothing to + rank: the first field carrying a $a is the title. The loop exists only so a record + that somehow carries several 245 fields still yields the first usable title rather + than being dropped for having a title-less 245 first. + """ + for field in record.findall("marc:datafield[@tag='245']", _DNB_NAMESPACES): + title = _dnb_first(field, "a") + if title: + return title, _dnb_first(field, "b") + return None, None + + +def _extract_dnb_isbn(record: ET.Element) -> Optional[str]: + """Return the record's ISBN, normalized to ISBN-13. + + A 020 carrying a $q qualifier is dropped whole: the DNB uses that field to record a + misprinted ISBN together with a note saying it is wrong and no correct value could be + determined, and $9 then normalizes the bad value. Importing that number would attach + the book to an ISBN that identifies something else. + + Every value goes through normalize_isbn and unparseable ones are discarded, so a + candidate never carries an ISBN that could not be looked up again later. + """ + normalized: list[str] = [] + for field in record.findall("marc:datafield[@tag='020']", _DNB_NAMESPACES): + grouped = _dnb_subfields(field) + if grouped.get("q"): + continue + for code in ("a", "9"): + for value in grouped.get(code, []): + try: + normalized.append(normalize_isbn(value)) + except ValueError: + continue + # normalize_isbn always returns ISBN-13, so the first usable value is the answer. + return normalized[0] if normalized else None + + +def _dnb_subjects(record: ET.Element) -> Optional[str]: + """Collect subject headings from MARC 650 fields. + + 689 is deliberately ignored: in DNB records it restates the same headings as 650 in a + topical form, so including it would double every tag. The list is capped at three to + match the BnF and Library of Congress providers, since tags render as a display field. + """ + subjects: list[str] = [] + for field in record.findall("marc:datafield[@tag='650']", _DNB_NAMESPACES): + value = _dnb_first(field, "a") + if value and value not in subjects: + subjects.append(value) + return ", ".join(subjects[:3]) if subjects else None + + # ── Merge / Deduplicate ─────────────────────────────────────────────────────── @@ -1810,7 +2216,12 @@ def _merge_results(*lists: list[BookImportCandidate]) -> list[BookImportCandidat def _parse_page_count(text: str) -> Optional[int]: - """Extract page count from a format string like '1 vol. (265 p.) ; 20 cm' or '412 p. ; 22 cm.'.""" + """Extract page count from a format string like '1 vol. (265 p.) ; 20 cm' or '412 p. ; 22 cm.'. + + Handles the English/BnF/LoC conventions only. The DNB has its own + `_parse_dnb_page_count` because its extent statements are localized + ("1184 Seiten", "1.184 Seiten", "416 sidor") and group thousands differently. + """ # Try parenthesized format first: (265 p.) match = re.search(r"\(([\d,\s]+)\s*p\.\)", text) if match: diff --git a/backend/tests/test_book_import.py b/backend/tests/test_book_import.py index d325d670..aacd1d0b 100644 --- a/backend/tests/test_book_import.py +++ b/backend/tests/test_book_import.py @@ -98,6 +98,7 @@ async def test_search_with_progress_auto_ol_and_hc(monkeypatch: pytest.MonkeyPat monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) events = [] async for e in bi.search_with_progress("query", "title", hardcover_api_token="token"): @@ -113,6 +114,7 @@ async def test_search_with_progress_auto_hc_skipped_no_token(monkeypatch: pytest monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) events = [] async for e in bi.search_with_progress("query", "title"): events.append(e) @@ -129,6 +131,7 @@ async def test_search_with_progress_auto_fallback_google(monkeypatch: pytest.Mon monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) fake_gb = BookImportCandidate(title="GB Book", source="google_books") monkeypatch.setattr(bi, "_search_google_books", AsyncMock(return_value=[fake_gb])) @@ -147,6 +150,7 @@ async def test_search_with_progress_auto_fallback_google_error(monkeypatch: pyte monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) monkeypatch.setattr( bi, "_search_google_books", AsyncMock(side_effect=bi.SourceBackendError("google_books", 500)), @@ -164,6 +168,7 @@ async def test_search_with_progress_general_exception(monkeypatch: pytest.Monkey monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) events = [] async for e in bi.search_with_progress("query", "title"): @@ -183,6 +188,7 @@ async def test_search_ol_exception(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) results = await bi.search("query", "title") assert results == [] @@ -198,6 +204,7 @@ async def test_search_ol_generic_exception(monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) results = await bi.search("query", "title") assert results == [] @@ -215,6 +222,7 @@ async def test_search_ol_generic_exception_with_hc(monkeypatch: pytest.MonkeyPat monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) results = await bi.search("query", "title", hardcover_api_token="token") assert len(results) == 1 @@ -230,6 +238,7 @@ async def test_search_hc_exception(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) results = await bi.search("query", "title", hardcover_api_token="token") assert results == [] @@ -242,6 +251,7 @@ async def test_search_fallback_google_no_key(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) results = await bi.search("query", "title") assert results == [] @@ -254,6 +264,7 @@ async def test_search_fallback_google_success(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) fake = BookImportCandidate(title="Book", source="google_books") monkeypatch.setattr(bi, "_search_google_books", AsyncMock(return_value=[fake])) @@ -268,6 +279,7 @@ async def test_search_fallback_google_error(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) monkeypatch.setattr( bi, "_search_google_books", AsyncMock(side_effect=bi.SourceBackendError("google_books", 500)), @@ -1131,6 +1143,7 @@ async def test_search_with_progress_ol_error_event(monkeypatch: pytest.MonkeyPat monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) events = [] async for e in bi.search_with_progress("query", "title"): @@ -1274,6 +1287,7 @@ async def test_search_with_progress_hc_done_event(monkeypatch: pytest.MonkeyPatc monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) events = [] async for e in bi.search_with_progress("query", "title", hardcover_api_token="token"): @@ -1293,6 +1307,7 @@ async def test_search_with_progress_hc_error_event(monkeypatch: pytest.MonkeyPat monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) events = [] async for e in bi.search_with_progress("query", "title", hardcover_api_token="token"): @@ -1586,6 +1601,7 @@ async def test_search_with_progress_emits_one_terminal_event_per_stage( monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[BookImportCandidate(title="B", source="bnf")])) monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) monkeypatch.setattr(bi.settings, "library_of_congress_import_search_enabled", True) events = [] @@ -1614,6 +1630,7 @@ async def test_search_with_progress_skips_inventaire_for_title_search( monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_inventaire", inventaire) monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[])) events = [] async for e in bi.search_with_progress("query", "title"): @@ -1756,6 +1773,7 @@ async def test_search_uses_google_when_all_parallel_sources_disabled(monkeypatch ("bnf_import_search_enabled", False), ("inventaire_import_search_enabled", False), ("library_of_congress_import_search_enabled", False), + ("dnb_import_search_enabled", False), ("hardcover_import_search_enabled", False), ("google_books_import_search_enabled", True), ): @@ -1776,6 +1794,7 @@ async def test_search_skips_google_when_fallback_disabled(monkeypatch: pytest.Mo ("bnf_import_search_enabled", False), ("inventaire_import_search_enabled", False), ("library_of_congress_import_search_enabled", False), + ("dnb_import_search_enabled", False), ("hardcover_import_search_enabled", False), ("google_books_import_search_enabled", False), ): @@ -1796,6 +1815,7 @@ async def test_search_with_progress_reports_disabled_google_fallback(monkeypatch ("bnf_import_search_enabled", False), ("inventaire_import_search_enabled", False), ("library_of_congress_import_search_enabled", False), + ("dnb_import_search_enabled", False), ("hardcover_import_search_enabled", False), ("google_books_import_search_enabled", False), ): @@ -1824,6 +1844,7 @@ async def test_search_with_progress_still_completes_without_parallel_sources( ("bnf_import_search_enabled", False), ("inventaire_import_search_enabled", False), ("library_of_congress_import_search_enabled", False), + ("dnb_import_search_enabled", False), ("hardcover_import_search_enabled", False), ("google_books_import_search_enabled", True), ): @@ -1863,4 +1884,720 @@ def test_book_sources_registry() -> None: assert "bnf" in stages assert "inventaire" in stages assert "library_of_congress" in stages + assert "dnb" in stages assert "hardcover" in stages + + +# ── Deutsche Nationalbibliothek ───────────────────────────────────────────────── + +# Shaped like a real DNB SRU response: the SRU namespace is the root default and the +# MARC21 record declares its own. The title carries the U+0098/U+009C non-sorting markers +# the DNB uses in place of the MARC nonfiling indicator. +DNB_SAMPLE_XML = """ + + 1.1 + 1 + + MARC21-xml + xml + + + 00000nam a2200000 c 4500 + 1395279225 + 260331s2027 gw ||||| |||| 00||||ger + + 9783328115458 + EUR 18.00 + 978-3-328-11545-8 + + + ger + + + (DE-588)118577166 + Mann, Thomas. + 1875-1955 + aut + + + \u0098Der\u009c Zauberberg + Penguin Klassiker + + + München + Penguin + 2027 + + + 1184 Seiten + 19 cm + + + Deutsche Literatur + + + Deutsche Literatur + + + Ein Roman über einen Menschen. + + + + +""" + + +def _dnb_record(body: str) -> str: + """Wrap MARC21 record fields in the SRU envelope the DNB returns.""" + return f""" + + + {body} + +""" + + +def test_map_dnb_response_basic() -> None: + candidates = bi.map_dnb_response(DNB_SAMPLE_XML) + assert len(candidates) == 1 + c = candidates[0] + # The non-sorting markers must not survive into the title. + assert c.title == "Der Zauberberg" + assert c.subtitle == "Penguin Klassiker" + assert c.author == "Thomas Mann" + assert c.authors == ["Thomas Mann"] + assert c.publisher == "Penguin" + assert c.published_year == 2027 + assert c.page_count == 1184 + assert c.language == "DE" + assert c.isbn == "9783328115458" + # 689 restates 650, so tags must not be doubled. + assert c.tags == "Deutsche Literatur" + assert c.blurb == "Ein Roman über einen Menschen." + assert c.source == "dnb" + assert c.cover_url is None + + +def test_map_dnb_response_uses_first_245_with_a_title() -> None: + """245 is non-repeating; the first field carrying a $a is the title. + + The first indicator only says whether a title added entry is made, so it must not be + used to rank candidate titles. + """ + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Erster Titel + + + Zweiter Titel + + """) + assert bi.map_dnb_response(xml)[0].title == "Erster Titel" + + +def test_map_dnb_response_skips_245_without_a_title() -> None: + """A 245 with no $a is not a title, so the record is still imported.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Nur ein Resttitel + + + Tatsächlicher Titel + + """) + assert bi.map_dnb_response(xml)[0].title == "Tatsächlicher Titel" + + +def test_map_dnb_response_strips_nonfiling_markers() -> None: + """U+0098 and U+009C are DNB non-sorting markers, not part of the title.""" + xml = _dnb_record(f""" + 260331s2027 gw ||||| |||| 00||||ger + + \u0098Der\u009c Zauberberg + + """) + c = bi.map_dnb_response(xml)[0] + assert c.title == "Der Zauberberg" + assert "\u0098" not in c.title + + +def test_map_dnb_response_skips_non_author_1xx() -> None: + """A 1XX coded as editor or translator is a contributor, not the book author.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Herausgeber, Hans + edt + + + Anthologie + + """) + c = bi.map_dnb_response(xml)[0] + assert c.authors is None + assert c.author is None + + +def test_map_dnb_response_falls_back_to_corporate_author() -> None: + """A work with no personal name still has a 110 corporate author.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Deutsche Akademie + aut + + + Bericht + + """) + assert bi.map_dnb_response(xml)[0].authors == ["Deutsche Akademie"] + + +def test_map_dnb_response_ignores_invalid_isbn_field() -> None: + """A 020 with a $q note records an ISBN the DNB itself says is wrong. + + $9 then normalizes that bad value, so the whole field has to be dropped: importing + it would attach the book to an ISBN that identifies something else. + """ + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + 9783161484100 + falsche ISBN in Vorlage + 978-3-16-148410-0 + + + Kochbuch + + """) + assert bi.map_dnb_response(xml)[0].isbn is None + + +def test_map_dnb_response_keeps_isbn_from_other_valid_field() -> None: + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + 1111111111111 + falsche ISBN in Vorlage + + + 9783328115458 + + + Der Zauberberg + + """) + assert bi.map_dnb_response(xml)[0].isbn == "9783328115458" + + +def test_map_dnb_response_appends_author_coded_co_authors() -> None: + """The DNB puts co-authors in 700 added entries, so they must be collected. + + Editors, translators and illustrators are far more common in 700 than authors are, so + only fields explicitly coded as authors may be added. + """ + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Angermann, Detlef + aut + + + Seitz, Florian + aut + + + Hugentobler, Urs + aut + + + Mission Erde + + """) + c = bi.map_dnb_response(xml)[0] + assert c.authors == ["Detlef Angermann", "Florian Seitz", "Urs Hugentobler"] + assert c.author == "Detlef Angermann" + + +@pytest.mark.parametrize("role, name", [ + ("edt", "Herausgeber, Hans"), + ("trl", "Übersetzer, Inge"), + ("ill", "Illustrator, Piet"), + ("art", "Künstler, Karl"), +]) +def test_map_dnb_response_skips_non_author_coauthors(role: str, name: str) -> None: + """A 700 coded as anything but an author is a contributor, not a co-author.""" + xml = _dnb_record(f""" + 260331s2027 gw ||||| |||| 00||||ger + + Mann, Thomas + aut + + + {name} + {role} + + + Anthologie + + """) + assert bi.map_dnb_response(xml)[0].authors == ["Thomas Mann"] + + +def test_map_dnb_response_ignores_uncoded_700() -> None: + """A 700 with no relator is not safe to call an author, unlike an uncoded 1XX. + + The 1XX main entry is the record's own statement of who wrote the book. A 700 is an + added entry, and the DNB codes every one of them, so an uncoded 700 is ambiguous. + """ + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Mann, Thomas + aut + + + Uncodierter Name + + + Anthologie + + """) + assert bi.map_dnb_response(xml)[0].authors == ["Thomas Mann"] + + +def test_map_dnb_response_handles_undated_publication() -> None: + """German records without a year carry "o.J." (ohne Jahr) or "n.d." instead.""" + for placeholder in ("o.J.", "n.d.", "[o.J.]", "o. J."): + xml = _dnb_record(f""" + 260331s2027 gw ||||| |||| 00||||ger + + Verlag + {placeholder} + + + Ohne Jahresangabe + + """) + c = bi.map_dnb_response(xml)[0] + assert c.publisher == "Verlag", placeholder + assert c.published_year is None, placeholder + + +def test_map_dnb_response_accepts_uncoded_main_entry() -> None: + """An uncoded 1XX is still the main entry, so it is accepted as the author.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Anonymus + + + Ohne Verfasserangabe + + """) + assert bi.map_dnb_response(xml)[0].authors == ["Anonymus"] + + +def test_map_dnb_response_uses_260_for_older_records() -> None: + """Older records carry the publication statement in 260, which has no indicator.""" + xml = _dnb_record(""" + 990101s1990 gw ||||| |||| 00||||ger + + Weimar + Verlag + 1990 + + + Altbestand + + """) + c = bi.map_dnb_response(xml)[0] + assert c.publisher == "Verlag" + assert c.published_year == 1990 + + +def test_map_dnb_response_ignores_copyright_statement() -> None: + """264 with second indicator 4 is the copyright statement, not the publication.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Reclam + ©1999 + + + München + Hanser + 2027 + + + Neuauflage + + """) + c = bi.map_dnb_response(xml)[0] + assert c.publisher == "Hanser" + assert c.published_year == 2027 + + +def test_map_dnb_response_accepts_264_without_indicator() -> None: + """A 264 with no second indicator is still the only publication statement.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Selfpublisher + 2020 + + + Eigenverlag + + """) + c = bi.map_dnb_response(xml)[0] + assert c.publisher == "Selfpublisher" + assert c.published_year == 2020 + + +def test_map_dnb_response_normalizes_isbn10() -> None: + """ISBNs are normalized to ISBN-13 so they match every other source's output.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + 3328115455 + + + Der Zauberberg + + """) + assert bi.map_dnb_response(xml)[0].isbn == "9783328115458" + + +def test_map_dnb_response_normalizes_hyphenated_isbn_from_subfield_9() -> None: + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + 978-3-328-11545-8 + + + Der Zauberberg + + """) + assert bi.map_dnb_response(xml)[0].isbn == "9783328115458" + + +def test_map_dnb_response_discards_unparseable_isbn() -> None: + """An ISBN that cannot be normalized is dropped rather than stored verbatim.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + keine-nummer + + + Titel ohne ISBN + + """) + assert bi.map_dnb_response(xml)[0].isbn is None + + +def test_map_dnb_response_caps_subject_headings() -> None: + """Tags are capped at three like the BnF and Library of Congress providers.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Erstes + + + Zweites + + + Drittes + + + Viertes + + + Viele Schlagwörter + + """) + assert bi.map_dnb_response(xml)[0].tags == "Erstes, Zweites, Drittes" + + +def test_parse_dnb_page_count_does_not_backtrack() -> None: + """A long run with no page-count keyword must not cost quadratic time. + + The old lazy pattern spent ~360 ms on this input; the bounded one must stay flat, so + a hostile 300$a cannot stall a search. + """ + import time + + for payload in ("1" + " " * 5000, "1" + "9" * 5000, "1.1.1." * 1000): + start = time.perf_counter() + assert bi._parse_dnb_page_count(payload) is None + assert (time.perf_counter() - start) < 0.25, payload[:16] + + +def test_map_dnb_response_unwraps_supplied_publisher_and_year() -> None: + """Square brackets mark a value the cataloguer supplied rather than transcribed. + + The title keeps its brackets because there they are part of the title itself: the + Library of Congress documents titles such as `24500$a[Man smoking at window]`. + """ + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + [Lisboa] + + + [Lisboa] + [Editora] + [2026] + + """) + c = bi.map_dnb_response(xml)[0] + assert c.title == "[Lisboa]" + assert c.publisher == "Editora" + assert c.published_year == 2026 + + +def test_map_dnb_response_reads_language_from_008() -> None: + """Records without a 041 still carry the language in the 008 fixed field.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||por + + A montanha + + """) + assert bi.map_dnb_response(xml)[0].language == "PT" + + +def test_map_dnb_response_prefers_041_over_008() -> None: + """041$a is the language of the text; 008 can disagree on translated works.""" + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + por + ger + + + A montanha + + """) + assert bi.map_dnb_response(xml)[0].language == "PT" + + +def test_map_dnb_response_no_title_is_skipped() -> None: + xml = _dnb_record(""" + 260331s2027 gw ||||| |||| 00||||ger + + Mann, Thomas + + """) + assert bi.map_dnb_response(xml) == [] + + +def test_map_dnb_response_empty() -> None: + xml = """ + + 0 +""" + assert bi.map_dnb_response(xml) == [] + + +def test_map_dnb_response_malformed_xml() -> None: + assert bi.map_dnb_response(" None: + """A hostile SRU payload must not be expanded, and must yield no candidates.""" + payload = _BILLION_LAUGHS.replace( + "&lol3;", + f"""260331s2027 gw ||||| |||| 00||||ger + &lol3;""", + ) + assert bi.map_dnb_response(payload) == [] + + +def test_map_dnb_response_rejects_external_entity() -> None: + """External entity references must not be resolved to local files.""" + payload = _XXE_FILE_READ.replace( + "&xxe;", + f"""260331s2027 gw ||||| |||| 00||||ger + &xxe;""", + ) + assert bi.map_dnb_response(payload) == [] + + +@pytest.mark.parametrize("raw, expected", [ + ("Mann, Thomas.", "Thomas Mann"), + ("Popper, Karl R.", "Karl R. Popper"), + ("Goethe, Johann Wolfgang von", "Johann Wolfgang von Goethe"), + ("Beethoven, Ludwig van", "Ludwig van Beethoven"), + ("Einstein, Albert", "Albert Einstein"), + (" Surname ", "Surname"), + # strip(" ,;") removes a dangling separator, so these never reach the split. + ("Ludwig van Beethoven, ", "Ludwig van Beethoven"), + ("Surname,", "Surname"), + (",Forename", "Forename"), + ("Beethoven, L.", "L. Beethoven"), + # A name that is not inverted carries no inversion punctuation, so titles and + # abbreviations keep their periods whatever their length. + ("Dr.", "Dr."), + ("Prof.", "Prof."), + ("MSc.", "MSc."), + ("BSc.", "BSc."), + ("Ab.", "Ab."), + ("R.", "R."), + ("Prof. Dr. Schmidt", "Prof. Dr. Schmidt"), + ("Deutsche Akademie der Wissenschaften", "Deutsche Akademie der Wissenschaften"), +]) +def test_reverse_dnb_name(raw: str, expected: str) -> None: + """1XX names are catalogued inverted and must read naturally once reversed.""" + assert bi._reverse_dnb_name(raw) == expected + + +@pytest.mark.parametrize("text, expected", [ + ("1184 Seiten", 1184), + ("1.184 Seiten", 1184), + ("1 184 Seiten", 1184), + ("1,184 Seiten", 1184), + ("1 184 Seiten", 1184), + ("926 pages", 926), + ("416 sidor", 416), + ("XXXIX, 601 S.", 601), + ("XXIV, 601 S.", 601), + ("219 Seiten", 219), + ("12 Seiten", 12), + ("Bd. 1 184 Seiten", 1184), + # A decimal must be rejected, not read as a page count. + ("1.5 pages", None), + ("1,5 sidor", None), + ("1,23 Seiten", None), + ("1 Band", None), + ("", None), +]) +def test_parse_dnb_page_count(text: str, expected: int | None) -> None: + """Extent statements are localized; only the arabic page count is usable.""" + assert bi._parse_dnb_page_count(text) == expected + + +def _dnb_client(handler) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + +@pytest.mark.anyio +async def test_search_dnb_isbn_query() -> None: + """An ISBN search queries the num index with the normalized ISBN-13.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(dict(request.url.params)) + return httpx.Response(200, text=DNB_SAMPLE_XML) + + async with _dnb_client(handler) as client: + candidates = await bi._search_dnb("978-3-328-11545-8", "isbn", client) + + assert seen["query"] == "num=9783328115458" + assert seen["recordSchema"] == "MARC21-xml" + assert seen["version"] == "1.1" + assert candidates[0].title == "Der Zauberberg" + + +@pytest.mark.anyio +async def test_search_dnb_title_query() -> None: + """A title search is quoted so "and" or "or" in it is not read as a CQL operator.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(dict(request.url.params)) + return httpx.Response(200, text="") + + async with _dnb_client(handler) as client: + await bi._search_dnb("Das Maß der Erde", "title", client) + + assert seen["query"] == 'tit="Das Maß der Erde"' + + +@pytest.mark.anyio +async def test_search_dnb_sanitizes_cql_term() -> None: + """A quote in the query must not terminate the CQL string early.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(dict(request.url.params)) + return httpx.Response(200, text="") + + async with _dnb_client(handler) as client: + await bi._search_dnb('Der "Zauberberg', "title", client) + + assert seen["query"] == 'tit="Der Zauberberg"' + + +@pytest.mark.anyio +async def test_search_dnb_empty_title_returns_no_request() -> None: + def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover + raise AssertionError("no request should be sent") + + async with _dnb_client(handler) as client: + assert await bi._search_dnb('"', "title", client) == [] + + +@pytest.mark.anyio +async def test_search_dnb_invalid_isbn_returns_no_request() -> None: + def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover + raise AssertionError("no request should be sent") + + async with _dnb_client(handler) as client: + assert await bi._search_dnb("not-an-isbn", "isbn", client) == [] + + +@pytest.mark.anyio +async def test_search_dnb_raises_backend_error_on_http_status() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, text="unavailable") + + async with _dnb_client(handler) as client: + with pytest.raises(bi.SourceBackendError) as excinfo: + await bi._search_dnb("Faust", "title", client) + + assert excinfo.value.source == "dnb" + assert excinfo.value.status_code == 503 + + +@pytest.mark.anyio +async def test_search_dnb_raises_backend_error_on_transport_failure() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom") + + async with _dnb_client(handler) as client: + with pytest.raises(bi.SourceBackendError) as excinfo: + await bi._search_dnb("Faust", "title", client) + + assert excinfo.value.source == "dnb" + + +@pytest.mark.anyio +async def test_dnb_is_reported_in_progress_events(monkeypatch: pytest.MonkeyPatch) -> None: + """A finished DNB search reports its own stage with a result count.""" + monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_bnf", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_inventaire", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_loc", AsyncMock(return_value=[])) + monkeypatch.setattr(bi, "_search_dnb", AsyncMock(return_value=[BookImportCandidate( + title="Der Zauberberg", source="dnb", + )])) + + events = [] + async for e in bi.search_with_progress("Der Zauberberg", "title"): + events.append(e) + + assert any(e.get("stage") == "dnb" and e.get("status") == "searching" for e in events) + assert any(e.get("stage") == "dnb" and e.get("status") == "done" and e.get("count") == 1 + for e in events) + complete = [e for e in events if e.get("stage") == "complete"][0] + assert complete["results"][0]["source"] == "dnb" + + +def test_dnb_is_skipped_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bi.settings, "dnb_import_search_enabled", False) + spec = next(s for s in bi._build_sources("") if s.stage == "dnb") + assert spec.enabled() is False + assert spec.display_name == "Deutsche Nationalbibliothek" diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index e609c462..47d60eec 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -27,7 +27,7 @@ def _only_legacy_sources(monkeypatch: MonkeyPatch) -> None: """Run this module's tests with only Open Library, Hardcover and Google Books active. - The tests here predate the BnF / Inventaire / Library of Congress providers and + The tests here predate the BnF / Inventaire / Library of Congress / DNB providers and assert on exact result counts from the original two parallel sources. Disabling the newer keyless providers keeps those expectations valid and keeps the suite free of real network calls. Their own behaviour is covered in test_book_import.py. @@ -35,6 +35,7 @@ def _only_legacy_sources(monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr(settings, "bnf_import_search_enabled", False) monkeypatch.setattr(settings, "inventaire_import_search_enabled", False) monkeypatch.setattr(settings, "library_of_congress_import_search_enabled", False) + monkeypatch.setattr(settings, "dnb_import_search_enabled", False) # Hardcover and Google Books ship disabled; these tests still exercise them. monkeypatch.setattr(settings, "hardcover_import_search_enabled", True) monkeypatch.setattr(settings, "google_books_import_search_enabled", True) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index a0c87057..8225eda1 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -244,6 +244,7 @@ def test_credentialed_sources_keep_true_default_for_backward_compatibility() -> assert defaults.bnf_import_search_enabled is True assert defaults.inventaire_import_search_enabled is True assert defaults.library_of_congress_import_search_enabled is True + assert defaults.dnb_import_search_enabled is True @pytest.mark.anyio diff --git a/docs/about.md b/docs/about.md index a7e408e2..776d8325 100644 --- a/docs/about.md +++ b/docs/about.md @@ -6,7 +6,7 @@ LibrisLog is a **multi-user book tracking web application** designed for readers - **Library Management**: Organize books into four reading statuses — Want to Read, Currently Reading, Read, and Did Not Finish - **Advanced Search**: Find books by title, author, tags, publisher, language, availability, notes, and description using field prefixes and negation -- **Book Import**: Search Open Library, BnF, Inventaire.io, the Library of Congress, Hardcover.app, and Google Books. Scan ISBN barcodes for quick lookup +- **Book Import**: Search Open Library, BnF, Inventaire.io, the Library of Congress, the German National Library, Hardcover.app, and Google Books. Scan ISBN barcodes for quick lookup - **Reading Progress**: Track pages read over time with a visual timeline and calendar heatmap - **Statistics Dashboard**: Charts showing pages read per month, books finished, language distribution, and more - **Cover Management**: Automatic cover image scraping from multiple sources with manual override @@ -22,7 +22,7 @@ LibrisLog is a **multi-user book tracking web application** designed for readers | Frontend | Svelte 5 + SvelteKit + Tailwind CSS 4 + DaisyUI 5 | | Backend | FastAPI + SQLModel + Alembic + Pydantic v2 | | Database | SQLite | -| Search Sources | Open Library, BnF, Inventaire.io, Library of Congress, Google Books, Hardcover.app, AbeBooks, Amazon | +| Search Sources | Open Library, BnF, Inventaire.io, Library of Congress, Deutsche Nationalbibliothek, Google Books, Hardcover.app, AbeBooks, Amazon | | Charts | Chart.js + chartjs-chart-matrix + chartjs-plugin-zoom | | Icons | Lucide Svelte | | Scraping | curl-cffi + Scrapling + BrowserForge | diff --git a/docs/guide/api-keys.md b/docs/guide/api-keys.md index 200067c6..6476fbcb 100644 --- a/docs/guide/api-keys.md +++ b/docs/guide/api-keys.md @@ -1,6 +1,6 @@ # API Keys -Some external sources require API keys. All are optional: Open Library, BnF, Inventaire.io, and the Library of Congress need no key at all, so LibrisLog works out of the box. +Some external sources require API keys. All are optional: Open Library, BnF, Inventaire.io, the Library of Congress, and the German National Library need no key at all, so LibrisLog works out of the box. Sources without a key can be switched off individually, see [Configuration](/guide/configuration). diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 28a93bf1..035735cf 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -76,6 +76,7 @@ naming the variables to set. | `BNF_IMPORT_SEARCH_ENABLED` | Query BnF (French national library), title and ISBN | `true` | `true` | | `INVENTAIRE_IMPORT_SEARCH_ENABLED` | Query Inventaire.io, ISBN only | `true` | `true` | | `LIBRARY_OF_CONGRESS_IMPORT_SEARCH_ENABLED` | Query the Library of Congress, title and ISBN | `true` | depends, see below | +| `DNB_IMPORT_SEARCH_ENABLED` | Query the Deutsche Nationalbibliothek (German National Library), title and ISBN | `true` | `true` | | `HARDCOVER_IMPORT_SEARCH_ENABLED` | Query Hardcover.app (also needs `HARDCOVER_APP_API_TOKEN`) | `true` | `false` unless a token is set | | `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED` | Allow the Google Books fallback (also needs `GOOGLE_BOOKS_API_KEY`) | `true` | `false` unless a key is set | | `GOOGLE_BOOKS_API_KEY` | Google Books API key (see [API Keys](/guide/api-keys)) | empty | — | @@ -85,7 +86,8 @@ The built-in default is `true` for every source, so upgrading never silently dro source that worked before these switches existed. `.env.example` sets the two keyed sources to `false`, which is what you want unless you have configured a key or token. -BnF, Inventaire, and the Library of Congress need no API key and are enabled by default. +BnF, Inventaire, the Library of Congress, and the German National Library need no API key +and are enabled by default. **Hardcover and Google Books cannot answer without credentials.** Either way they are handled the same way: if the switch is on and the key or token is empty, the source is @@ -161,7 +163,7 @@ TELEMETRY_DISABLED=true - `API_KEY_ENCRYPTION_KEY` must be a real secret key (minimum 32 characters). Do not use the placeholder value from `.env.example`. If left as placeholder or set to a weak value, API key creation will fail. - When `OIDC_ENABLED=true`, all three OIDC variables must be set. -- `GOOGLE_BOOKS_API_KEY` and `HARDCOVER_APP_API_TOKEN` are optional. See [API Keys](/guide/api-keys) for how to obtain them. The app runs fine without them using Open Library, BnF, Inventaire, and the Library of Congress. `.env.example` sets `HARDCOVER_IMPORT_SEARCH_ENABLED` and `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED` to `false`; set either to `true` once its credential is configured. +- `GOOGLE_BOOKS_API_KEY` and `HARDCOVER_APP_API_TOKEN` are optional. See [API Keys](/guide/api-keys) for how to obtain them. The app runs fine without them using Open Library, BnF, Inventaire, the Deutsche Nationalbibliothek, and the Library of Congress. `.env.example` sets `HARDCOVER_IMPORT_SEARCH_ENABLED` and `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED` to `false`; set either to `true` once its credential is configured. - At least one book import source must be able to answer. Disabling every source, or enabling only Hardcover and Google Books without their credentials, stops the backend from starting. ## Example .env diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index edfc63f7..8b2c22e4 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -15,6 +15,7 @@ The most common way to add books is by searching external sources: - **BnF** (no key required; strong French-language coverage) - **Inventaire.io** (no key required; ISBN searches only, provides cover images) - **Library of Congress** (no key required; US/English coverage, unencrypted HTTP) + - **Deutsche Nationalbibliothek** (no key required; strong German-language coverage, umlauts transliterated in titles) - **Hardcover.app** (if `HARDCOVER_APP_API_TOKEN` is set **and** `HARDCOVER_IMPORT_SEARCH_ENABLED=true` — see [API Keys](/guide/api-keys)) - **Google Books** as a fallback (if `GOOGLE_BOOKS_API_KEY` is set **and** `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=true` — see [API Keys](/guide/api-keys)) diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 1734a0ca..2217f2b8 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -65,6 +65,7 @@ Search external sources for book metadata: - **BnF** (Bibliothèque nationale de France) — Free, no API key required; strong French-language coverage - **Inventaire.io** — Free, no API key required; ISBN searches only, and the only new source that provides cover images - **Library of Congress** — Free, no API key required; US/English coverage. Uses unencrypted HTTP, see [Configuration](/guide/configuration) +- **Deutsche Nationalbibliothek** (German National Library) — Free, no API key required; strong German-language coverage. Note that the DNB transliterates umlauts in its catalogued titles, so searching for *Das Maß* returns a record titled *Das Mass der Erde*. An ISBN lookup returns the title the DNB holds for that ISBN, transliterated or not - **Hardcover.app** — Requires API token, off in `.env.example` by default - **Google Books** — Requires API key, off in `.env.example` by default, fallback only diff --git a/docs/index.md b/docs/index.md index 39c02e12..6976b735 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,7 +25,7 @@ features: details: Organize books into Want to Read, Currently Reading, Read, and Did Not Finish statuses. - icon: 🔍 title: Import & Discover - details: Search Open Library, BnF, Inventaire.io, the Library of Congress, Google Books, and Hardcover.app. Scan ISBN barcodes for quick lookup. + details: Search Open Library, BnF, Inventaire.io, the Library of Congress, the German National Library, Google Books, and Hardcover.app. Scan ISBN barcodes for quick lookup. - icon: 📊 title: Insights & Statistics details: Beautiful charts, reading timeline, and calendar heatmap to visualize your reading habits. diff --git a/docs/releases.md b/docs/releases.md index 5baaadf8..99ba5010 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -40,13 +40,14 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an -**Summary:** Adds three more metadata sources to the import search, brings back coverage for non-English editions that Open Library misses, adds an on/off switch for every import source, keeps the Add Book dialog header reachable while scrolling, and updates the frontend framework and dependency set. +**Summary:** Adds four more metadata sources to the import search, brings back coverage for non-English editions that Open Library misses, adds an on/off switch for every import source, keeps the Add Book dialog header reachable while scrolling, and updates the frontend framework and dependency set. **Features** - 📚 **Bibliothèque nationale de France (BnF)** as a search source, queried over the public SRU endpoint. No API key needed. Strong coverage of French editions: an ISBN such as `9791020923554` returns no result from Open Library but a full record from BnF, with title, subtitle, authors, publisher, year, page count, language, and subject headings. BnF answers one ISBN with several records, including a commercial barcode stub; those are grouped per ISBN so only the richest record is imported - 🔗 **Inventaire.io** as a search source, queried over its public entities API. No API key needed. It aggregates data from several national libraries and is the only new source that supplies cover images. Inventaire answers ISBN lookups only, because it exposes no title search, so it is reported as skipped on a title search instead of returning an empty result - 🏛️ **Library of Congress** as a search source, queried over the official SRU server. No API key needed. Coverage is US and English oriented, so it complements rather than replaces the other sources. **This source is only reachable over plain HTTP**, so its queries and responses travel unencrypted; it can be switched off if that is not acceptable in your network. See the [Configuration guide](/guide/configuration) -- 🔀 **Every import source can be switched on or off individually.** Six new `*_IMPORT_SEARCH_ENABLED` settings cover Open Library, BnF, Inventaire.io, the Library of Congress, Hardcover, and the Google Books fallback. Sources that are switched off are not queried at all +- 📕 **Deutsche Nationalbibliothek** as a search source, queried over its public SRU endpoint. No API key needed. Strong coverage of German-language editions, and unlike the Library of Congress it answers both title and ISBN searches over HTTPS. Records are read as MARC21, so the subtitle, publisher, year, page count, language, subject headings and description all come through, and co-authors catalogued as added entries are included alongside the lead author. Four cataloguing quirks are handled rather than shown raw: the non-sorting markers the DNB embeds around articles such as the "Der" in *Der Zauberberg* are stripped, a record that flags its own ISBN as misprinted is imported without one instead of with a number that identifies a different book, editors or translators listed as added entries are not reported as authors, and an inverted catalogued name is turned back into reading order without eating the period on a real initial or title. One quirk is passed through unchanged: the DNB transliterates umlauts in its titles, so *Das Maß* comes back as *Das Mass der Erde* +- 🔀 **Every import source can be switched on or off individually.** Seven new `*_IMPORT_SEARCH_ENABLED` settings cover Open Library, BnF, Inventaire.io, the Library of Congress, the Deutsche Nationalbibliothek, Hardcover, and the Google Books fallback. Sources that are switched off are not queried at all - 🔑 **Hardcover and Google Books are set to `false` in `.env.example`.** Neither can answer a query without credentials, so the example config recommends switching them off unless a token or key is configured. The built-in default stays `true`, so existing installs that never set the variable keep their current behavior after upgrading. A source left on without its credential is reported as skipped and makes no request - 🚦 **Startup guard for unusable sources.** If no source can answer, the backend refuses to start and names the settings to change, instead of silently returning no search results forever. That includes the case where only Hardcover and Google Books are enabled without their credentials - 🌍 **Localized source names.** Import sources are shown under their translated name in the search progress list and in the edition chips, in all five UI languages @@ -59,7 +60,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an - ⬆️ Updated backend and frontend dependencies, most notably SvelteKit 3 and Vitest 5. Building the frontend from source now needs Node.js 22.17 or newer **Bug fixes / security** -- 🛡️ **Remote SRU responses are parsed with entity expansion disabled.** The BnF and Library of Congress responses are untrusted remote XML and are now parsed defensively, so a hostile or malformed response cannot exhaust memory or read local files through entity references +- 🛡️ **Remote SRU responses are parsed with entity expansion disabled.** The BnF, Library of Congress and Deutsche Nationalbibliothek responses are untrusted remote XML and are now parsed defensively, so a hostile or malformed response cannot exhaust memory or read local files through entity references - 🛡️ **Import rejects cover URLs that point at private addresses.** A book candidate is submitted by the client, so its cover URL is untrusted input. URLs resolving to loopback, private, or link-local addresses are now rejected before download instead of being fetched **Breaking changes:** None. Existing installs keep working untouched: every source defaults to `true`, so no configured source is lost on upgrade. To follow the recommended configuration, set `HARDCOVER_IMPORT_SEARCH_ENABLED=false` and `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false` unless you have configured a token or key. If your deployment blocks outbound plain HTTP on port 210, set `LIBRARY_OF_CONGRESS_IMPORT_SEARCH_ENABLED=false`. diff --git a/frontend/src/lib/components/ImportSearch.svelte b/frontend/src/lib/components/ImportSearch.svelte index fae02f68..bf24025f 100644 --- a/frontend/src/lib/components/ImportSearch.svelte +++ b/frontend/src/lib/components/ImportSearch.svelte @@ -99,7 +99,8 @@ google_books: 'import.sourceNameGoogleBooks', bnf: 'import.sourceNameBnf', inventaire: 'import.sourceNameInventaire', - library_of_congress: 'import.sourceNameLibraryOfCongress' + library_of_congress: 'import.sourceNameLibraryOfCongress', + dnb: 'import.sourceNameDnb' }; function sourceLabel(stage: string): string { diff --git a/frontend/src/lib/components/ImportSearch.test.ts b/frontend/src/lib/components/ImportSearch.test.ts index 42772375..c01d1cd3 100644 --- a/frontend/src/lib/components/ImportSearch.test.ts +++ b/frontend/src/lib/components/ImportSearch.test.ts @@ -127,6 +127,7 @@ describe('ImportSearch', () => { { stage: 'bnf', status: 'done', count: 1 }, { stage: 'inventaire', status: 'done', count: 1 }, { stage: 'library_of_congress', status: 'error', reason: 'backend_error' }, + { stage: 'dnb', status: 'done', count: 3 }, { stage: 'hardcover', status: 'skipped', reason: 'no_api_token' } ]) ); @@ -139,6 +140,7 @@ describe('ImportSearch', () => { expect(screen.getByText('Inventaire - 1 result')).toBeInTheDocument(); expect(screen.getByText('Open Library - 0 results')).toBeInTheDocument(); expect(screen.getByText(/Library of Congress backend error/)).toBeInTheDocument(); + expect(screen.getByText('German National Library - 3 results')).toBeInTheDocument(); expect(screen.getByText(/Hardcover skipped/)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 0fa71c69..989f8fc8 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -249,6 +249,7 @@ "sourceNameBnf": "BnF", "sourceNameInventaire": "Inventaire", "sourceNameLibraryOfCongress": "Library of Congress", + "sourceNameDnb": "Deutsche Nationalbibliothek", "sourceNameHardcover": "Hardcover", "sourceNameGoogleBooks": "Google Books", "sourceBackendError": "{source}-Backendfehler (Backend-Logs prüfen)", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 112ee882..ad6c5c2c 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -249,6 +249,7 @@ "sourceNameBnf": "BnF", "sourceNameInventaire": "Inventaire", "sourceNameLibraryOfCongress": "Library of Congress", + "sourceNameDnb": "German National Library", "sourceNameHardcover": "Hardcover", "sourceNameGoogleBooks": "Google Books", "sourceBackendError": "{source} backend error (check backend logs)", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 22d10414..990607b5 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -249,6 +249,7 @@ "sourceNameBnf": "BnF", "sourceNameInventaire": "Inventaire", "sourceNameLibraryOfCongress": "Biblioteca del Congreso", + "sourceNameDnb": "Biblioteca Nacional de Alemania", "sourceNameHardcover": "Hardcover", "sourceNameGoogleBooks": "Google Books", "sourceBackendError": "Error de backend de {source} (revisa los registros del servidor)", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 88db0ffd..e198a71c 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -249,6 +249,7 @@ "sourceNameBnf": "BnF", "sourceNameInventaire": "Inventaire", "sourceNameLibraryOfCongress": "Bibliothèque du Congrès", + "sourceNameDnb": "Bibliothèque nationale d'Allemagne", "sourceNameHardcover": "Hardcover", "sourceNameGoogleBooks": "Google Books", "sourceBackendError": "Erreur backend {source} (vérifie les journaux du serveur)", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 4b866bcd..95793fd2 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -249,6 +249,7 @@ "sourceNameBnf": "BnF", "sourceNameInventaire": "Inventaire", "sourceNameLibraryOfCongress": "美国国会图书馆", + "sourceNameDnb": "德国国家图书馆", "sourceNameHardcover": "Hardcover", "sourceNameGoogleBooks": "Google Books", "sourceBackendError": "{source} 后端错误(请检查后端日志)", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 906b8612..7cb25d39 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -363,6 +363,7 @@ export type ImportSource = | 'bnf' | 'inventaire' | 'library_of_congress' + | 'dnb' | 'hardcover' | 'google_books';