diff --git a/.env.example b/.env.example index bc6123dc..adde01c1 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,40 @@ DASHBOARD_QUOTE_CACHE_TTL=86400 # in seconds; default 24h # Frontend settings (build-time) PUBLIC_DEFAULT_LOCALE=en # UI default locale: en | de +# Book import search providers +# At least one source must be able to answer. If every source below is disabled, the +# backend refuses to start and names the variables in the error. +OPEN_LIBRARY_IMPORT_SEARCH_ENABLED=true + +# BnF (Bibliotheque nationale de France) - no API key needed, HTTPS +BNF_IMPORT_SEARCH_ENABLED=true + +# Inventaire.io - no API key needed, HTTPS, ISBN searches only +INVENTAIRE_IMPORT_SEARCH_ENABLED=true + +# Hardcover and Google Books need credentials before they can answer anything, so they +# are set to false here. The built-in default is true so that upgrading never silently +# drops a source; set either to true once its key or token is configured: +# HARDCOVER_APP_API_TOKEN (see https://codebude.github.io/librislog/guide/api-keys) +# GOOGLE_BOOKS_API_KEY +# A source left enabled without its key makes no request and is reported as skipped, +# so leaving the default is harmless, just noisier in the search progress list. +# Setting GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false also disables the on-demand +# "Search Google Books too" button. +HARDCOVER_IMPORT_SEARCH_ENABLED=false +GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false + +# Library of Congress - no API key needed +# WARNING: This source is reached over PLAIN HTTP (unencrypted) on port 210; the LC +# offers no HTTPS for its SRU endpoint. Book queries and responses travel unencrypted +# and can be observed or tampered with on the network path. Only public bibliographic +# data is transferred. Set this to false if unencrypted egress to port 210 is not +# 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 ab13a837..3bf39411 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +# Vitest 5 writes generated reports and artifacts here +.vitest/ # Translations *.mo @@ -232,6 +234,7 @@ node_modules/ *-snapshot.md # AI tools +/AGENTS.md /.memsearch /.playwright-mcp /.sverklo diff --git a/README.md b/README.md index 417c77ef..c9f0be61 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ License

-**Multi-user book tracking webapp** — maintain four reading lists, import books from Open Library & Google Books, scrape cover art, and get rich reading analytics — all on your own hardware. +**Multi-user book tracking webapp** — maintain four reading lists, import books from six metadata sources, scrape cover art, and get rich reading analytics — all on your own hardware. > `docker compose up -d` → full data ownership, no vendor lock-in, no API keys required. @@ -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.** Works with Open Library out of the box. Add Google Books or Hardcover.app tokens optionally for richer search results. +- **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. @@ -80,11 +80,13 @@ Open **http://localhost:8001** and create your account. - **Library** — Grid/list view, search and sort, four reading statuses (Want to Read, Currently Reading, Read, Did Not Finish) - **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 -- **Book import** — Search Open Library, Google Books, Hardcover.app. Scan ISBN barcodes on mobile. Manual entry for anything not found +- **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, 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 - **Multi-user** — Admin/user roles, per-user libraries, optional OIDC login +- **Public profiles** — Shareable, read-only profile links with configurable sections, audience, language, and optional expiry - **Themes** — Light, dark, and custom DaisyUI themes with persistent preferences - **Administration** — Full backup/restore of the SQLite database, user management, API key management diff --git a/backend/app/config.py b/backend/app/config.py index 9876a1d6..ea95b4ed 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,17 @@ class Settings(BaseSettings): cover_import_timeout_seconds: int = 15 hardcover_app_api_token: str = "" thalia_cover_search_enabled: bool = False + open_library_import_search_enabled: bool = True + # Hardcover and Google Books need credentials to answer at all. The built-in default + # stays true so upgrading never silently drops a source someone had configured + # before these switches existed; without a token/key the source is skipped anyway. + # .env.example and the docs recommend setting these to false when unused. + hardcover_import_search_enabled: bool = True + google_books_import_search_enabled: bool = True + 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/main.py b/backend/app/main.py index 432219eb..b619cc84 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -76,9 +76,46 @@ async def _telemetry_heartbeat() -> None: await asyncio.sleep(_TELEMETRY_INTERVAL_SECONDS) +def _validate_import_sources() -> None: + """Ensure at least one import search provider can actually answer a query. + + Counting enabled flags alone is not enough: Hardcover and Google Books require a + token/API key, so being switched on without credentials still means no source can + answer. This checks for a provider that is both enabled and usable. + """ + from app.services import book_import + + active: list[str] = [] + for spec in book_import._build_sources(""): + if not spec.enabled(): + continue + if spec.stage == "hardcover" and not settings.hardcover_app_api_token.strip(): + continue + active.append(spec.stage) + + if book_import._google_fallback_enabled() and settings.google_books_api_key.strip(): + active.append("google_books") + + if not active: + keyless = ", ".join( + sorted( + f"{spec.stage.upper()}_IMPORT_SEARCH_ENABLED" + for spec in book_import._build_sources("") + if spec.stage not in {"hardcover", "google_books"} + ) + ) + raise RuntimeError( + "No usable book import source is configured. " + f"Set at least one of {keyless} to true, or provide " + "HARDCOVER_APP_API_TOKEN with HARDCOVER_IMPORT_SEARCH_ENABLED=true, " + "or GOOGLE_BOOKS_API_KEY with GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=true." + ) + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: create required directories and start background tasks.""" + _validate_import_sources() Path(settings.covers_dir).mkdir(parents=True, exist_ok=True) Path(settings.import_temp_dir).mkdir(parents=True, exist_ok=True) cleanup_temp_files() diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index 8df096cd..15856d1f 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -17,6 +17,7 @@ from app.schemas import BookImportCandidate, BookImportRequest, BookRead from app.services import book_import from app.services.authors import resolve_authors_payload, sync_book_authors +from app.services.cover_import import is_safe_cover_import_url from app.services.cover_storage import download_cover from app.services.tags import build_book_read, sync_book_tags @@ -134,6 +135,10 @@ async def import_book( # Attempt to download and cache the cover locally; fall back to external URL. cover_url = c.cover_url + if cover_url and not is_safe_cover_import_url(cover_url): + # The candidate is user-submitted, so its cover URL is untrusted input. + logger.warning("Rejecting cover URL for unsafe address: %s", cover_url) + cover_url = None if cover_url: async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: filename = await download_cover(cover_url, settings.covers_dir, client, current_user.id) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6ebe2e57..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" + 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 fbbe5082..9ae9dbeb 100644 --- a/backend/app/services/book_import.py +++ b/backend/app/services/book_import.py @@ -3,20 +3,30 @@ Search priority: 1. Open Library (no API key required) - 2. Google Books (optional API key, used as fallback) + 2. BnF (no API key required) + 3. Inventaire (no API key required, ISBN-only) + 4. Library of Congress (no API key required) + 5. Hardcover (API token required, skipped if not configured) + 6. Google Books (optional API key, used as fallback when all above return empty) -Both return a list of BookImportCandidate objects normalised to the same schema. +All return a list of BookImportCandidate objects normalised to the same schema. """ import asyncio import logging import random +import re +import xml.etree.ElementTree as ET from collections.abc import AsyncGenerator -from typing import Literal, Optional +from dataclasses import dataclass +from typing import Awaitable, Callable, Literal, Optional import httpx import pycountry +from defusedxml import ElementTree as SafeET +from defusedxml.common import DefusedXmlException +from app.config import settings from app.schemas import BookImportCandidate from app.services.cover_import import is_safe_cover_import_url from app.services.isbn_utils import normalize_isbn @@ -26,6 +36,18 @@ OPEN_LIBRARY_SEARCH_URL: str = "https://openlibrary.org/search.json" OPEN_LIBRARY_COVER_URL: str = "https://covers.openlibrary.org/b/id/{cover_id}-L.jpg" +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" + +# The LoC SRU server listens on plain HTTP port 210, which is frequently blocked or +# silently dropped by egress firewalls. Give it a shorter budget than the shared +# client so a blocked LoC cannot hold up the whole search. +_LOC_TIMEOUT_SECONDS: float = 5.0 + _USER_AGENT: str = "LibrisLog/1.0 (book import; +https://github.com/codebude/librislog)" GOOGLE_BOOKS_SEARCH_URL: str = "https://www.googleapis.com/books/v1/volumes" @@ -79,7 +101,14 @@ class SourceBackendError(Exception): """Raised when an external book source returns an error response.""" - def __init__(self, source: Literal["open_library", "google_books"], status_code: int | None = None) -> None: + def __init__( + self, + source: Literal[ + "open_library", "google_books", "hardcover", "bnf", "inventaire", + "library_of_congress", "dnb" + ], + status_code: int | None = None, + ) -> None: self.source = source self.status_code = status_code super().__init__(f"{source} backend error") @@ -94,6 +123,154 @@ def _truncate_api_key(api_key: str) -> str: return f"{api_key[:4]}...{api_key[-4:]}" +# ── Source registry ─────────────────────────────────────────────────────────── + + +def _ol_enabled() -> bool: + return settings.open_library_import_search_enabled + + +def _bnf_enabled() -> bool: + return settings.bnf_import_search_enabled + + +def _inventaire_enabled() -> bool: + return settings.inventaire_import_search_enabled + + +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 + + +def _google_fallback_enabled() -> bool: + """Return True when the Google Books fallback may run.""" + return settings.google_books_import_search_enabled + + +def _skip_reason(stage: str, search_type: str, hardcover_api_token: str) -> str | None: + """Return why a source cannot run for this query, or None when it can. + + A skipped source is reported once and never queried, so the UI does not claim a + search ran when nothing was sent. + """ + if stage == "hardcover" and not hardcover_api_token.strip(): + return "no_api_token" + if stage == "inventaire" and search_type != "isbn": + # Inventaire exposes no usable title-search route. + return "title_search_unsupported" + return None + + +@dataclass(frozen=True) +class SourceSpec: + """Configuration for a single book import search provider.""" + + stage: str + display_name: str + enabled: Callable[[], bool] + search: Callable[[str, str, httpx.AsyncClient], Awaitable[list[BookImportCandidate]]] + + +def _build_sources(hardcover_api_token: str) -> tuple[SourceSpec, ...]: + """Return the parallel search sources in result-order. + + Hardcover is always listed so the UI can report it as skipped when no token is + configured; its dispatcher is bound to the token passed by the caller rather than + to settings, so an explicit token argument keeps working exactly as before. + """ + return ( + SourceSpec( + stage="open_library", + display_name="Open Library", + enabled=_ol_enabled, + search=_dispatch_open_library, + ), + SourceSpec( + stage="bnf", + display_name="BnF", + enabled=_bnf_enabled, + search=_dispatch_bnf, + ), + SourceSpec( + stage="inventaire", + display_name="Inventaire", + enabled=_inventaire_enabled, + search=_dispatch_inventaire, + ), + SourceSpec( + stage="library_of_congress", + display_name="Library of Congress", + 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", + enabled=_hc_enabled, + search=_make_hardcover_dispatcher(hardcover_api_token), + ), + ) + + +def _make_hardcover_dispatcher( + api_token: str, +) -> Callable[[str, str, httpx.AsyncClient], Awaitable[list[BookImportCandidate]]]: + """Build a Hardcover search callable bound to ``api_token``.""" + + async def _dispatch(query: str, search_type: str, client: httpx.AsyncClient) -> list[BookImportCandidate]: + if not api_token.strip(): + return [] + return await _search_hardcover(query, search_type, api_token, client) + + return _dispatch + + +# Indirection through module-level wrappers so tests can monkeypatch the +# underlying search functions (see tests/test_book_import.py). +async def _dispatch_open_library( + query: str, search_type: str, client: httpx.AsyncClient +) -> list[BookImportCandidate]: + return await _search_open_library(query, search_type, client) + + +async def _dispatch_bnf( + query: str, search_type: str, client: httpx.AsyncClient +) -> list[BookImportCandidate]: + return await _search_bnf(query, search_type, client) + + +async def _dispatch_inventaire( + query: str, search_type: str, client: httpx.AsyncClient +) -> list[BookImportCandidate]: + return await _search_inventaire(query, search_type, client) + + +async def _dispatch_loc( + query: str, search_type: str, client: httpx.AsyncClient +) -> list[BookImportCandidate]: + 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 ──────────────────────────────────────────────────────────────── @@ -109,26 +286,16 @@ async def search_with_progress( """Search external book sources and yield progress events as dicts. Event shapes (stage / extra fields): - open_library / status=searching - open_library / status=done, count=int - open_library / status=error, reason=str - hardcover / status=searching - hardcover / status=done, count=int - hardcover / status=skipped, reason=str - hardcover / status=error, reason=str - google_books / status=searching - google_books / status=done, count=int - google_books / status=skipped, reason=str - google_books / status=error, reason=str - complete / results=list[dict] - error / message=str + All sources / status=searching, done (count), error (reason), skipped (reason) + complete / results=list[dict] + error / message=str Args: query: Search query string. search_type: ``"title"`` or ``"isbn"``. api_key: Google Books API key (optional). - hardcover_api_token: Hardcover.app API token (optional). - mode: ``"auto"`` (OL + HC, fallback to GB) or ``"google_only"``. + hardcover_api_token: Hardcover.app API token (optional, legacy). + mode: ``"auto"`` (all enabled sources, fallback to GB) or ``"google_only"``. http_client: Reusable httpx client (created internally if omitted). Yields: @@ -149,7 +316,14 @@ async def search_with_progress( results: list[BookImportCandidate] = [] if mode == "google_only": - if not api_key: + if not _google_fallback_enabled(): + logger.warning( + "Google-only search requested for %r but the Google Books source " + "is disabled (GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false)", + query, + ) + yield {"stage": "google_books", "status": "skipped", "reason": "disabled"} + elif not api_key: logger.warning("Google-only search requested for %r but API key is not set", query) yield {"stage": "google_books", "status": "skipped", "reason": "no_api_key"} else: @@ -167,52 +341,78 @@ async def search_with_progress( logger.warning("Google Books backend error for %r: status=%s", query, exc.status_code) yield {"stage": "google_books", "status": "error", "reason": "backend_error"} else: - ol_events: list[dict] = [] - hc_events: list[dict] = [] - ol_results: list[BookImportCandidate] = [] - hc_results: list[BookImportCandidate] = [] - - yield {"stage": "open_library", "status": "searching"} - if hardcover_api_token.strip(): - yield {"stage": "hardcover", "status": "searching"} - - async def _run_ol() -> None: - nonlocal ol_results, ol_events - try: - r = await _search_open_library(query, search_type, client) - ol_results = r - ol_events.append({"stage": "open_library", "status": "done", "count": len(r)}) - except SourceBackendError as exc: - ol_events.append({"stage": "open_library", "status": "error", "reason": "backend_error"}) - - async def _run_hc() -> None: - nonlocal hc_results, hc_events - if not hardcover_api_token.strip(): - hc_events.append({"stage": "hardcover", "status": "skipped", "reason": "no_api_token"}) - return - try: - r = await _search_hardcover(query, search_type, hardcover_api_token, client) - hc_results = r - hc_events.append({"stage": "hardcover", "status": "done", "count": len(r)}) - except Exception: - logger.exception("hardcover search error") - hc_events.append({"stage": "hardcover", "status": "error", "reason": "backend_error"}) - - await asyncio.gather(_run_ol(), _run_hc()) - - for e in ol_events: - yield e - for e in hc_events: - yield e - - results = _merge_results(ol_results, hc_results) + enabled_sources = [s for s in _build_sources(hardcover_api_token) if s.enabled()] + + if enabled_sources: + all_events: dict[str, list[dict]] = {s.stage: [] for s in enabled_sources} + all_results: dict[str, list[BookImportCandidate]] = {s.stage: [] for s in enabled_sources} + + # A source that cannot run for this query gets exactly one terminal + # `skipped` event and is excluded from the gather, so it never also + # reports a result count. + runnable: list[SourceSpec] = [] + for s in enabled_sources: + reason = _skip_reason(s.stage, search_type, hardcover_api_token) + if reason is not None: + all_events[s.stage].append( + {"stage": s.stage, "status": "skipped", "reason": reason} + ) + else: + all_events[s.stage].append({"stage": s.stage, "status": "searching"}) + runnable.append(s) + + async def _run_source(spec: SourceSpec) -> None: + try: + r = await spec.search(query, search_type, client) + all_results[spec.stage] = r + all_events[spec.stage].append( + {"stage": spec.stage, "status": "done", "count": len(r)} + ) + except SourceBackendError as exc: + logger.warning( + "%s backend error for %r: status=%s", spec.stage, query, exc.status_code + ) + all_events[spec.stage].append( + {"stage": spec.stage, "status": "error", "reason": "backend_error"} + ) + except Exception: + logger.exception("%s search error", spec.stage) + all_events[spec.stage].append( + {"stage": spec.stage, "status": "error", "reason": "backend_error"} + ) + + await asyncio.gather(*[_run_source(s) for s in runnable]) + + for s in enabled_sources: + for e in all_events[s.stage]: + yield e + + merged: list[BookImportCandidate] = [] + for s in runnable: + merged = _merge_results(merged, all_results[s.stage]) + results = merged + else: + # Every parallel source is switched off. Google Books can still answer + # as the fallback, which is the only remaining provider. + logger.warning( + "No parallel import search provider is enabled for %r — " + "only the Google Books fallback is available", + query, + ) if not results: - if not api_key: + if not _google_fallback_enabled(): + logger.warning( + "No results for %r and the Google Books fallback is disabled " + "(GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false)", + query, + ) + yield {"stage": "google_books", "status": "skipped", "reason": "disabled"} + elif not api_key: logger.warning("No results for %r and GOOGLE_BOOKS_API_KEY is not set", query) yield {"stage": "google_books", "status": "skipped", "reason": "no_api_key"} else: - logger.info("No OL/HC results — falling back to Google Books") + logger.info("No results from any source — falling back to Google Books") logger.debug( "Google Books invocation — mode=%s query=%r search_type=%r api_key=%s", mode, query, search_type, _truncate_api_key(api_key), @@ -244,17 +444,17 @@ async def search( hardcover_api_token: str = "", http_client: Optional[httpx.AsyncClient] = None, ) -> list[BookImportCandidate]: - """Search Open Library in parallel with Hardcover; fall back to Google Books if both empty. + """Search all enabled external book sources; fall back to Google Books if all return empty. Args: query: Search query string. search_type: ``"title"`` or ``"isbn"``. api_key: Google Books API key (optional). - hardcover_api_token: Hardcover.app API token (optional). + hardcover_api_token: Hardcover.app API token (optional, legacy). http_client: Reusable httpx client (created internally if omitted). Returns: - A deduplicated list of BookImportCandidate objects. + A list of BookImportCandidate objects. """ logger.debug("search() called — query=%r search_type=%r has_api_key=%s has_hc_token=%s", query, search_type, bool(api_key), bool(hardcover_api_token)) @@ -266,32 +466,48 @@ async def search( ) try: - tasks = [_search_open_library(query, search_type, client)] - if hardcover_api_token.strip(): - tasks.append(_search_hardcover(query, search_type, hardcover_api_token, client)) - - results_list = await asyncio.gather(*tasks, return_exceptions=True) - - ol_results = results_list[0] if not isinstance(results_list[0], BaseException) else [] - if isinstance(results_list[0], SourceBackendError): - logger.warning("Open Library backend error for %r: status=%s", query, results_list[0].status_code) - elif isinstance(results_list[0], Exception): - logger.warning("Open Library error for %r: %s", query, results_list[0]) - - hc_results: list[BookImportCandidate] = [] - if len(results_list) > 1: - if not isinstance(results_list[1], BaseException): - hc_results = results_list[1] - else: - logger.warning("Hardcover error for %r: %s", query, results_list[1]) + enabled_sources = [s for s in _build_sources(hardcover_api_token) if s.enabled()] + results: list[BookImportCandidate] = [] - logger.info("Open Library returned %d result(s) for %r", len(ol_results), query) - logger.info("Hardcover returned %d result(s) for %r", len(hc_results), query) + if enabled_sources: + all_results: list[list[BookImportCandidate]] = [] - results = _merge_results(ol_results, hc_results) + async def _run_source(spec: SourceSpec) -> list[BookImportCandidate]: + try: + r = await spec.search(query, search_type, client) + logger.info("%s returned %d result(s) for %r", spec.stage, len(r), query) + return r + except SourceBackendError as exc: + logger.warning("%s backend error for %r: status=%s", spec.stage, query, exc.status_code) + return [] + except Exception as exc: + logger.warning("%s error for %r: %s", spec.stage, query, exc) + return [] + + results_list = await asyncio.gather(*[_run_source(s) for s in enabled_sources]) + + for r in results_list: + all_results.append(r) + + for r in all_results: + results = _merge_results(results, r) + else: + # Every parallel source is switched off. Google Books can still answer as + # the fallback, which is the only remaining provider. + logger.warning( + "No parallel import search provider is enabled for %r — " + "only the Google Books fallback is available", + query, + ) if not results: - if not api_key: + if not _google_fallback_enabled(): + logger.warning( + "No results for %r and the Google Books fallback is disabled " + "(GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false)", + query, + ) + elif not api_key: logger.warning( "No results for %r and GOOGLE_BOOKS_API_KEY is not set — " "Google Books requires an API key for all requests. " @@ -299,7 +515,7 @@ async def search( query, ) else: - logger.info("No OL/HC results — falling back to Google Books") + logger.info("No results from any source — falling back to Google Books") try: results = await _search_google_books(query, search_type, api_key, client) except SourceBackendError as exc: @@ -826,26 +1042,1203 @@ def map_hardcover(edition: dict) -> BookImportCandidate | None: ) -# ── Merge / Deduplicate ─────────────────────────────────────────────────────── +# ── BnF ──────────────────────────────────────────────────────────────────────── + + +async def _search_bnf( + query: str, + search_type: str, + client: httpx.AsyncClient, +) -> list[BookImportCandidate]: + """Search BnF catalogue general via SRU. + + Raises: + SourceBackendError: On HTTP errors from BnF. + """ + if search_type == "isbn": + try: + isbn13 = normalize_isbn(query) + except ValueError: + logger.warning("BnF invalid ISBN: %s", query) + return [] + query_str = f'bib.isbn adj "{isbn13}"' + else: + term = _sanitize_cql_term(query) + if not term: + logger.debug("BnF: title query is empty after sanitizing") + return [] + query_str = f'bib.title all "{term}" and bib.doctype adj "a"' + + params: dict = { + "version": "1.2", + "operation": "searchRetrieve", + "query": query_str, + "recordSchema": "dublincore", + "maximumRecords": 10, + "sortBy": "dat/sort.descending", + } + + logger.debug("BnF request — url=%s params=%s", BNF_SRU_URL, params) + + try: + resp = await client.get(BNF_SRU_URL, params=params, headers={"User-Agent": _USER_AGENT}) + logger.debug("BnF response — status=%d body_size=%d bytes", + resp.status_code, len(resp.content)) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning("BnF HTTP error: %s %s", exc.response.status_code, exc.response.text[:200]) + raise SourceBackendError("bnf", exc.response.status_code) from exc + except httpx.HTTPError as exc: + logger.warning("BnF request failed: %s", exc) + raise SourceBackendError("bnf") from exc + + candidates = map_bnf_response(resp.text) + for c in candidates: + logger.debug(" BnF candidate: title=%r isbn=%r", c.title, c.isbn) + return candidates + + +def map_bnf_response(xml_text: str) -> list[BookImportCandidate]: + """Parse BnF SRU Dublin Core XML response into BookImportCandidate list.""" + namespaces = { + "srw": "http://www.loc.gov/zing/srw/", + "oai_dc": "http://www.openarchives.org/OAI/2.0/oai_dc/", + "dc": "http://purl.org/dc/elements/1.1/", + } + + 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("BnF XML parse error: %s", exc) + return [] + + records = root.findall(".//oai_dc:dc", namespaces) + + # BnF answers one ISBN with every record that carries it, which includes a + # commercial barcode stub ("Code a barres commercial : EAN ..."). The marker also + # appears on genuine records, so it only ranks a record lower instead of dropping + # it: within an ISBN group the richest, least barcode-ish record wins. + best_by_key: dict[str, tuple[BookImportCandidate, bool]] = {} + order: list[str] = [] + seen_arks: set[str] = set() + + for record in records: + candidate = map_bnf_record(record, namespaces) + if candidate is None: + continue + ark = _extract_bnf_ark(record, namespaces) + if ark and ark in seen_arks: + continue + if ark: + seen_arks.add(ark) + + key = candidate.isbn or ark or f"__record_{len(order)}" + is_shell = _has_bnf_barcode_marker(record, namespaces) + incumbent = best_by_key.get(key) + if incumbent is None: + best_by_key[key] = (candidate, is_shell) + order.append(key) + continue + if not is_shell and (incumbent[1] or _bnf_record_richness(candidate) > _bnf_record_richness(incumbent[0])): + best_by_key[key] = (candidate, is_shell) + + return [best_by_key[key][0] for key in order] + + +def _bnf_record_richness(candidate: BookImportCandidate) -> int: + """Count populated fields, used to pick the better of two BnF records.""" + return sum( + 1 + for value in ( + candidate.subtitle, + candidate.author, + candidate.publisher, + candidate.published_year, + candidate.page_count, + candidate.language, + candidate.tags, + ) + if value + ) + + +def _has_bnf_barcode_marker(record: ET.Element, namespaces: dict[str, str]) -> bool: + """Return True when a BnF record is (or includes) the commercial barcode stub.""" + for description in record.findall("dc:description", namespaces): + if description.text and "Code à barres commercial" in description.text: + return True + return False + + +def _extract_bnf_isbn(record: ET.Element, namespaces: dict[str, str]) -> str | None: + """Extract the ISBN from a BnF record's dc:identifier values ('ISBN 9791020923554').""" + for identifier in record.findall("dc:identifier", namespaces): + text = (identifier.text or "").strip() + match = re.search(r"ISBN\s+([0-9Xx][0-9Xx\s-]{8,}[0-9Xx])", text, re.IGNORECASE) + if not match: + continue + try: + return normalize_isbn(match.group(1)) + except ValueError: + continue + return None + + +def map_bnf_record(record: ET.Element, namespaces: dict[str, str]) -> BookImportCandidate | None: + """Map a single BnF Dublin Core record to BookImportCandidate.""" + title_elem = record.find("dc:title", namespaces) + if title_elem is None or not title_elem.text: + return None + + raw_title = title_elem.text + title, subtitle = _split_bnf_title(raw_title) + + authors: list[str] = [] + for creator in record.findall("dc:creator", namespaces): + if creator.text: + author = _clean_bnf_author(creator.text) + if author: + authors.append(author) + for contributor in record.findall("dc:contributor", namespaces): + if contributor.text: + author = _clean_bnf_author(contributor.text) + if author and author not in authors: + authors.append(author) + author_str = ", ".join(authors) if authors else None + + publishers = [p.text.strip() for p in record.findall("dc:publisher", namespaces) if p.text] + publisher = _clean_bnf_publisher(publishers[0]) if publishers else None + + date_elem = record.find("dc:date", namespaces) + published_year = None + if date_elem is not None and date_elem.text: + try: + published_year = int(date_elem.text[:4]) + except (ValueError, IndexError): + pass + + format_elem = record.find("dc:format", namespaces) + page_count = None + if format_elem is not None and format_elem.text: + page_count = _parse_page_count(format_elem.text) + + languages = [l.text for l in record.findall("dc:language", namespaces) if l.text] + language = _normalize_language_code(languages[0]) if languages else None + + subjects = [_clean_bnf_tag(s.text) for s in record.findall("dc:subject", namespaces) if s.text] + subjects = [s for s in subjects if s] + tags = ", ".join(subjects[:3]) if subjects else None + + return BookImportCandidate( + title=title, + subtitle=subtitle, + author=author_str, + authors=authors, + isbn=_extract_bnf_isbn(record, namespaces), + cover_url=None, + publisher=publisher, + published_year=published_year, + page_count=page_count, + language=language, + tags=tags, + source="bnf", + ) + + +def _clean_bnf_publisher(raw: str) -> str: + """Drop the BnF publication-place suffix, e.g. 'Editions Gallimard (Paris)'.""" + return re.sub(r"\s*\([^()]*\)\s*$", "", raw).strip() or raw.strip() + + +def _split_bnf_title(raw_title: str) -> tuple[str, Optional[str]]: + """Split a BnF dc:title into (title, subtitle). + + BnF conventions seen in the catalogue: + "L'heure du Sud : ou l'invention ... (Éd. 2024)" -> edition statement, dropped + "Dune / Frank Herbert" -> statement of responsibility, dropped + "Titre : sous-titre" -> real subtitle, kept + """ + title = raw_title.strip() + + # Drop a trailing edition statement. BnF returns both accented and unaccented + # forms, e.g. "(Ed. 2024)" and "(Éd. 2024)". + title = re.sub( + r"\s*\((?:[ÉEeé]d\.?|[ÉEeé]dition)\s*[^()]*\)\s*$", + "", + title, + ).strip() + + # Drop a trailing statement of responsibility after " / ". + if " / " in title: + title = title.split(" / ", 1)[0].strip() + + if " : " in title: + head, tail = title.split(" : ", 1) + subtitle = tail.split(" / ", 1)[0].strip() if " / " in tail else tail.strip() + # The edition statement may sit after the subtitle rather than the whole title. + subtitle = re.sub( + r"\s*\((?:[ÉEeé]d\.?|[ÉEeé]dition)\s*[^()]*\)\s*$", + "", + subtitle, + ).strip() + return head.strip(), subtitle or None + + return title, None + + +# BnF subject headings use " -- " for subdivisions ("Relations internationales -- +# 2001-...."). Keep the top-level heading only. +_BNF_SUBDIVISION_RE = re.compile(r"\s+--\s+.*$") + + +def _clean_bnf_tag(tag: str) -> str: + """Strip the subdivision suffix from a single BnF subject heading.""" + return _BNF_SUBDIVISION_RE.sub("", tag).strip() or tag.strip() + + +def _sanitize_cql_term(value: str) -> str: + """Make a user query safe to interpolate inside a quoted CQL string. + + A stray double quote would otherwise terminate the CQL string early; BnF answers + such a query with a diagnostic document and zero records instead of an error, so + the breakage would be silent. + """ + cleaned = value.replace('"', " ").replace("\\", " ") + return re.sub(r"\s+", " ", cleaned).strip() + + +# Role terms BnF appends after a personal name (UNIMARC 7xx role codes rendered in +# French). Matching a known vocabulary keeps real names such as "Ursula K. Le Guin" +# intact, which a generic "strip everything after a period" rule would mangle. +_BNF_ROLE_RE = re.compile( + r"\.\s*(?:auteur|editeur|éditeur|traducteur|postfacier|préfacier|prefacier|" + r"illustrateur|directeur de publication|photographe|compilateur|annotateur|" + r"correcteur|dessinateur|graveur|interviewer|prologue|epilogue|épilogue|" + r"preface|préface|enregistrement|editing|revue de|de)\b.*$", + re.IGNORECASE, +) + + +def _clean_bnf_author(raw: str) -> str: + """Strip BnF role suffixes from a creator/contributor string. + + Handles "Badie, Bertrand (1950-....). Éditeur scientifique" and + "Labica, Thierry. Postfacier" down to the bare name. + """ + author = raw.strip() + + # Life dates first: "Name (1900-1980)" or "Name (1950-.... ; journaliste)". + author = re.sub(r"\s*\((?:\d{4}|-{3})[^)]*\)", "", author).strip() + + # Then a trailing role phrase: "Name. Postfacier". + author = _BNF_ROLE_RE.sub("", author).strip() + + return author.rstrip(".").strip() or raw.strip() + + +def _extract_bnf_ark(record: ET.Element, namespaces: dict[str, str]) -> str | None: + """Extract BnF ARK identifier from a record.""" + for identifier in record.findall("dc:identifier", namespaces): + if identifier.text and "ark:/" in identifier.text: + return identifier.text.strip() + return None + + +# ── Inventaire.io ─────────────────────────────────────────────────────────────── + +# Claims pulled along with the edition in the single by-uris request: +# P629 -> the work, P50 -> authors on that work, P123 -> publisher. +# Each relative is returned as a full entity with resolved labels. +INVENTAIRE_RELATIVES: tuple[str, ...] = ("wdt:P629", "wdt:P50", "wdt:P123") + +_LANG_PREFERENCE: tuple[str, ...] = ("en", "fr", "de", "es", "it") + + +async def _search_inventaire( + query: str, + search_type: str, + client: httpx.AsyncClient, +) -> list[BookImportCandidate]: + """Search inventaire.io by ISBN via the entities API. + + Inventaire is ISBN-only: it has no usable title-search route. + + Raises: + SourceBackendError: On HTTP errors from inventaire.io. + """ + if search_type != "isbn": + logger.debug("Inventaire: skipping title search (ISBN-only provider)") + return [] + + try: + isbn13 = normalize_isbn(query) + except ValueError: + logger.warning("Inventaire invalid ISBN: %s", query) + return [] + + # relatives must be repeated query params; a comma-joined value returns HTTP 400. + params: list[tuple[str, str]] = [("uris", f"isbn:{isbn13}")] + params += [("relatives", rel) for rel in INVENTAIRE_RELATIVES] + params.append(("lang", "en")) + + logger.debug("Inventaire request — url=%s isbn=%s", INVENTAIRE_ENTITIES_URL, isbn13) + + try: + resp = await client.get(INVENTAIRE_ENTITIES_URL, params=params, headers={"User-Agent": _USER_AGENT}) + logger.debug("Inventaire response — status=%d body_size=%d bytes", + resp.status_code, len(resp.content)) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning("Inventaire HTTP error: %s %s", exc.response.status_code, exc.response.text[:200]) + raise SourceBackendError("inventaire", exc.response.status_code) from exc + except httpx.HTTPError as exc: + logger.warning("Inventaire request failed: %s", exc) + raise SourceBackendError("inventaire") from exc + + candidates = map_inventaire_response(resp.json()) + for c in candidates: + logger.debug(" Inventaire candidate: title=%r author=%r isbn=%r cover=%r", + c.title, c.author, c.isbn, c.cover_url) + return candidates + + +def map_inventaire_response(data: dict) -> list[BookImportCandidate]: + """Parse an inventaire.io by-uris response into a BookImportCandidate list. + + The response is ``{"entities": {uri: entity}, "redirects": {"isbn:...": uri}}``. + Every claim value is a plain string; the ISBN redirect points at the edition. + """ + entities: dict[str, dict] = data.get("entities") or {} + if not entities: + return [] + + edition_uri = _find_inventaire_edition_uri(data.get("redirects") or {}, entities) + if edition_uri is None: + return [] + + edition = entities[edition_uri] + candidate = map_inventaire_entity(edition, entities, entities[edition_uri].get("labels") or {}) + if candidate is None: + return [] + if not candidate.isbn: + candidate = candidate.model_copy(update={"isbn": _isbn_from_uri(edition_uri)}) + return [candidate] + + +def _find_inventaire_edition_uri(redirects: dict[str, str], entities: dict[str, dict]) -> str | None: + """Return the URI of the edition entity in an inventaire.io response.""" + for uri, target in redirects.items(): + if uri.startswith("isbn:") and target in entities: + return target + # Fall back to any entity that actually is an edition. + for uri, entity in entities.items(): + if entity.get("type") == "edition": + return uri + return None + + +def _isbn_from_uri(uri: str) -> str | None: + """Return the ISBN carried by an ``isbn:`` URI, normalized. + + Returns None when the value is not a valid ISBN, so a malformed identifier is + never persisted. + """ + if not uri.startswith("isbn:"): + return None + raw = uri[len("isbn:"):] + try: + return normalize_isbn(raw) + except ValueError: + logger.debug("Inventaire returned a non-ISBN identifier: %r", raw) + return None + + +def map_inventaire_entity( + edition: dict, + entities: dict[str, dict], + labels: dict[str, str] | None = None, +) -> BookImportCandidate | None: + """Map an inventaire.io edition entity to BookImportCandidate.""" + claims: dict[str, list[str]] = edition.get("claims") or {} + + title = _inventaire_claim(claims, "wdt:P1476") + if not title: + title = _inventaire_label(labels or {}) + if not title: + return None + subtitle = _inventaire_claim(claims, "wdt:P1680") + + isbn: str | None = None + for raw in _inventaire_claims(claims, "wdt:P212") + _inventaire_claims(claims, "wdt:P957"): + try: + isbn = normalize_isbn(raw) + break + except ValueError: + continue + + published_year = _inventaire_year(_inventaire_claim(claims, "wdt:P577")) + page_count = _inventaire_int(_inventaire_claim(claims, "wdt:P1104")) + + language = _normalize_language_code(edition.get("originalLang") or None) + + cover_url = _inventaire_cover(edition, claims) + + authors = _inventaire_authors(claims, entities) + author = ", ".join(authors) if authors else None + + publisher = _inventaire_publisher(claims, entities) + + return BookImportCandidate( + title=title, + subtitle=subtitle, + author=author, + authors=authors, + isbn=isbn, + cover_url=cover_url, + publisher=publisher, + published_year=published_year, + page_count=page_count, + language=language, + tags=None, + source="inventaire", + ) + + +def _inventaire_claims(claims: dict[str, list[str]], claim_id: str) -> list[str]: + """Return the string values of a claim, tolerating legacy dict-shaped values.""" + values: list[str] = [] + for raw in claims.get(claim_id) or []: + if isinstance(raw, str) and raw: + values.append(raw) + elif isinstance(raw, dict): + value = raw.get("value") + if isinstance(value, str) and value: + values.append(value) + return values + + +def _inventaire_claim(claims: dict[str, list[str]], claim_id: str) -> str | None: + """Return the first string value of a claim.""" + values = _inventaire_claims(claims, claim_id) + return values[0] if values else None -def _merge_results( - primary: list[BookImportCandidate], - secondary: list[BookImportCandidate], +def _inventaire_label(labels: dict[str, str]) -> str | None: + """Pick a label in the reader's likely language, ignoring generated ones.""" + for key in _LANG_PREFERENCE: + value = labels.get(key) + if value: + return value + return None + + +def _inventaire_year(raw: str | None) -> int | None: + """Extract the year from a Wikidata date value such as '2024-03-01'.""" + if not raw: + return None + match = re.match(r"^(-?\d{1,4})", raw.strip()) + if not match: + return None + try: + return int(match.group(1)) + except ValueError: + return None + + +def _inventaire_int(raw: str | None) -> int | None: + """Parse a numeric claim value such as a page count.""" + if not raw: + return None + match = re.search(r"\d+", raw) + if not match: + return None + return int(match.group(0)) + + +def _inventaire_cover(edition: dict, claims: dict[str, list[str]]) -> str | None: + """Build the absolute cover URL from the image hash, if one exists.""" + cover_hash = _inventaire_claim(claims, "invp:P2") + if not cover_hash: + image_url = (edition.get("image") or {}).get("url") or "" + cover_hash = image_url.rsplit("/", 1)[-1] if image_url else "" + if not cover_hash: + return None + cover_url = f"{INVENTAIRE_COVER_BASE_URL}/{cover_hash}" + return cover_url if is_safe_cover_import_url(cover_url) else None + + +def _inventaire_authors(claims: dict[str, list[str]], entities: dict[str, dict]) -> list[str]: + """Resolve author labels from the work entity referenced by the edition. + + Native inventaire works often carry no P50, so the edition's own P50 is used as a + fallback when the work yields nothing. + """ + work_uri = _inventaire_claim(claims, "wdt:P629") + work = entities.get(work_uri) if work_uri else None + + authors = _inventaire_author_labels((work or {}).get("claims") or {}, entities) + if not authors: + authors = _inventaire_author_labels(claims, entities) + return authors + + +def _inventaire_author_labels(claims: dict[str, list[str]], entities: dict[str, dict]) -> list[str]: + """Resolve P50 author URIs into display labels.""" + authors: list[str] = [] + for author_uri in _inventaire_claims(claims, "wdt:P50"): + entity = entities.get(author_uri) + label = _inventaire_label((entity or {}).get("labels") or {}) + if label and label not in authors: + authors.append(label) + return authors + + +def _inventaire_publisher(claims: dict[str, list[str]], entities: dict[str, dict]) -> str | None: + """Resolve the publisher label from the edition's P123 relative.""" + publisher_uri = _inventaire_claim(claims, "wdt:P123") + if not publisher_uri: + return None + entity = entities.get(publisher_uri) + if not entity: + return None + return _inventaire_label(entity.get("labels") or {}) + + +# ── Library of Congress ──────────────────────────────────────────────────────── + + +async def _search_loc( + query: str, + search_type: str, + client: httpx.AsyncClient, ) -> list[BookImportCandidate]: - """Merge two candidate lists, preserving every candidate in input order. + """Search Library of Congress via SRU. + + Raises: + SourceBackendError: On HTTP errors from LoC. + """ + if search_type == "isbn": + try: + isbn13 = normalize_isbn(query) + except ValueError: + logger.warning("LoC invalid ISBN: %s", query) + return [] + query_str = f"bath.isbn={isbn13}" + else: + term = _sanitize_cql_term(query) + if not term: + logger.debug("LoC: title query is empty after sanitizing") + return [] + query_str = f"bath.title={term}" + + params: dict = { + "version": "1.1", + "operation": "searchRetrieve", + "query": query_str, + "recordSchema": "mods", + "maximumRecords": 10, + } + + logger.debug("LoC request — url=%s params=%s", LOC_SRU_URL, params) + + try: + # LoC is reached over plain HTTP on port 210, which many firewalls drop rather + # than refuse. A shorter timeout keeps a blocked LoC from stalling the search. + resp = await client.get( + LOC_SRU_URL, + params=params, + headers={"User-Agent": _USER_AGENT}, + timeout=_LOC_TIMEOUT_SECONDS, + ) + logger.debug("LoC response — status=%d body_size=%d bytes", + resp.status_code, len(resp.content)) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning("LoC HTTP error: %s %s", exc.response.status_code, exc.response.text[:200]) + raise SourceBackendError("library_of_congress", exc.response.status_code) from exc + except httpx.HTTPError as exc: + logger.warning("LoC request failed: %s", exc) + raise SourceBackendError("library_of_congress") from exc + + candidates = map_loc_response(resp.text) + for c in candidates: + logger.debug(" LoC candidate: title=%r isbn=%r", c.title, c.isbn) + return candidates + + +def map_loc_response(xml_text: str) -> list[BookImportCandidate]: + """Parse LoC SRU MODS XML response into BookImportCandidate list.""" + namespaces = { + "srw": "http://www.loc.gov/zing/srw/", + "mods": "http://www.loc.gov/mods/v3", + } + + 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("LoC XML parse error: %s", exc) + return [] + + records = root.findall(".//mods:mods", namespaces) + candidates: list[BookImportCandidate] = [] + + for record in records: + candidate = map_loc_record(record, namespaces) + if candidate is not None: + candidates.append(candidate) + + return candidates + + +def map_loc_record(record: ET.Element, namespaces: dict[str, str]) -> BookImportCandidate | None: + """Map a single LoC MODS record to BookImportCandidate.""" + title_info = record.find("mods:titleInfo", namespaces) + if title_info is None: + return None + + title_elem = title_info.find("mods:title", namespaces) + if title_elem is None or not title_elem.text: + return None + + title = title_elem.text.strip() + + subtitle_elem = title_info.find("mods:subTitle", namespaces) + subtitle = subtitle_elem.text.strip() if subtitle_elem is not None and subtitle_elem.text else None + + authors: list[str] = [] + for name in record.findall("mods:name", namespaces): + # Only personal names are book authors; corporate names would be the publisher. + name_type = name.get("type", "") + if name_type and name_type != "personal": + continue + name_part = name.find("mods:namePart", namespaces) + if name_part is not None and name_part.text: + author = _reverse_loc_author(name_part.text.strip()) + if author and author not in authors: + authors.append(author) + author_str = ", ".join(authors) if authors else None + + isbn = _extract_loc_isbn(record, namespaces) + + origin_info = record.find("mods:originInfo", namespaces) + publisher = None + published_year = None + if origin_info is not None: + publisher_elem = origin_info.find("mods:publisher", namespaces) + if publisher_elem is not None and publisher_elem.text: + publisher = publisher_elem.text.strip() + + date_elem = origin_info.find("mods:dateIssued", namespaces) + if date_elem is not None and date_elem.text: + try: + published_year = int(date_elem.text[:4]) + except (ValueError, IndexError): + pass + + language = None + for lang in record.findall("mods:language", namespaces): + term = lang.find("mods:languageTerm", namespaces) + if term is not None and term.text: + language = _normalize_language_code(term.text.strip()) + break + + page_count = None + physical_desc = record.find("mods:physicalDescription", namespaces) + if physical_desc is not None: + extent = physical_desc.find("mods:extent", namespaces) + if extent is not None and extent.text: + page_count = _parse_page_count(extent.text) + + tags = None + # MODS nests topics inside , so search descendants rather than direct children. + topics = [t.text for t in record.findall(".//mods:topic", namespaces) if t.text] + if topics: + tags = ", ".join(topics[:3]) + + return BookImportCandidate( + title=title, + subtitle=subtitle, + author=author_str, + authors=authors, + isbn=isbn, + cover_url=None, + publisher=publisher, + published_year=published_year, + page_count=page_count, + language=language, + tags=tags, + source="library_of_congress", + ) + + +def _extract_loc_isbn(record: ET.Element, namespaces: dict[str, str]) -> str | None: + """Extract an ISBN from MODS identifier elements, preferring type='isbn'.""" + fallbacks: list[str] = [] + for identifier in record.findall("mods:identifier", namespaces): + text = (identifier.text or "").strip() + if not text or not re.fullmatch(r"[0-9Xx][0-9Xx\s-]{8,}[0-9Xx]", text): + continue + try: + normalized = normalize_isbn(text) + except ValueError: + continue + if identifier.get("type") == "isbn": + return normalized + fallbacks.append(normalized) + return fallbacks[0] if fallbacks else None + + +def _reverse_loc_author(raw: str) -> str: + """Reverse LoC's 'LastName, FirstName' form into 'FirstName LastName'. + + Real MODS namePart values carry trailing separators in both forms: + "Herbert, Frank." (abbreviation period) and "Horydczak, Theodor," (list comma). + Both must be stripped before the name is reversed. + """ + name = raw.strip().rstrip(".,").strip() + if ", " in name: + last, first = name.split(", ", 1) + reversed_name = f"{first.strip().rstrip('.,')} {last.strip().rstrip('.,')}".strip() + return reversed_name or name + 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 ─────────────────────────────────────────────────────── + + +def _merge_results(*lists: list[BookImportCandidate]) -> list[BookImportCandidate]: + """Merge multiple candidate lists, preserving every candidate in input order. The frontend is responsible for grouping variants that represent the same book (e.g. by ISBN) and letting the user pick the best record. A user can only own one book per exact ISBN string, so keeping all provider-specific records lets the user compare data quality before importing. """ - return primary + secondary + merged: list[BookImportCandidate] = [] + for lst in lists: + merged.extend(lst) + return merged # ── Helpers ─────────────────────────────────────────────────────────────────── +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.'. + + 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: + try: + return int(match.group(1).replace(",", "").replace(" ", "")) + except ValueError: + pass + # Try plain format: 412 p. (or 1,234 p.) + match = re.search(r"([\d,]+)\s*p\.", text) + if match: + try: + return int(match.group(1).replace(",", "").replace(" ", "")) + except ValueError: + pass + return None + + def _pick_isbn(isbns: list[str]) -> Optional[str]: """Return the first ISBN-13 found, or the first ISBN-10, or None.""" for isbn in isbns: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4812a24c..4cd98951 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -52,5 +52,11 @@ pythonpath = ["."] filterwarnings = [ "error::ResourceWarning", "ignore::DeprecationWarning:lxml\\.html.*", + # Pydantic emits a DeprecationWarning with the message "deprecated" on every read of a + # field declared with deprecated=True. The legacy `author` field is deliberately + # deprecated in favour of `authors`, and both the router and the tests read it on + # purpose, so this fires ~290 times per run and buries any real warning. The API + # deprecation itself is unaffected: it still warns for library consumers. + "ignore:^deprecated$:DeprecationWarning", ] addopts = "-ra --cov=app --cov-report term-missing" diff --git a/backend/tests/test_book_import.py b/backend/tests/test_book_import.py index 2cfb7850..aacd1d0b 100644 --- a/backend/tests/test_book_import.py +++ b/backend/tests/test_book_import.py @@ -11,6 +11,20 @@ from app.services import book_import as bi +@pytest.fixture(autouse=True) +def _all_sources_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Turn every import source on for this module. + + The orchestration tests here assert on which sources run, not on the shipped + defaults, which are covered in test_main.py. Enabling them explicitly also keeps + the suite independent of whatever the developer's .env happens to contain. + """ + for spec in bi._build_sources(""): + monkeypatch.setattr(bi.settings, f"{spec.stage}_import_search_enabled", True) + # Google Books is the fallback, not a registry entry. + monkeypatch.setattr(bi.settings, "google_books_import_search_enabled", True) + + # ── SourceBackendError ───────────────────────────────────────────────────────── def test_source_backend_error_with_status() -> None: @@ -81,6 +95,10 @@ async def test_search_with_progress_auto_ol_and_hc(monkeypatch: pytest.MonkeyPat fake_ol = BookImportCandidate(title="OL Book", source="open_library") monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[fake_ol])) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) events = [] async for e in bi.search_with_progress("query", "title", hardcover_api_token="token"): @@ -91,17 +109,29 @@ async def test_search_with_progress_auto_ol_and_hc(monkeypatch: pytest.MonkeyPat @pytest.mark.anyio -async def test_search_with_progress_auto_hc_skipped_no_token() -> None: +async def test_search_with_progress_auto_hc_skipped_no_token(monkeypatch: pytest.MonkeyPatch) -> None: + 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=[])) events = [] async for e in bi.search_with_progress("query", "title"): events.append(e) assert any(e.get("stage") == "hardcover" and e.get("status") == "skipped" for e in events) + # A skipped source must be reported exactly once: never also as a result count. + hardcover = [e for e in events if e.get("stage") == "hardcover"] + assert len(hardcover) == 1 @pytest.mark.anyio async def test_search_with_progress_auto_fallback_google(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) fake_gb = BookImportCandidate(title="GB Book", source="google_books") monkeypatch.setattr(bi, "_search_google_books", AsyncMock(return_value=[fake_gb])) @@ -117,6 +147,10 @@ async def test_search_with_progress_auto_fallback_google(monkeypatch: pytest.Mon async def test_search_with_progress_auto_fallback_google_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) monkeypatch.setattr( bi, "_search_google_books", AsyncMock(side_effect=bi.SourceBackendError("google_books", 500)), @@ -131,11 +165,15 @@ async def test_search_with_progress_auto_fallback_google_error(monkeypatch: pyte @pytest.mark.anyio async def test_search_with_progress_general_exception(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_open_library", AsyncMock(side_effect=RuntimeError("boom"))) + 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) - assert any(e.get("stage") == "error" for e in events) + assert any(e.get("stage") == "open_library" and e.get("status") == "error" for e in events) # ── search ───────────────────────────────────────────────────────────────────── @@ -147,6 +185,10 @@ async def test_search_ol_exception(monkeypatch: pytest.MonkeyPatch) -> None: AsyncMock(side_effect=bi.SourceBackendError("open_library", 503)), ) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) results = await bi.search("query", "title") assert results == [] @@ -159,6 +201,10 @@ async def test_search_ol_generic_exception(monkeypatch: pytest.MonkeyPatch) -> N AsyncMock(side_effect=RuntimeError("boom")), ) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) results = await bi.search("query", "title") assert results == [] @@ -173,6 +219,10 @@ async def test_search_ol_generic_exception_with_hc(monkeypatch: pytest.MonkeyPat ) fake_hc = BookImportCandidate(title="HC Book", source="hardcover") monkeypatch.setattr(bi, "_search_hardcover", AsyncMock(return_value=[fake_hc])) + 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 @@ -185,6 +235,10 @@ async def test_search_hc_exception(monkeypatch: pytest.MonkeyPatch) -> None: bi, "_search_hardcover", AsyncMock(side_effect=RuntimeError("boom")), ) + 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 == [] @@ -194,6 +248,10 @@ async def test_search_hc_exception(monkeypatch: pytest.MonkeyPatch) -> None: async def test_search_fallback_google_no_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) results = await bi.search("query", "title") assert results == [] @@ -203,6 +261,10 @@ async def test_search_fallback_google_no_key(monkeypatch: pytest.MonkeyPatch) -> async def test_search_fallback_google_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) fake = BookImportCandidate(title="Book", source="google_books") monkeypatch.setattr(bi, "_search_google_books", AsyncMock(return_value=[fake])) @@ -214,6 +276,10 @@ async def test_search_fallback_google_success(monkeypatch: pytest.MonkeyPatch) - async def test_search_fallback_google_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) monkeypatch.setattr( bi, "_search_google_books", AsyncMock(side_effect=bi.SourceBackendError("google_books", 500)), @@ -1074,6 +1140,10 @@ async def test_search_with_progress_ol_error_event(monkeypatch: pytest.MonkeyPat AsyncMock(side_effect=bi.SourceBackendError("open_library", 500)), ) monkeypatch.setattr(bi, "_search_hardcover", 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=[])) events = [] async for e in bi.search_with_progress("query", "title"): @@ -1214,6 +1284,10 @@ async def test_search_with_progress_hc_done_event(monkeypatch: pytest.MonkeyPatc fake_hc = BookImportCandidate(title="HC Book", source="hardcover") monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) monkeypatch.setattr(bi, "_search_hardcover", AsyncMock(return_value=[fake_hc])) + 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"): @@ -1230,8 +1304,1300 @@ async def test_search_with_progress_hc_error_event(monkeypatch: pytest.MonkeyPat bi, "_search_hardcover", AsyncMock(side_effect=RuntimeError("boom")), ) + 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"): events.append(e) assert any(e.get("stage") == "hardcover" and e.get("status") == "error" for e in events) + + +# ── BnF ────────────────────────────────────────────────────────────────────────── + +# Two records for one ISBN: a commercial barcode stub and the real bibliographic +# record, as catalogue.bnf.fr actually answers a bib.isbn query. +BNF_SAMPLE_XML = """ + + + + + + L'heure du Sud : ou l'invention d'un nouvel ordre mondial (Ed. 2024) + Badie, Bertrand + Vidal, Dominique + Editions les Liens qui liberent (Paris) + 2024 + 1 volume 295 p ; 22 cm + fre + http://catalogue.bnf.fr/ark:/12148/cb48773987b + ISBN 9791020923554 + Code à barres commercial : EAN 9791020923554 + + + + + + + L'heure du Sud : ou l'invention d'un nouvel ordre mondial / sous la direction de Bertrand Badie + Badie, Bertrand (1950-....).Editeur scientifique + les Liens qui liberent (Paris) + 2024 + 1 vol. (295 p.) : ill. ; 22 cm + fre + http://catalogue.bnf.fr/ark:/12148/cb47695466k + ISBN 9791020923554 + Relations internationales -- 2001-.... + + + + + +""" + + +def test_map_bnf_response_basic() -> None: + candidates = bi.map_bnf_response(BNF_SAMPLE_XML) + # Both records carry the same ISBN, so only the richer one survives. + assert len(candidates) == 1 + c = candidates[0] + assert c.title == "L'heure du Sud" + assert c.subtitle == "ou l'invention d'un nouvel ordre mondial" + assert c.authors == ["Badie, Bertrand"] + assert c.isbn == "9791020923554" + assert c.publisher == "les Liens qui liberent" + assert c.published_year == 2024 + assert c.page_count == 295 + assert c.language == "FR" + assert c.tags == "Relations internationales" + assert c.source == "bnf" + assert c.cover_url is None + + +def test_map_bnf_response_empty() -> None: + xml = """ + + +""" + assert bi.map_bnf_response(xml) == [] + + +def test_map_bnf_response_malformed_xml() -> None: + assert bi.map_bnf_response(" None: + xml = """ + +""" + assert bi.map_bnf_response(xml) == [] + + +# ── Inventaire ───────────────────────────────────────────────────────────────── + +INVENTAIRE_SAMPLE = { + # Shape verified against https://inventaire.io/api/entities/by-uris: + # claim values are plain strings, and P629/P50/P123 relatives come back as entities. + "entities": { + "inv:edition123": { + "type": "edition", + "originalLang": "fr", + "labels": {"fromclaims": "L'heure du Sud"}, + "claims": { + "wdt:P1476": ["L'heure du Sud"], + "wdt:P1680": ["ou l'invention d'un nouvel ordre mondial"], + "wdt:P212": ["979-10-209-2355-4"], + "wdt:P577": ["2024"], + "wdt:P1104": ["295"], + "wdt:P123": ["wd:Q3236382"], + "wdt:P629": ["inv:work123"], + "invp:P2": ["abcdef123456"], + }, + }, + "inv:work123": { + "type": "work", + "labels": {"fr": "L'heure du Sud"}, + "claims": {"wdt:P50": ["wd:Q7934"]}, + }, + "wd:Q7934": { + "type": "human", + "labels": {"en": "Bertrand Badie"}, + }, + "wd:Q3236382": { + "type": "publisher", + "labels": {"en": "Editions les Liens qui liberent"}, + }, + }, + "redirects": {"isbn:9791020923554": "inv:edition123"}, +} + + +def test_map_inventaire_response_basic() -> None: + candidates = bi.map_inventaire_response(INVENTAIRE_SAMPLE) + assert len(candidates) == 1 + c = candidates[0] + assert c.title == "L'heure du Sud" + assert c.subtitle == "ou l'invention d'un nouvel ordre mondial" + assert c.isbn == "9791020923554" + assert c.published_year == 2024 + assert c.page_count == 295 + assert c.language == "FR" + assert c.source == "inventaire" + assert c.cover_url == "https://inventaire.io/img/entities/abcdef123456" + assert c.authors == ["Bertrand Badie"] + assert c.publisher == "Editions les Liens qui liberent" + + +def test_map_inventaire_response_authors_fall_back_to_edition_claims() -> None: + data = { + "entities": { + "inv:edition123": { + "type": "edition", + "labels": {"fromclaims": "Plain Edition"}, + "claims": { + "wdt:P1476": ["Plain Edition"], + "wdt:P212": ["9782072716645"], + "wdt:P50": ["wd:Q43499"], + }, + }, + "inv:work123": {"type": "work", "labels": {}, "claims": {}}, + "wd:Q43499": {"type": "human", "labels": {"en": "Erasmus"}}, + }, + "redirects": {"isbn:9782072716645": "inv:edition123"}, + } + candidates = bi.map_inventaire_response(data) + assert candidates[0].authors == ["Erasmus"] + + +def test_map_inventaire_response_without_title_is_skipped() -> None: + data = { + "entities": {"inv:edition123": {"type": "edition", "labels": {}, "claims": {}}}, + "redirects": {"isbn:9791020923554": "inv:edition123"}, + } + assert bi.map_inventaire_response(data) == [] + + +def test_map_inventaire_response_empty() -> None: + assert bi.map_inventaire_response({"entities": {}, "redirects": {}}) == [] + + +def test_map_inventaire_response_no_entities() -> None: + assert bi.map_inventaire_response({}) == [] + + +# ── Library of Congress ─────────────────────────────────────────────────────── + +LOC_SAMPLE_XML = """ + + + + + + + Dune + Book One of the Dune Chronicles + + + Herbert, Frank. + + + Chilton Books + + + Chilton Books + 1965 + + + eng + + + 412 p. ; 22 cm. + + + Science fiction + + 9780441013593 + 65021248 + + + + + +""" + + +def test_map_loc_response_basic() -> None: + candidates = bi.map_loc_response(LOC_SAMPLE_XML) + assert len(candidates) == 1 + c = candidates[0] + assert c.title == "Dune" + assert c.subtitle == "Book One of the Dune Chronicles" + assert c.author == "Frank Herbert" + assert c.authors == ["Frank Herbert"] + assert c.publisher == "Chilton Books" + assert c.published_year == 1965 + assert c.page_count == 412 + assert c.language == "EN" + assert c.isbn == "9780441013593" + assert c.tags == "Science fiction" + assert c.source == "library_of_congress" + assert c.cover_url is None + + +def test_map_loc_response_reads_topics_nested_in_subject() -> None: + """MODS nests topics under , so only a descendant search finds them.""" + xml = """ + + + + Nested Topics + + Outer + Inner + + + +""" + candidates = bi.map_loc_response(xml) + assert candidates[0].tags == "Outer, Inner" + + +def test_map_loc_response_accepts_spaced_isbn() -> None: + xml = """ + + + + Spaced + 0-441-01359-8 + + +""" + candidates = bi.map_loc_response(xml) + assert candidates[0].isbn == "9780441013593" + + +def test_map_loc_response_empty() -> None: + xml = """ + + +""" + assert bi.map_loc_response(xml) == [] + + +def test_map_loc_response_malformed_xml() -> None: + assert bi.map_loc_response(" None: + """Each stage reports exactly one terminal event; no stage ends up twice.""" + monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) + 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 = [] + async for e in bi.search_with_progress("query", "isbn"): + events.append(e) + + terminal = {"done", "skipped", "error"} + by_stage: dict[str, list[str]] = {} + for e in events: + if e.get("stage") in ("complete", "error"): + continue + by_stage.setdefault(e["stage"], []).append(e.get("status")) + + for stage, statuses in by_stage.items(): + assert statuses.count("searching") <= 1, (stage, statuses) + assert len([s for s in statuses if s in terminal]) == 1, (stage, statuses) + + +@pytest.mark.anyio +async def test_search_with_progress_skips_inventaire_for_title_search( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Inventaire has no title search, so it must be skipped rather than report 0.""" + inventaire = AsyncMock(return_value=[]) + monkeypatch.setattr(bi, "_search_open_library", AsyncMock(return_value=[])) + 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"): + events.append(e) + + inventaire_events = [e for e in events if e.get("stage") == "inventaire"] + assert len(inventaire_events) == 1 + assert inventaire_events[0]["status"] == "skipped" + assert inventaire_events[0]["reason"] == "title_search_unsupported" + inventaire.assert_not_awaited() + + +@pytest.mark.anyio +async def test_search_with_progress_google_only_mode_respects_the_flag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(bi.settings, "google_books_import_search_enabled", False) + google = AsyncMock(return_value=[]) + + events = [] + async for e in bi.search_with_progress("query", "title", api_key="key", mode="google_only"): + events.append(e) + + assert any( + e.get("stage") == "google_books" and e.get("reason") == "disabled" for e in events + ) + google.assert_not_awaited() + + +# ── Parser edge cases found against live payloads ────────────────────────────── + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("Herbert, Frank.", "Frank Herbert"), + ("Herbert, Frank,", "Frank Herbert"), + ("Horydczak, Theodor,", "Theodor Horydczak"), + ("Mary Shelley", "Mary Shelley"), + ], +) +def test_reverse_loc_author_handles_trailing_separators(raw: str, expected: str) -> None: + assert bi._reverse_loc_author(raw) == expected + + +def test_split_bnf_title_strips_accented_edition_statements() -> None: + assert bi._split_bnf_title("L'heure du Sud : ou l'invention (Éd. 2024)") == ( + "L'heure du Sud", + "ou l'invention", + ) + assert bi._split_bnf_title("Série noire : intégrale (Édition 2020)") == ( + "Série noire", + "intégrale", + ) + assert bi._split_bnf_title("Dune / Frank Herbert") == ("Dune", None) + + +def test_clean_bnf_tag_drops_subdivisions() -> None: + assert bi._clean_bnf_tag("Relations internationales -- 2001-....") == "Relations internationales" + assert bi._clean_bnf_tag("Pays en développement") == "Pays en développement" + + +@pytest.mark.parametrize( + "raw", + ['Le Seigneur des "Anneaux"', 'He said "hello"', 'back\\slash', '"', " "], +) +def test_sanitize_cql_term_removes_quotes_and_backslashes(raw: str) -> None: + cleaned = bi._sanitize_cql_term(raw) + assert '"' not in cleaned + assert "\\" not in cleaned + + +@pytest.mark.anyio +async def test_search_bnf_title_with_quotes_still_queries(monkeypatch: pytest.MonkeyPatch) -> None: + """A quote in a title must not break the CQL query into a silent empty result.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["query"] = request.url.params.get("query", "") + return httpx.Response(200, text="") + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + results = await bi._search_bnf('Le Seigneur des "Anneaux"', "title", client) + + assert results == [] + assert '""' not in seen["query"] + assert seen["query"].count('"') % 2 == 0 + + +# ── Hardened XML parsing ─────────────────────────────────────────────────────── + +_BILLION_LAUGHS = """ + + + +]> + + + &lol3; + +""" + +_XXE_FILE_READ = """ +]> + + + &xxe; + +""" + + +def test_map_loc_response_rejects_entity_expansion() -> None: + """A hostile SRU payload must not be expanded, and must yield no candidates.""" + assert bi.map_loc_response(_BILLION_LAUGHS) == [] + + +def test_map_loc_response_rejects_external_entity() -> None: + """External entity references must not be resolved to local files.""" + assert bi.map_loc_response(_XXE_FILE_READ) == [] + + +def test_map_bnf_response_rejects_entity_expansion() -> None: + payload = _BILLION_LAUGHS.replace("mods:mods", "oai_dc:dc").replace( + 'xmlns:mods="http://www.loc.gov/mods/v3"', + 'xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/"', + ).replace("mods:titleInfo", "").replace("mods:title", "dc:title").replace( + "", "" + ).replace("", "") + assert bi.map_bnf_response(payload) == [] + + +# ── Google fallback gating ───────────────────────────────────────────────────── + +@pytest.mark.anyio +async def test_search_uses_google_when_all_parallel_sources_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """With every parallel source off, Google Books must still answer as the fallback.""" + for flag, value in ( + ("open_library_import_search_enabled", False), + ("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), + ): + monkeypatch.setattr(bi.settings, flag, value) + + fake_gb = BookImportCandidate(title="GB Book", source="google_books") + monkeypatch.setattr(bi, "_search_google_books", AsyncMock(return_value=[fake_gb])) + + results = await bi.search("query", "title", api_key="key") + assert [r.source for r in results] == ["google_books"] + + +@pytest.mark.anyio +async def test_search_skips_google_when_fallback_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Disabling the fallback must stop the Google request entirely.""" + for flag, value in ( + ("open_library_import_search_enabled", False), + ("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), + ): + monkeypatch.setattr(bi.settings, flag, value) + + google = AsyncMock(return_value=[BookImportCandidate(title="GB", source="google_books")]) + monkeypatch.setattr(bi, "_search_google_books", google) + + results = await bi.search("query", "title", api_key="key") + assert results == [] + google.assert_not_awaited() + + +@pytest.mark.anyio +async def test_search_with_progress_reports_disabled_google_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + for flag, value in ( + ("open_library_import_search_enabled", False), + ("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), + ): + monkeypatch.setattr(bi.settings, flag, value) + + google = AsyncMock(return_value=[BookImportCandidate(title="GB", source="google_books")]) + monkeypatch.setattr(bi, "_search_google_books", google) + + events = [] + async for e in bi.search_with_progress("query", "title", api_key="key"): + events.append(e) + + assert any( + e.get("stage") == "google_books" and e.get("reason") == "disabled" for e in events + ) + google.assert_not_awaited() + + +@pytest.mark.anyio +async def test_search_with_progress_still_completes_without_parallel_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A Google-only deployment is valid and must still emit a complete event.""" + for flag, value in ( + ("open_library_import_search_enabled", False), + ("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), + ): + monkeypatch.setattr(bi.settings, flag, value) + + fake_gb = BookImportCandidate(title="GB Book", source="google_books") + monkeypatch.setattr(bi, "_search_google_books", AsyncMock(return_value=[fake_gb])) + + events = [] + async for e in bi.search_with_progress("query", "title", api_key="key"): + events.append(e) + + complete = [e for e in events if e.get("stage") == "complete"] + assert len(complete) == 1 + assert len(complete[0]["results"]) == 1 + + +# ── _parse_page_count ────────────────────────────────────────────────────────── + +def test_parse_page_count_valid() -> None: + assert bi._parse_page_count("1 vol. (265 p.) ; 20 cm") == 265 + assert bi._parse_page_count("412 p. ; 22 cm.") == 412 + assert bi._parse_page_count("1,234 p.") == 1234 + assert bi._parse_page_count("528 p. : 23 cm.") == 528 + + +def test_parse_page_count_invalid() -> None: + assert bi._parse_page_count("no pages") is None + assert bi._parse_page_count("") is None + + +# ── Source registry ───────────────────────────────────────────────────────────── + +def test_book_sources_registry() -> None: + stages = [s.stage for s in bi._build_sources("")] + assert "open_library" in stages + 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 60646a33..47d60eec 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -23,6 +23,24 @@ from app.services import book_import +@pytest.fixture(autouse=True) +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 / 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. + """ + 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) + + # ── Fake response data ───────────────────────────────────────────────────────── OPEN_LIBRARY_DUNE_DOC = { @@ -1006,6 +1024,32 @@ async def fake_download(url: str, covers_dir: str, http_client: httpx.AsyncClien assert resp.json()["cover_url"] is None +def test_import_book_rejects_private_cover_url(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + """A candidate cover URL pointing at a private address must never be fetched (SSRF).""" + monkeypatch.setattr(settings, "covers_dir", str(tmp_path)) + fetched: list[str] = [] + + async def fake_download(url: str, covers_dir: str, http_client: httpx.AsyncClient, user_id: int | None) -> str | None: + fetched.append(url) # pragma: no cover - must never run + return "cover.jpg" + + monkeypatch.setattr(import_router, "download_cover", fake_download) + + payload = { + "candidate": { + "title": "Internal Service", + "isbn": "9780000000001", + "cover_url": "http://169.254.169.254/latest/meta-data/", + "source": "bnf", + }, + "reading_status": "want_to_read", + } + resp = client.post("/api/import", json=payload) + assert resp.status_code == 201 + assert resp.json()["cover_url"] is None + assert fetched == [] + + def test_import_book_no_cover_url_skips_download(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: """When the candidate has no cover_url, download_cover is never called.""" monkeypatch.setattr(settings, "covers_dir", str(tmp_path)) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index d96e9ff9..8225eda1 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -193,6 +193,121 @@ async def call_next(request: Request) -> Response: assert received["scheme"] == "http" +# ── Import source startup validation ─────────────────────────────────────────── + +def _import_source_flags() -> list[str]: + """Every per-source enable flag, derived from the registry itself.""" + from app.services import book_import + + return [f"{spec.stage}_import_search_enabled" for spec in book_import._build_sources("")] + + +@pytest.mark.anyio +async def test_lifespan_refuses_to_start_without_any_import_source(monkeypatch) -> None: + """Disabling every book import source must stop startup with an actionable error.""" + from app.config import settings + from app.services import book_import + import app.main as main_module + + for flag in _import_source_flags(): + monkeypatch.setattr(settings, flag, False) + monkeypatch.setattr(settings, "google_books_import_search_enabled", False) + + with patch("app.main._periodic_maintenance", new=AsyncMock()): + with pytest.raises(RuntimeError) as exc_info: + async with main_module.lifespan(main_module.app): + pass + + message = str(exc_info.value) + assert "No usable book import source" in message + # The error must name the variables the operator can set. + for stage in (s.stage for s in book_import._build_sources("")): + assert f"{stage.upper()}_IMPORT_SEARCH_ENABLED" in message + + +def test_credentialed_sources_keep_true_default_for_backward_compatibility() -> None: + """Every source keeps a built-in default of true. + + Upgrading must never silently drop a source that worked before these switches + existed. Hardcover and Google Books are skipped anyway when their key or token is + empty, so the true default costs nothing; the example config recommends false. + """ + from app.config import Settings + + defaults = Settings( + api_key_encryption_key="x" * 32, + _env_file=None, + ) + assert defaults.open_library_import_search_enabled is True + assert defaults.hardcover_import_search_enabled is True + assert defaults.google_books_import_search_enabled is True + 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 +async def test_lifespan_refuses_credentialed_sources_without_credentials(monkeypatch) -> None: + """Enabled Hardcover/Google Books without a token/key can answer nothing. + + This configuration must be rejected, otherwise the app starts and silently + returns no search results forever. + """ + from app.config import settings + import app.main as main_module + + for flag in _import_source_flags(): + monkeypatch.setattr(settings, flag, False) + # Only the two credentialed sources are on, and neither has credentials. + monkeypatch.setattr(settings, "hardcover_import_search_enabled", True) + monkeypatch.setattr(settings, "google_books_import_search_enabled", True) + monkeypatch.setattr(settings, "hardcover_app_api_token", "") + monkeypatch.setattr(settings, "google_books_api_key", "") + + with patch("app.main._periodic_maintenance", new=AsyncMock()): + with pytest.raises(RuntimeError) as exc_info: + async with main_module.lifespan(main_module.app): + pass + + assert "HARDCOVER_APP_API_TOKEN" in str(exc_info.value) + assert "GOOGLE_BOOKS_API_KEY" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_lifespan_accepts_enabled_hardcover_with_token(monkeypatch) -> None: + """An enabled source counts once it actually has its credential.""" + from app.config import settings + import app.main as main_module + + for flag in _import_source_flags(): + monkeypatch.setattr(settings, flag, False) + monkeypatch.setattr(settings, "hardcover_import_search_enabled", True) + monkeypatch.setattr(settings, "hardcover_app_api_token", "token") + + with patch("app.main._periodic_maintenance", new=AsyncMock()): + with patch("app.main._telemetry_heartbeat", new=AsyncMock()): + async with main_module.lifespan(main_module.app): + pass + + +@pytest.mark.anyio +async def test_lifespan_starts_with_a_single_import_source(monkeypatch) -> None: + """One enabled source is enough to start.""" + from app.config import settings + import app.main as main_module + + for flag in _import_source_flags(): + monkeypatch.setattr(settings, flag, False) + monkeypatch.setattr(settings, "google_books_import_search_enabled", False) + monkeypatch.setattr(settings, "bnf_import_search_enabled", True) + + with patch("app.main._periodic_maintenance", new=AsyncMock()): + with patch("app.main._telemetry_heartbeat", new=AsyncMock()): + async with main_module.lifespan(main_module.app): + pass + + @pytest.mark.anyio async def test_lifespan_logs_warning_when_mail_not_configured(monkeypatch) -> None: """Lifespan should warn when MAIL_SERVER or MAIL_FROM are not configured.""" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2b09470f..5c810ce9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -31,6 +31,8 @@ services: ports: - "8001:80" restart: unless-stopped + environment: + BACKEND_ADDR: ${BACKEND_ADDR:-http://backend:8000} mailpit: image: axllent/mailpit:latest diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 76609f38..ff887ee6 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -35,6 +35,8 @@ services: depends_on: backend: condition: service_healthy + environment: + BACKEND_ADDR: http://backend:8000 test-runner: build: diff --git a/docker-compose.yml b/docker-compose.yml index 6e1bb0d0..01df94fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,3 +17,5 @@ services: ports: - "8001:80" restart: unless-stopped + environment: + BACKEND_ADDR: ${BACKEND_ADDR:-http://backend:8000} diff --git a/docs/about.md b/docs/about.md index ada16ccf..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, Google Books, and Hardcover.app. 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, 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 28e7074d..6476fbcb 100644 --- a/docs/guide/api-keys.md +++ b/docs/guide/api-keys.md @@ -1,10 +1,33 @@ # API Keys -Some external sources require API keys. All are optional — LibrisLog works out of the box with Open Library. +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). + +::: warning Both keyed sources are off in `.env.example` +A key alone is not enough to make a source answer, and leaving a source switched on +without its key is just noise. `.env.example` therefore ships +`HARDCOVER_IMPORT_SEARCH_ENABLED=false` and `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false`. +Set the credential and flip the matching switch to `true`: + +```bash +GOOGLE_BOOKS_API_KEY=your-key +GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=true +``` + +```bash +HARDCOVER_APP_API_TOKEN=your-token +HARDCOVER_IMPORT_SEARCH_ENABLED=true +``` + +The built-in default for both switches is `true`, so an existing `.env` that does not +mention them keeps working after an upgrade. If you leave a source on without its +credential, it is listed in the search progress as skipped and no request is sent. +::: ## Google Books API -Enables Google Books as a fallback search source and improves cover resolution. +Enables Google Books as a fallback search source and improves cover resolution. Remember to also set `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=true`. 1. Go to the [Google Cloud Console](https://console.cloud.google.com) 2. Create a new project or select an existing one @@ -18,7 +41,7 @@ Enables Google Books as a fallback search source and improves cover resolution. ## Hardcover API -Enables Hardcover.app as an additional search source. +Enables Hardcover.app as an additional search source. Remember to also set `HARDCOVER_IMPORT_SEARCH_ENABLED=true`. 1. Sign in to [hardcover.app](https://hardcover.app) 2. Go to your profile → **Settings** → **API** diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ea39a248..6715da14 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -65,10 +65,51 @@ The Mailpit web UI is available at http://localhost:8025 to inspect sent emails. ## Book Import Sources -| Variable | Description | -|----------|-------------| -| `GOOGLE_BOOKS_API_KEY` | Google Books API key (see [API Keys](/guide/api-keys)) | -| `HARDCOVER_APP_API_TOKEN` | Hardcover.app API token (see [API Keys](/guide/api-keys)) | +Every search source can be switched on or off individually. At least one source must be +able to answer: if every source is disabled, or the only enabled ones are Hardcover and +Google Books without their credentials, the backend refuses to start and logs an error +naming the variables to set. + +| Variable | Description | Built-in default | Recommended | +|----------|-------------|------------------|-------------| +| `OPEN_LIBRARY_IMPORT_SEARCH_ENABLED` | Query Open Library | `true` | `true` | +| `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 | — | +| `HARDCOVER_APP_API_TOKEN` | Hardcover.app API token (see [API Keys](/guide/api-keys)) | empty | — | + +The built-in default is `true` for every source, so upgrading never silently drops a +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, 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 +reported as skipped in the search progress list and no request is sent, so it never +slows a search down. To use either, set its credential and keep the switch on: + +```bash +GOOGLE_BOOKS_API_KEY=your-key +GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=true +``` + +Inventaire answers ISBN lookups only; it has no title search, so on a title search it is +reported as skipped. Google Books runs last, only when every other enabled source +returned nothing. Setting `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false` also turns off the +on-demand **Search Google Books too** button. + +> **Warning:** The Library of Congress SRU endpoint (`http://lx2.loc.gov:210/LCDB`) is +> only reachable over **plain HTTP**. Queries are therefore unencrypted and can be +> observed or altered on the network path. It is the official, documented LC SRU server +> and returns public bibliographic data only, but set +> `LIBRARY_OF_CONGRESS_IMPORT_SEARCH_ENABLED=false` if unencrypted egress to port 210 +> is not acceptable in your environment. The other sources are all HTTPS. ## Cover Scraping @@ -105,11 +146,19 @@ To opt out entirely: TELEMETRY_DISABLED=true ``` -## Frontend Build +## Frontend -| Variable | Description | Default | -|----------|-------------|---------| -| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en`, `de`, `zh`, `es`, or `fr`) | `en` | +### Run Time + +| Variable | Description | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `BACKEND_ADDR` | Configures the upstream address for the Nginx webserver. For the default `docker-compose.yml` this is set to `http://backend:8000`. **NOTE**: Remove trailing `/` as they might interfere with NGINXs routing behavior. | + +### Build Time + +| Variable | Description | Default | +| ----------------------- | ----------------------------------------------------- | ------- | +| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en`, `de`, `zh`, `es`, or `fr`) | `en` | ## Import Limits @@ -122,7 +171,8 @@ 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. +- `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/developer-setup.md b/docs/guide/developer-setup.md index 5367b826..1a896de6 100644 --- a/docs/guide/developer-setup.md +++ b/docs/guide/developer-setup.md @@ -72,7 +72,7 @@ The backend runs on http://localhost:8000 with auto-reload on code changes. ## Local Development — Frontend Requirements: -- Node.js 20+ (see `frontend/.nvmrc`) +- Node.js 22.17+ (see `frontend/.nvmrc`) — SvelteKit 3 requires it when building the frontend Steps: diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index ecc02d04..8b2c22e4 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -11,9 +11,17 @@ The most common way to add books is by searching external sources: 1. Click "Add Book" in the library 2. Enter a title, author, or ISBN in the search box 3. The app queries: - - **Open Library** (always, no key required) - - **Google Books** (if `GOOGLE_BOOKS_API_KEY` is set — see [API Keys](/guide/api-keys)) - - **Hardcover.app** (if `HARDCOVER_APP_API_TOKEN` is set — see [API Keys](/guide/api-keys)) + - **Open Library** (no key required) + - **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)) + + Every source can be disabled individually. Hardcover and Google Books are set to + `false` in `.env.example` because they need credentials; see + [Configuration](/guide/configuration). 4. Select a result to import with full metadata and cover 5. Choose an availability value and, optionally, a medium (Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper) before saving diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index c36f5969..2217f2b8 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -62,10 +62,16 @@ A book can have **multiple authors**: type a name and press **Enter** to add it Search external sources for book metadata: - **Open Library** — Free, no API key required -- **Google Books** — Requires API key (set in `.env`) -- **Hardcover.app** — Requires API token (set in `.env`) +- **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 -Open Library and Hardcover (if an API token is configured) are queried **in parallel** for both title and ISBN searches. Google Books is only used as a **fallback** when the other sources return no results, or on demand via the **Search Google Books too** button, which adds Google Books results to the current results. +All enabled sources are queried **in parallel** for both title and ISBN searches. Inventaire is the exception: it has no title-search route, so on a title search it is reported as skipped. Google Books is only used as a **fallback** when the other sources return no results, or on demand via the **Search Google Books too** button, which adds Google Books results to the current results. + +Each source can be switched off individually. Hardcover and Google Books are set to `false` in `.env.example`, because neither can answer without credentials: add the key or token and set the matching `*_IMPORT_SEARCH_ENABLED` to `true`. A source that is on but has no credential is listed as skipped and is never contacted. See [Configuration](/guide/configuration) for the settings and for the rule that at least one source must stay usable. While a search is running, the **Search** button changes to **Cancel**, so you can stop the request at any time and refine your query. diff --git a/docs/index.md b/docs/index.md index b2656a85..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, 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 275785df..31b806ac 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,14 +8,15 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/ ## Latest Release -::: tip ⭐ v1.9.0 — Smarter Import Search & Input UX -LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an import basket for search results, parallel and cancelable searches, Excel (XLSX) import with a new Bookstats preset, configurable reading-date automation, media/medium statistics, and timezone-correct daily page statistics. +::: tip ⭐ v1.10.0 — More Book Sources & Upgrades +LibrisLog v1.10.0 adds four more metadata sources to the import search (BnF, Inventaire.io, the Library of Congress, and the Deutsche Nationalbibliothek) with a per-source on/off switch and a startup guard, lets you point the frontend at a custom backend address, keeps the Add Book dialog header reachable while scrolling, and updates the frontend framework and dependency set. ::: ### All releases | Version | Date | Type | |---|---|---| +| [v1.10.0](#v1-10-0-more-book-sources-upgrades) | 2026-10-18 | Feature release | | [v1.9.0](#v1-9-0-smarter-import-search-input-ux) | 2026-09-15 | Feature release | | [v1.8.0](#v1-8-0-camera-zoom-control-optional-telemetry) | 2026-09-02 | Feature release | | [v1.7.0](#v1-7-0-reading-streaks-goals) | 2026-08-26 | Feature release | @@ -35,6 +36,42 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an --- +## v1.10.0: More Book Sources & Upgrades + + + +**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) +- 📕 **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 + +**Improvements** +- 🌐 **Configurable backend address.** The frontend webserver proxies `/api` to `BACKEND_ADDR` (default `http://backend:8000`) instead of a hardcoded backend host, so the web container can reach a backend outside the bundled `docker-compose.yml`. A value that would break API routing, such as one with a trailing slash, is reported when the container starts. See the [Configuration guide](/guide/configuration) +- 📌 **Sticky Add Book dialog header.** The dialog title and the close button stay pinned to the top of the window while the content scrolls, so closing the dialog no longer requires scrolling back up after a long list of results +- 🔍 **Search results report each source.** The progress list names every source that was queried, including those that were skipped and why + +**Dependencies** +- ⬆️ 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, 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 + +**Contributors:** Thank you to **[@mj0nez](http://github.com/mj0nez)** for making the backend address of the webserver configurable. + +**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`. + +[Compare with v1.9.0](https://github.com/codebude/librislog/compare/v1.9.0...v1.10.0) + +--- + ## v1.9.0: Smarter Import Search & Input UX @@ -67,7 +104,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Compare with v1.8.0](https://github.com/codebude/librislog/compare/v1.8.0...main) +[Compare with v1.8.0](https://github.com/codebude/librislog/compare/v1.8.0...v1.9.0) --- @@ -90,7 +127,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Compare with v1.7.0](https://github.com/codebude/librislog/compare/v1.7.0...main) +[Compare with v1.7.0](https://github.com/codebude/librislog/compare/v1.7.0...v1.8.0) --- @@ -123,7 +160,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an - ⚠️ Creating a book now requires **at least one author** (via `authors` or the legacy `author` field) — API requests without any author are rejected - ⚠️ The `availability:` search prefix is renamed to `possession:` -[Compare with v1.6.0](https://github.com/codebude/librislog/compare/v1.6.0...main) +[Compare with v1.6.0](https://github.com/codebude/librislog/compare/v1.6.0...v1.7.0) --- @@ -145,7 +182,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.5.2...v1.6.0) +[Compare with v1.5.2](https://github.com/codebude/librislog/compare/v1.5.2...v1.6.0) --- @@ -161,7 +198,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.5.1...v1.5.2) +[Compare with v1.5.1](https://github.com/codebude/librislog/compare/v1.5.1...v1.5.2) --- @@ -176,7 +213,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.5.0...v1.5.1) +[Compare with v1.5.0](https://github.com/codebude/librislog/compare/v1.5.0...v1.5.1) --- @@ -198,7 +235,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.4.0...v1.5.0) +[Compare with v1.4.0](https://github.com/codebude/librislog/compare/v1.4.0...v1.5.0) --- @@ -220,7 +257,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.3.1...v1.4.0) +[Compare with v1.3.1](https://github.com/codebude/librislog/compare/v1.3.1...v1.4.0) --- @@ -235,7 +272,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.3.0...v1.3.1) +[Compare with v1.3.0](https://github.com/codebude/librislog/compare/v1.3.0...v1.3.1) --- @@ -252,7 +289,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.2.2...v1.3.0) +[Compare with v1.2.2](https://github.com/codebude/librislog/compare/v1.2.2...v1.3.0) --- @@ -267,7 +304,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.2.1...v1.2.2) +[Compare with v1.2.1](https://github.com/codebude/librislog/compare/v1.2.1...v1.2.2) --- @@ -289,7 +326,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. (The ISBN uniqueness change is a database migration and runs automatically on upgrade.) -[Full changelog](https://github.com/codebude/librislog/compare/v1.2.0...v1.2.1) +[Compare with v1.2.0](https://github.com/codebude/librislog/compare/v1.2.0...v1.2.1) --- @@ -305,7 +342,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.1.1...v1.2.0) +[Compare with v1.1.1](https://github.com/codebude/librislog/compare/v1.1.1...v1.2.0) --- @@ -322,7 +359,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.1.0...v1.1.1) +[Compare with v1.1.0](https://github.com/codebude/librislog/compare/v1.1.0...v1.1.1) --- @@ -340,7 +377,7 @@ LibrisLog v1.9.0 brings shareable public profile pages, edition grouping and an **Breaking changes:** None. -[Full changelog](https://github.com/codebude/librislog/compare/v1.0.0...v1.1.0) +[Compare with v1.0.0](https://github.com/codebude/librislog/compare/v1.0.0...v1.1.0) --- diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 03c231d1..c5f2e5d3 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -17,5 +17,7 @@ RUN npm run build # Stage 2: serve FROM nginx:alpine COPY --from=builder /app/build /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY docker-entrypoint.d/ /docker-entrypoint.d/ +ENV BACKEND_ADDR=http://backend:8000 EXPOSE 80 diff --git a/frontend/docker-entrypoint.d/10-warn-backend-addr.sh b/frontend/docker-entrypoint.d/10-warn-backend-addr.sh new file mode 100755 index 00000000..1327ca73 --- /dev/null +++ b/frontend/docker-entrypoint.d/10-warn-backend-addr.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Warns about BACKEND_ADDR values that would break API routing. +# +# nginx treats a URI part in proxy_pass (a trailing slash) as a prefix to replace, +# so "http://backend:8000/" forwards a request for /api/books as /books and every +# API call returns 404 while nginx itself starts cleanly. +# +# The value cannot be corrected here. envsubst only substitutes variables verbatim, +# and the image runs each script in /docker-entrypoint.d as a separate process, so an +# export from this script never reaches the later envsubst call. Printing is the only +# thing that works without replacing the image entrypoint or giving up nginx's +# startup-time upstream validation. +# +# The numbered filename makes this run before 20-envsubst-on-templates.sh. + +BACKEND_ADDR="${BACKEND_ADDR-}" + +case "$BACKEND_ADDR" in + "") + echo "librislog: BACKEND_ADDR is empty, so nginx renders an invalid proxy_pass and will not start." >&2 + echo "librislog: set it to an address such as http://backend:8000." >&2 + ;; + */) + echo "librislog: BACKEND_ADDR ends with a slash ('$BACKEND_ADDR')." >&2 + echo "librislog: nginx replaces the matched location prefix, so /api/books is forwarded as /books and every API call returns 404." >&2 + echo "librislog: drop the trailing slash." >&2 + ;; +esac + +exit 0 diff --git a/frontend/nginx.conf b/frontend/nginx.conf.template similarity index 95% rename from frontend/nginx.conf rename to frontend/nginx.conf.template index fa87c980..84b0f576 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf.template @@ -7,7 +7,7 @@ server { # API proxy to backend service inside docker network. location /api/ { - proxy_pass http://backend:8000; + proxy_pass ${BACKEND_ADDR}; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a7e478b..191286a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,22 +25,22 @@ }, "devDependencies": { "@playwright/test": "^1.60.0", - "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.57.0", + "@sveltejs/adapter-auto": "^8.0.0", + "@sveltejs/adapter-static": "^4.0.0", + "@sveltejs/kit": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^7.0.1", "@testing-library/svelte": "^5.3.1", "@testing-library/user-event": "^14.6.7", "@types/hammerjs": "^2.0.46", "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.7", - "happy-dom": "^20.9.0", - "svelte": "^5.55.2", - "svelte-check": "^4.4.6", + "@vitest/coverage-v8": "^5.0.4", + "happy-dom": "^21.0.1", + "svelte": "^5.57.1", + "svelte-check": "^4.7.5", "typescript": "^6.0.2", - "vite": "^8.0.7", - "vitest": "^4.1.6" + "vite": "^8.0.12", + "vitest": "^5.0.4" } }, "node_modules/@adobe/css-tools": { @@ -86,9 +86,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { @@ -102,9 +102,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "dev": true, "license": "MIT", "engines": { @@ -247,31 +247,31 @@ "license": "MIT" }, "node_modules/@lucide/svelte": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.39.0.tgz", - "integrity": "sha512-jCgA/RqDv9uWHs4DiIUef4kpf5TfLl9y8OMGrh+W4aQTCOscT4X2BX14ta65J+5ooslrN1bXXiO9AaeAOHAcPA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.57.0.tgz", + "integrity": "sha512-KWO10Jo80+Rd/JGBQkVcwkF1czJH+CwjHtfjZzLfRsmcI42YuMai3SCiRtdo5+3yo7fhT3oHKHaSBvN8GHEVpQ==", "license": "ISC", "peerDependencies": { "svelte": "^5" } }, "node_modules/@oxc-project/types": { - "version": "0.147.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", - "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -288,9 +288,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", - "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", "cpu": [ "arm" ], @@ -304,9 +304,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", - "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", "cpu": [ "arm64" ], @@ -320,9 +320,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", - "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", "cpu": [ "arm64" ], @@ -336,9 +336,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", - "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", "cpu": [ "x64" ], @@ -352,9 +352,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", - "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", "cpu": [ "x64" ], @@ -368,9 +368,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", - "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", "cpu": [ "arm" ], @@ -384,9 +384,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", - "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", "cpu": [ "arm64" ], @@ -403,9 +403,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", - "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", "cpu": [ "arm64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", - "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", "cpu": [ "ppc64" ], @@ -441,9 +441,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", - "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", "cpu": [ "s390x" ], @@ -460,9 +460,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", - "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", "cpu": [ "x64" ], @@ -479,9 +479,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", - "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", "cpu": [ "x64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", - "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", "cpu": [ "arm64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", - "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", - "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", "cpu": [ "x64" ], @@ -568,57 +568,53 @@ } }, "node_modules/@sveltejs/adapter-auto": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", - "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-8.0.0.tgz", + "integrity": "sha512-CKOwUS865xfoZn3CpM868slPfitsQ4vmxAR/yOfTIguAwStgkhjhzV0zEhmkzPalH338LJ9XnQX9vfQeY7220A==", "dev": true, "license": "MIT", "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^3.0.0-next.0" } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", - "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-4.0.0.tgz", + "integrity": "sha512-aF1J23R1ebKqUxQ4/qLr/Lxu63NFmLAtg8UZl3HFLxaoA1D/nhTVGcD9lTqx65iUbjNR2iOL0TgOxSX82x6Paw==", "dev": true, "license": "MIT", "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^3.0.0-next.0" } }, "node_modules/@sveltejs/kit": { - "version": "2.70.3", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", - "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-3.0.1.tgz", + "integrity": "sha512-8JMCCq7ZUJELi3hbhb0+c9Rs8qnbHW90vhOfM+t1FSF4fhIpG9/FOsH4IYgcITMhbdkSrrn+Kvd9G+q8A+UO6Q==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.9", - "@types/cookie": "^0.6.0", - "acorn": "^8.16.0", - "cookie": "^0.6.0", - "devalue": "^5.8.1", + "@rolldown/pluginutils": "^1.0.1", + "@standard-schema/spec": "^1.1.0", + "cookie": "^2.0.1", + "devalue": "^5.9.4", "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" + "magic-string": "^1.2.3", + "mrmime": "^2.0.1", + "sirv": "^3.0.2" }, "bin": { "svelte-kit": "svelte-kit.js" }, "engines": { - "node": ">=18.13" + "node": ">=22.17" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "svelte": "^5.57.1", + "typescript": "^6.0.0", + "vite": "^8.0.12" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -629,6 +625,16 @@ } } }, + "node_modules/@sveltejs/kit/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/@sveltejs/load-config": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", @@ -640,9 +646,9 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", - "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", "dev": true, "license": "MIT", "dependencies": { @@ -660,13 +666,13 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", - "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@tailwindcss/node": { @@ -939,9 +945,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1035,9 +1041,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.7", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", - "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", "dev": true, "license": "MIT", "engines": { @@ -1066,13 +1072,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1093,13 +1092,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.4.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", - "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/whatwg-mimetype": { @@ -1110,9 +1109,9 @@ "license": "MIT" }, "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1120,29 +1119,27 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.4.tgz", + "integrity": "sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "5.0.4", + "vitest": "5.0.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1150,40 +1147,60 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "node_modules/@vitest/coverage-v8/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "1.0.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -1195,78 +1212,30 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1325,9 +1294,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", - "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -1366,9 +1335,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -1388,9 +1357,9 @@ } }, "node_modules/chartjs-chart-matrix": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.5.tgz", - "integrity": "sha512-hVqXBEtLoYJk+iA9NajA/ArG7HlPZl3XXNsRDY3c5sCuqSrM6uTythSJh1jVR7UNVApdY9PStb84xSEjEceKaw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.1.0.tgz", + "integrity": "sha512-pdo8DnhFXOisjbLDgfkzZH1w0xmjGqiaAMgkQSHCEMzxQENsXTgkXImIyweDiO7SqlE5PuzjhklyBCiorQhRmw==", "license": "MIT", "peerDependencies": { "chart.js": ">=3.0.0" @@ -1450,21 +1419,18 @@ "node": ">=6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/css.escape": { @@ -1488,9 +1454,9 @@ } }, "node_modules/daisyui": { - "version": "5.7.27", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.27.tgz", - "integrity": "sha512-Je10yYK7UFJYyLSQrIxhFmpwX+86gXdNTnfNkA1jlev7j9oz3QrqioEQ6/0JYOvYPs5NYH4aaJ+X35/N80G/xg==", + "version": "5.7.50", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.50.tgz", + "integrity": "sha512-+/LRqnTCoc7s/bJEKKwtJSwzeXCVdAmRdNmZeZzgHCaQ9PJyl7j+2V2U32GQDMyD+VDkP9e/+JGoC72Y1hLk7w==", "license": "MIT", "funding": { "url": "https://github.com/saadeghi/daisyui?sponsor=1" @@ -1537,9 +1503,9 @@ } }, "node_modules/devalue": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", - "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -1550,9 +1516,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1656,9 +1622,9 @@ } }, "node_modules/esrap": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz", - "integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1729,10 +1695,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1771,9 +1736,9 @@ } }, "node_modules/happy-dom": { - "version": "20.12.2", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.12.2.tgz", - "integrity": "sha512-vCIoyoKQ7vxLb9zRiTc7uKryHDG3YY6tXK96D6MuugFnV9xTbmiPGM/GYFn1q+fWoIBv53q4rBEgydDkQwnDZw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.1.tgz", + "integrity": "sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==", "dev": true, "license": "MIT", "dependencies": { @@ -1789,23 +1754,6 @@ "node": ">=20.0.0" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/html5-qrcode": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", @@ -1849,45 +1797,6 @@ "@types/estree": "^1.0.6" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1904,16 +1813,6 @@ "dev": true, "license": "MIT" }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2210,9 +2109,9 @@ } }, "node_modules/magicast": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", - "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { @@ -2221,22 +2120,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/memoizee": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", @@ -2286,9 +2169,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -2310,9 +2193,9 @@ "license": "ISC" }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2323,13 +2206,6 @@ "node": ">=12.20.0" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2349,28 +2225,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2381,9 +2254,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "funding": [ { "type": "opencollective", @@ -2400,9 +2273,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2459,12 +2332,12 @@ } }, "node_modules/rolldown": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", - "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.147.0", + "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2474,21 +2347,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.6", - "@rolldown/binding-android-arm64": "1.2.6", - "@rolldown/binding-darwin-arm64": "1.2.6", - "@rolldown/binding-darwin-x64": "1.2.6", - "@rolldown/binding-freebsd-x64": "1.2.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", - "@rolldown/binding-linux-arm64-gnu": "1.2.6", - "@rolldown/binding-linux-arm64-musl": "1.2.6", - "@rolldown/binding-linux-ppc64-gnu": "1.2.6", - "@rolldown/binding-linux-s390x-gnu": "1.2.6", - "@rolldown/binding-linux-x64-gnu": "1.2.6", - "@rolldown/binding-linux-x64-musl": "1.2.6", - "@rolldown/binding-openharmony-arm64": "1.2.6", - "@rolldown/binding-win32-arm64-msvc": "1.2.6", - "@rolldown/binding-win32-x64-msvc": "1.2.6" + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" } }, "node_modules/sade": { @@ -2503,33 +2376,6 @@ "node": ">=6" } }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", - "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -2546,25 +2392,18 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -2581,36 +2420,23 @@ "node": ">=8" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/svelte": { - "version": "5.57.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", - "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "acorn": "^8.12.1", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.8.1", + "devalue": "^5.9.2", "esm-env": "^1.2.1", - "esrap": "^2.2.12", + "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -3194,16 +3020,19 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -3227,9 +3056,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -3273,22 +3102,22 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "devOptional": true, "license": "MIT" }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -3305,7 +3134,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3356,20 +3185,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vite/node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -3652,38 +3467,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3691,16 +3499,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -3741,6 +3549,30 @@ } } }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/vitest/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -3752,26 +3584,22 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 27ad7106..9d80fa0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,22 +17,22 @@ }, "devDependencies": { "@playwright/test": "^1.60.0", - "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.57.0", + "@sveltejs/adapter-auto": "^8.0.0", + "@sveltejs/adapter-static": "^4.0.0", + "@sveltejs/kit": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^7.0.1", "@testing-library/svelte": "^5.3.1", "@testing-library/user-event": "^14.6.7", "@types/hammerjs": "^2.0.46", "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.7", - "happy-dom": "^20.9.0", - "svelte": "^5.55.2", - "svelte-check": "^4.4.6", + "@vitest/coverage-v8": "^5.0.4", + "happy-dom": "^21.0.1", + "svelte": "^5.57.1", + "svelte-check": "^4.7.5", "typescript": "^6.0.2", - "vite": "^8.0.7", - "vitest": "^4.1.6" + "vite": "^8.0.12", + "vitest": "^5.0.4" }, "dependencies": { "@fontsource/inter": "^5.2.8", @@ -51,12 +51,15 @@ "tailwindcss": "^4.3.0" }, "overrides": { - "cookie": "^0.7.2", + "cookie": "^2.0.1", "svelte-i18n": { "esbuild": "^0.25.0" } }, "allowScripts": { "esbuild@0.25.12": true + }, + "imports": { + "#lib/*": "./src/lib/*" } } diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts index 997e11bc..f6394533 100644 --- a/frontend/src/lib/chartjs/register.ts +++ b/frontend/src/lib/chartjs/register.ts @@ -37,7 +37,7 @@ import advancedFormat from 'dayjs/plugin/advancedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import quarterOfYear from 'dayjs/plugin/quarterOfYear'; import weekday from 'dayjs/plugin/weekday'; -import { getTimezone } from '$lib/stores/timezone'; +import { getTimezone } from '#lib/stores/timezone.js'; dayjs.extend(utc); dayjs.extend(timezone); diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index a176ea5f..f9d90b7d 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -1,8 +1,8 @@ diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index 66af4935..d49b6809 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -1,22 +1,22 @@ @@ -302,11 +299,17 @@
-
-
- - {$_('app.title')} - +
+
+ {$_('app.title')} +
@@ -349,7 +360,11 @@

🔒

{$_('publicProfile.page.loginRequired')}

{$_('publicProfile.page.loginRequiredDesc')}

- {$_('publicProfile.page.loginButton')} + + {$_('publicProfile.page.loginButton')}
{:else if status === 'error'} @@ -447,9 +462,10 @@ {/each} {#if lastReadAll.length > lastRead.length} - + {/if} {/if} @@ -474,16 +490,16 @@
{#each books as book}
- {#if book.cover_url} + {#if book.cover_url} - {:else} -
- {/if} -
-

{book.title}

-

({formatAuthors(book.authors)})

-
+ {:else} +
+ {/if} +
+

{book.title}

+

({formatAuthors(book.authors)})

+
{/each}
{#if timelineHiddenCount(monthKey, books.length) > 0} @@ -493,9 +509,10 @@ {/each} {#if timelineBooks.length > timelineLimit} - + {/if} {/if} @@ -514,7 +531,7 @@ placeholder={$_('import.searchByTitleOrAuthor')} class="input input-bordered input-sm w-full max-w-sm" bind:value={libraryQuery} - oninput={() => (libraryLimit = 8)} + oninput={() => libraryLimit = 8} /> {#if filteredBooks.length === 0}

{$_('search.noResultsFor', { values: { query: libraryQuery.trim() } })}

@@ -534,9 +551,10 @@ {/each} {#if filteredBooks.length > libraryLimit} - + {/if} {/if} {/if} @@ -553,16 +571,21 @@
{#each summaryStats as key}
-
{$_(PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey)}
+
{$_((PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!).i18nKey)}
{#if key === 'busiest_month'}
{formatMonthLabel(statValue(key) as string)}
{$_('statistics.booksCount', { values: { count: formatNumber(statValue(key + '_count') as number) } })}
{:else if key === 'most_popular_language'} -
{(statValue(key) as string) ? formatLanguageCode(statValue(key) as string, appLocale) : '-'}
+
+ {statValue(key) as string + ? formatLanguageCode(statValue(key) as string, appLocale) + : '-'} +
+
{formatNumber(statValue(key + '_count') as number)}
{:else}
{formatNumber(statValue(key) as number)}
- + {/if}
{/each} @@ -580,8 +603,11 @@ {@const rows = distributionRows(key)} {@const distTotal = rows.reduce((sum, row) => sum + row.value, 0)} {#if rows.length > 0} -
-

{$_(PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey)}

+
+

{$_((PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!).i18nKey)}

+
{#each rows as row}
@@ -627,21 +653,21 @@ {@const max = points.reduce((m, p) => Math.max(m, p.value), 0)} {#if points.length > 0}
-

{$_(PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey)}

-
- {#each points as point} - - {/each} + + {/each}
{#each points as point} @@ -662,7 +688,7 @@ {#if ratingSummaryKeys.length > 0}
{#each ratingSummaryKeys as key} - {@const i18nKey = PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey} + {@const i18nKey = (PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!).i18nKey}

{$_(i18nKey)}

{key === 'average_rating' ? formatNumber(statValue(key) as number) : formatNumber(statValue(key) as number, 0)}

@@ -692,8 +718,15 @@ {#if ratedBookKeys.length > 0}
{#each ratedBookKeys as key} - {@const i18nKey = PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey} - {@const books = statValue(key) as { title: string; author: string | null; rating: number; cover_url: string | null }[]} + {@const i18nKey = (PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!).i18nKey} + + {@const books = statValue(key) as { + title: string; + author: string | null; + rating: number; + cover_url: string | null + }[]} + {#if books.length > 0}

{$_(i18nKey)}

@@ -737,8 +770,8 @@
diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 925ca37f..12ae58a9 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -1,21 +1,21 @@