diff --git a/README.md b/README.md index 0e7edcd0..417c77ef 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Open **http://localhost:8001** and create your account. - **No API keys required.** Works with Open Library out of the box. Add Google Books or Hardcover.app tokens optionally for richer search results. - **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 CSV with automatic field mapping, generic CSV with per-field Python transforms, JSON, ZIP with covers. +- **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. - **Point your phone at an ISBN barcode.** Real-time barcode scanning in the browser — no native app required. - **Cover art from multiple sources.** Automatic search across AbeBooks, Open Library, Amazon, and Hardcover — plus manual upload or URL paste. - **Full REST API.** OpenAPI-documented backend you can script against — build your own frontend, connect home automation, or pipe data into your own tools. @@ -81,7 +81,7 @@ Open **http://localhost:8001** and create your account. - **Reading progress** — Page-level slider, full progress timeline per book with edit/history - **Statistics dashboard** — Calendar heatmap, distribution charts, books finished per period, top authors - **Book import** — Search Open Library, Google Books, Hardcover.app. Scan ISBN barcodes on mobile. Manual entry for anything not found -- **Data portability** — Export as JSON, CSV, or ZIP with covers. Import from Goodreads or any CSV with custom field mapping +- **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 diff --git a/backend/app/routers/data.py b/backend/app/routers/data.py index 876757df..65d9afef 100644 --- a/backend/app/routers/data.py +++ b/backend/app/routers/data.py @@ -91,9 +91,10 @@ async def parse_import_file( delimiter: str = Form(","), current_user: User = Depends(require_user), ) -> DataImportParseResponse: - """Parse an uploaded CSV or JSON import file and return field info and samples. + """Parse an uploaded CSV, JSON, or XLSX import file and return field info and samples. - ``delimiter`` is the single-character CSV field separator (ignored for JSON). + ``delimiter`` is the single-character CSV field separator (ignored for JSON + and XLSX). """ assert current_user.id is not None allowed_content_types = { @@ -102,11 +103,18 @@ async def parse_import_file( "application/vnd.ms-excel", "application/json", "text/plain", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel.sheet.macroEnabled.12", } - if file.content_type and file.content_type not in allowed_content_types: - raise HTTPException(status_code=415, detail="Unsupported upload content type. Use CSV or JSON files.") + filename = file.filename or "upload" + allowed_extensions = (".csv", ".json", ".xlsx", ".xlsm") + extension_ok = filename.lower().endswith(allowed_extensions) + if file.content_type and file.content_type not in allowed_content_types and not extension_ok: + raise HTTPException( + status_code=415, detail="Unsupported upload content type. Use CSV, JSON, or Excel (.xlsx) files." + ) try: - payload = parse_upload(await file.read(), file.filename or "upload", current_user.id, delimiter) + payload = parse_upload(await file.read(), filename, current_user.id, delimiter) except (ValueError, json.JSONDecodeError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return DataImportParseResponse.model_validate(payload) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ab67e627..6ebe2e57 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -647,10 +647,11 @@ class DataExportRequest(SQLModel): class DataImportParseResponse(SQLModel): """Response after parsing an uploaded import file.""" file_id: str - format: Literal["csv", "json"] + format: Literal["csv", "json", "xlsx"] source_fields: list[str] sample_rows: list[dict] row_count: int + sheet: Optional[str] = None class ImportFieldConfig(SQLModel): diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index d90817af..8770b96c 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -1,4 +1,4 @@ -"""CSV/JSON data import pipeline — parsing, validation, mapping, and execution.""" +"""CSV/JSON/XLSX data import pipeline: parsing, validation, mapping, and execution.""" import csv import hashlib @@ -6,11 +6,17 @@ import logging import re import secrets -from datetime import datetime, timezone +import zipfile +from datetime import date, datetime, time, timezone +from io import BytesIO from pathlib import Path from typing import Any, Callable, Optional +from xml.etree.ElementTree import ParseError import httpx +from defusedxml.common import DefusedXmlException +from openpyxl import load_workbook +from openpyxl.utils.exceptions import InvalidFileException from sqlalchemy.exc import IntegrityError from sqlmodel import Session, col, select @@ -151,18 +157,103 @@ def _to_flat_row(row: dict) -> dict[str, object]: return flat +def _xlsx_cell_to_str(value: object) -> str: + """Normalize an XLSX cell value to a string, matching CSV semantics. + + Dates and times are rendered as ISO-8601 strings, integral floats lose + their trailing ``.0`` (so ``_parse_int`` accepts them), and empty cells + become an empty string. + """ + if value is None: + return "" + if isinstance(value, (datetime, date, time)): + return value.isoformat() + if isinstance(value, bool): + return str(value) + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _parse_xlsx(content: bytes) -> tuple[list[str], list[dict], str]: + """Convert the active worksheet of an XLSX/XLSM workbook into flat rows. + + The first non-empty row is treated as the header. Subsequent rows are + converted to string values (cell values only, formulas use their cached + result) and fully empty rows are skipped. + + Returns: + A tuple of (source_fields, rows, sheet_name). + + Raises: + ValueError: If the header is missing or the file cannot be parsed. + """ + try: + workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) + except ( + InvalidFileException, + zipfile.BadZipFile, + ParseError, + DefusedXmlException, + KeyError, + OSError, + ) as exc: + raise ValueError("error.importInvalidXlsxFile") from exc + + try: + worksheet = workbook.active + if worksheet is None: + raise ValueError("error.importInvalidXlsxFile") + sheet_name = worksheet.title or "" + + source_fields: list[str] = [] + rows: list[dict] = [] + header_found = False + for raw_row in worksheet.iter_rows(values_only=True): + values = list(raw_row) + if not header_found: + if all(cell is None or str(cell) == "" for cell in values): + continue + header_found = True + last = max( + (idx for idx, cell in enumerate(values) if cell is not None and str(cell) != ""), + default=-1, + ) + source_fields = [str(cell) if cell is not None else "" for cell in values[: last + 1]] + continue + + if all(cell is None or str(cell) == "" for cell in values): + continue + if len(rows) >= settings.max_import_row_count: + raise ValueError("error.importTooManyRows") + rows.append( + { + field: _xlsx_cell_to_str(values[idx] if idx < len(values) else None) + for idx, field in enumerate(source_fields) + } + ) + + if not header_found: + raise ValueError("error.importMissingHeader") + finally: + workbook.close() + + return source_fields, rows, sheet_name + + def parse_upload(content: bytes, filename: str, user_id: int, delimiter: str = ",") -> dict: - """Parse an uploaded CSV or JSON file and persist the parsed result to disk. + """Parse an uploaded CSV, JSON, or XLSX file and persist the result to disk. Args: content: Raw file bytes. filename: Original filename (used to detect format). user_id: Owner of the upload. delimiter: Single-character field separator used for CSV files - (ignored for JSON). + (ignored for JSON and XLSX). Returns: - A dict with file_id, format, source_fields, sample_rows, and row_count. + A dict with file_id, format, source_fields, sample_rows, row_count, + and (for XLSX) sheet. Raises: ValueError: On validation failures. @@ -173,6 +264,7 @@ def parse_upload(content: bytes, filename: str, user_id: int, delimiter: str = " raise ValueError("error.importFileTooLarge") lower = filename.lower() + sheet: str | None = None if lower.endswith(".csv"): if len(delimiter) != 1: raise ValueError("error.importInvalidDelimiter") @@ -197,6 +289,9 @@ def parse_upload(content: bytes, filename: str, user_id: int, delimiter: str = " rows.append(flat) source_set.update(flat.keys()) source_fields = sorted(source_set) + elif lower.endswith((".xlsx", ".xlsm")): + parsed_format = "xlsx" + source_fields, rows, sheet = _parse_xlsx(content) else: raise ValueError("error.importUnsupportedFileType") @@ -214,6 +309,7 @@ def parse_upload(content: bytes, filename: str, user_id: int, delimiter: str = " "format": parsed_format, "source_fields": source_fields, "rows": rows, + "sheet": sheet, "created_at": utcnow().isoformat(), } path = _temp_file_path(user_id, file_id) @@ -232,6 +328,7 @@ def parse_upload(content: bytes, filename: str, user_id: int, delimiter: str = " "source_fields": source_fields, "sample_rows": rows[:5], "row_count": len(rows), + "sheet": sheet, } @@ -962,6 +1059,80 @@ async def execute_import( } +_BOOKSTATS_SOURCE_FIELDS: list[str] = [ + "Titel", "Autor(en)", "ISBN", "ASIN", "Erscheinungsjahr", "Genre", + "Seitenanzahl", "Dauer (Stunden)", "Dauer (Minuten)", "Buchart", "Preis", + "Erhalten als", "Lesestatus", "Lesebeginn", "Leseende", "Bewertung", + "Kategorie", "Notizen", "Erhalten am", +] + +_BOOKSTATS_AUTHORS_TRANSFORM = """\ +raw = str(value).strip() +result = [] +if not raw: + return result +chunks = re.split(r';| & | and ', raw) +for chunk in chunks: + chunk = chunk.strip() + if not chunk: + continue + parts = [] + for p in chunk.split(','): + p = p.strip() + if p: + parts.append(p) + if len(parts) <= 1: + result.append(chunk) + elif len(parts) == 2: + if parts[0].count(' ') == 0: + result.append(parts[1] + ' ' + parts[0]) + else: + result.append(parts[0]) + result.append(parts[1]) + elif len(parts) % 2 == 0: + for i in range(0, len(parts), 2): + result.append(parts[i + 1] + ' ' + parts[i]) + else: + result.append(chunk) +return result""" + +_BOOKSTATS_TAGS_TRANSFORM = """\ +result = [] +genre = str(value).strip() +if genre: + result.append(genre) +kategorie = str(row.get('Kategorie', '')).strip() +if kategorie and kategorie.lower() != genre.lower(): + result.append(kategorie) +return result""" + +_BOOKSTATS_READING_STATUS_TRANSFORM = """\ +mapping = {'gelesen': 'read', 'am lesen': 'currently_reading', 'ungelesen': 'want_to_read', 'abgebrochen': 'did_not_finish'} +return mapping.get(str(value).strip().lower(), 'want_to_read')""" + +_BOOKSTATS_ACQUISITION_TRANSFORM = """\ +mapping = {'kauf': 'owned', 'geschenk': 'owned', 'leihe': 'borrowed'} +return mapping.get(str(value).strip().lower(), 'owned')""" + +_BOOKSTATS_MEDIUM_TRANSFORM = """\ +mapping = {'taschenbuch': 'Print', 'hardcover': 'Print', 'e-book': 'eBook', 'ebook': 'eBook', 'hörbuch': 'Audiobook', 'hoerbuch': 'Audiobook'} +return mapping.get(str(value).strip().lower())""" + +_BOOKSTATS_RATING_TRANSFORM = """\ +raw = str(value).strip() +if not raw or raw == '0': + return None +return raw""" + +_BOOKSTATS_DATE_TRANSFORM = """\ +raw = str(value).strip() +if not raw: + return None +if raw.replace('.', '', 1).isdigit(): + return (datetime.datetime(1899, 12, 30) + datetime.timedelta(days=int(float(raw)))).strftime('%Y-%m-%d') +return raw""" + + PREDEFINED_MAPPINGS: list[dict[str, Any]] = [ { "id": -1, @@ -1027,6 +1198,27 @@ async def execute_import( "cover_url": {"source": "", "transform": None}, }, }, + { + "id": -2, + "name": "Bookstats Export", + "source_fields": list(_BOOKSTATS_SOURCE_FIELDS), + "mapping": { + "title": {"source": "Titel", "transform": None}, + "authors": {"source": "Autor(en)", "transform": _BOOKSTATS_AUTHORS_TRANSFORM}, + "isbn": {"source": "ISBN", "transform": None}, + "published_year": {"source": "Erscheinungsjahr", "transform": None}, + "page_count": {"source": "Seitenanzahl", "transform": None}, + "tags": {"source": "Genre", "transform": _BOOKSTATS_TAGS_TRANSFORM}, + "reading_status": {"source": "Lesestatus", "transform": _BOOKSTATS_READING_STATUS_TRANSFORM}, + "acquisition_status": {"source": "Erhalten als", "transform": _BOOKSTATS_ACQUISITION_TRANSFORM}, + "medium": {"source": "Buchart", "transform": _BOOKSTATS_MEDIUM_TRANSFORM}, + "rating": {"source": "Bewertung", "transform": _BOOKSTATS_RATING_TRANSFORM}, + "date_started": {"source": "Lesebeginn", "transform": _BOOKSTATS_DATE_TRANSFORM}, + "date_finished": {"source": "Leseende", "transform": _BOOKSTATS_DATE_TRANSFORM}, + "date_added": {"source": "Erhalten am", "transform": _BOOKSTATS_DATE_TRANSFORM}, + "notes": {"source": "Notizen", "transform": None}, + }, + }, ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 56c51f9a..4812a24c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,8 +11,10 @@ dependencies = [ "curl-cffi>=0.16.1", "fastapi-mail>=1.6.8", "fastapi>=0.141.1", + "defusedxml>=0.7.1", "httpx>=0.28.1", "itsdangerous>=2.2.0", + "openpyxl>=3.1.5", "playwright>=1.62.0", "passlib[bcrypt]>=1.7.4", "pydantic-settings>=2.15.0", diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 49ff2751..743d3d6c 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -227,11 +227,13 @@ def test_data_import_mapping_crud(client: TestClient) -> None: list_resp = client.get("/api/data/import/mappings") assert list_resp.status_code == 200 data = list_resp.json() - assert len(data) == 2 + assert len(data) == 3 assert data[0]["is_predefined"] is True assert data[0]["name"] == "Goodreads Export" - assert data[1]["is_predefined"] is False - assert data[1]["name"] == "Goodreads" + assert data[1]["is_predefined"] is True + assert data[1]["name"] == "Bookstats Export" + assert data[2]["is_predefined"] is False + assert data[2]["name"] == "Goodreads" get_resp = client.get(f"/api/data/import/mappings/{saved['id']}") assert get_resp.status_code == 200 @@ -474,7 +476,63 @@ def test_data_import_parse_unsupported_content_type(client: TestClient) -> None: files={"file": ("test.exe", b"invalid", "application/octet-stream")}, ) assert resp.status_code == 415 - assert resp.json()["detail"] == "Unsupported upload content type. Use CSV or JSON files." + assert resp.json()["detail"] == "Unsupported upload content type. Use CSV, JSON, or Excel (.xlsx) files." + + +def test_data_import_parse_xlsx( + client: TestClient, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + from io import BytesIO + + from openpyxl import Workbook + + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "Books" + worksheet.append(["Title", "Author"]) + worksheet.append(["Dune", "Frank Herbert"]) + buffer = BytesIO() + workbook.save(buffer) + + resp = client.post( + "/api/data/import/parse", + files={ + "file": ( + "books.xlsx", + buffer.getvalue(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["format"] == "xlsx" + assert body["sheet"] == "Books" + assert body["source_fields"] == ["Title", "Author"] + assert body["row_count"] == 1 + + +def test_data_import_parse_accepts_xlsx_with_generic_content_type( + client: TestClient, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + from io import BytesIO + + from openpyxl import Workbook + + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + workbook = Workbook() + workbook.active.append(["Title"]) + workbook.active.append(["Dune"]) + buffer = BytesIO() + workbook.save(buffer) + + resp = client.post( + "/api/data/import/parse", + files={"file": ("books.xlsx", buffer.getvalue(), "application/octet-stream")}, + ) + assert resp.status_code == 200 + assert resp.json()["format"] == "xlsx" def test_data_import_parse_invalid_json(client: TestClient) -> None: @@ -650,6 +708,16 @@ def test_data_import_mapping_get_predefined(client: TestClient) -> None: assert data["name"] == "Goodreads Export" +def test_data_import_mapping_get_predefined_bookstats(client: TestClient) -> None: + resp = client.get("/api/data/import/mappings/-2") + assert resp.status_code == 200 + data = resp.json() + assert data["is_predefined"] is True + assert data["id"] == -2 + assert data["name"] == "Bookstats Export" + assert data["mapping"]["tags"]["source"] == "Genre" + + def test_data_import_mapping_get_predefined_missing(client: TestClient) -> None: resp = client.get("/api/data/import/mappings/-999") assert resp.status_code == 404 diff --git a/backend/tests/test_data_import.py b/backend/tests/test_data_import.py index ec0b955b..19260a73 100644 --- a/backend/tests/test_data_import.py +++ b/backend/tests/test_data_import.py @@ -7,10 +7,11 @@ from datetime import datetime, timezone from io import BytesIO from pathlib import Path -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest +from openpyxl import Workbook from pytest import MonkeyPatch from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select @@ -137,6 +138,118 @@ def test_parse_upload_unsupported_file_type() -> None: di.parse_upload(b"x", "test.txt", 1) +# ── parse_upload: XLSX ──────────────────────────────────────────────────────── + +def _make_xlsx_bytes(rows: list[list[Any]], sheet_title: str = "Books") -> bytes: + workbook = Workbook() + worksheet = workbook.active + worksheet.title = sheet_title + for row in rows: + worksheet.append(row) + buffer = BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +def test_xlsx_cell_to_str_normalization() -> None: + assert di._xlsx_cell_to_str(None) == "" + assert di._xlsx_cell_to_str("text") == "text" + assert di._xlsx_cell_to_str(4) == "4" + assert di._xlsx_cell_to_str(4.0) == "4" + assert di._xlsx_cell_to_str(3.5) == "3.5" + assert di._xlsx_cell_to_str(True) == "True" + assert di._xlsx_cell_to_str(datetime(2024, 1, 15, 10, 30)) == "2024-01-15T10:30:00" + from datetime import date, time + + assert di._xlsx_cell_to_str(date(2024, 1, 15)) == "2024-01-15" + assert di._xlsx_cell_to_str(time(10, 30)) == "10:30:00" + + +def test_parse_upload_xlsx_basic(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + content = _make_xlsx_bytes( + [["Title", "Author", "Pages"], ["Dune", "Frank Herbert", 412]] + ) + result = di.parse_upload(content, "books.xlsx", 1) + assert result["format"] == "xlsx" + assert result["sheet"] == "Books" + assert result["source_fields"] == ["Title", "Author", "Pages"] + assert result["row_count"] == 1 + assert result["sample_rows"][0] == { + "Title": "Dune", + "Author": "Frank Herbert", + "Pages": "412", + } + + +def test_parse_upload_xlsm_extension_uses_xlsx_parser(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + content = _make_xlsx_bytes([["Title"], ["Dune"]]) + result = di.parse_upload(content, "books.xlsm", 1) + assert result["format"] == "xlsx" + assert result["source_fields"] == ["Title"] + + +def test_parse_upload_xlsx_date_cell_is_iso_string(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + content = _make_xlsx_bytes([["Title", "Started"], ["Dune", datetime(2024, 1, 15, 9, 30)]]) + result = di.parse_upload(content, "books.xlsx", 1) + assert result["sample_rows"][0]["Started"] == "2024-01-15T09:30:00" + + +def test_parse_upload_xlsx_skips_empty_rows(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + content = _make_xlsx_bytes([["Title"], ["Dune"], [None], [""], ["Messiah"]]) + result = di.parse_upload(content, "books.xlsx", 1) + assert result["row_count"] == 2 + assert [row["Title"] for row in result["sample_rows"]] == ["Dune", "Messiah"] + + +def test_parse_upload_xlsx_trims_trailing_empty_header_columns(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + content = _make_xlsx_bytes([["Title", None, None], ["Dune", None, None]]) + result = di.parse_upload(content, "books.xlsx", 1) + assert result["source_fields"] == ["Title"] + assert result["sample_rows"][0] == {"Title": "Dune"} + + +def test_parse_upload_xlsx_empty_sheet_raises(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + content = _make_xlsx_bytes([]) + with pytest.raises(ValueError, match="error.importMissingHeader"): + di.parse_upload(content, "books.xlsx", 1) + + +def test_parse_upload_xlsx_corrupt_file_raises(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + with pytest.raises(ValueError, match="error.importInvalidXlsxFile"): + di.parse_upload(b"not-a-real-xlsx", "books.xlsx", 1) + + +def test_parse_upload_xlsx_too_many_rows(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + monkeypatch.setattr(settings, "max_import_row_count", 1) + content = _make_xlsx_bytes([["Title"], ["Book1"], ["Book2"]]) + with pytest.raises(ValueError, match="error.importTooManyRows"): + di.parse_upload(content, "books.xlsx", 1) + + +def test_parse_upload_xlsx_uses_active_sheet(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + workbook = Workbook() + workbook.active.title = "First" + second = workbook.create_sheet("Second") + second.append(["Title"]) + second.append(["FromSecond"]) + workbook.active = workbook.sheetnames.index("Second") + buffer = BytesIO() + workbook.save(buffer) + + result = di.parse_upload(buffer.getvalue(), "books.xlsx", 1) + assert result["sheet"] == "Second" + assert result["sample_rows"][0]["Title"] == "FromSecond" + + def test_parse_upload_temp_file_create_failed(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) # Force FileExistsError on every attempt @@ -1705,5 +1818,53 @@ def test_get_predefined_mapping_known_id() -> None: assert result["name"] == "Goodreads Export" +def test_get_predefined_mapping_bookstats_id() -> None: + result = di.get_predefined_mapping(-2) + assert result is not None + assert result["name"] == "Bookstats Export" + source_fields = cast(list[str], result["source_fields"]) + mapping_raw = cast(dict[str, dict[str, Any]], result["mapping"]) + assert "Titel" in source_fields + assert mapping_raw["tags"]["source"] == "Genre" + + def test_get_predefined_mapping_unknown_id() -> None: assert di.get_predefined_mapping(-999) is None + + +def test_bookstats_predefined_mapping_transforms() -> None: + """The Bookstats preset maps a representative row through all its transforms.""" + preset = di.get_predefined_mapping(-2) + assert preset is not None + mapping_raw = cast(dict[str, dict[str, Any]], preset["mapping"]) + mapping = {target: ImportFieldConfig(**config) for target, config in mapping_raw.items()} + row = { + "Titel": "Der Distelfink: Roman", + "Autor(en)": "Lamm, Laila, Grabinger, Michaela", + "ISBN": "9783442473601", + "Erscheinungsjahr": "2015", + "Genre": "Literatur, Klassiker", + "Seitenanzahl": "1024", + "Buchart": "Hörbuch", + "Erhalten als": "Leihe", + "Lesestatus": "Abgebrochen", + "Lesebeginn": "44193", + "Leseende": "", + "Bewertung": "0", + "Kategorie": "Horror", + "Notizen": "", + "Erhalten am": "44193", + } + transform_cache = di._build_transform_cache(mapping) + mapped = di._mapped_row(row, mapping, transform_cache, {}) + + assert mapped["title"] == "Der Distelfink: Roman" + assert mapped["authors"] == ["Laila Lamm", "Michaela Grabinger"] + assert mapped["tags"] == ["Literatur, Klassiker", "Horror"] + assert mapped["reading_status"] == "did_not_finish" + assert mapped["acquisition_status"] == "borrowed" + assert mapped["medium"] == "Audiobook" + assert mapped["rating"] == "" + assert mapped["date_started"] == "2020-12-28" + assert mapped["date_finished"] == "" + assert mapped["date_added"] == "2020-12-28" diff --git a/docs/api/index.md b/docs/api/index.md index 82c6ec2f..0c76a000 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -65,7 +65,7 @@ Books have an optional nullable `medium` field. Accepted values are `Print`, `eB For updates, `author`/`authors` are optional; if you send an empty `authors` list the book's authors are cleared. -The legacy `author` string is **parsed on commas, tag-style** (e.g. `"Isaac Asimov, Frank Herbert"` becomes two authors). This only applies to the API create/update path. It differs from **file import** (CSV/JSON), where a single author string is split on `;`, ` & `, or ` and ` — never on commas — so a name like `"Asimov, Isaac"` stays one author. See [Import & Export](../guide/using-librislog/import-export.md) for the import behaviour. +The legacy `author` string is **parsed on commas, tag-style** (e.g. `"Isaac Asimov, Frank Herbert"` becomes two authors). This only applies to the API create/update path. It differs from **file import** (CSV/JSON/XLSX), where a single author string is split on `;`, ` & `, or ` and ` — never on commas — so a name like `"Asimov, Isaac"` stays one author. See [Import & Export](../guide/using-librislog/import-export.md) for the import behaviour. # Update reading status curl -X POST \ diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index 102a2a4b..ecc02d04 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -72,11 +72,14 @@ Import data from external sources: - **JSON** — LibrisLog export format - **CSV** — Custom field mapping supported +- **Excel (XLSX)**: Custom field mapping supported The JSON export mirrors the API shape: `author` is the joined string (separated with `; `), `authors` is the list of names, and `tags` is a list of tag names. All three round-trip through the adaptive import. For CSV files, a **delimiter** field appears once a `.csv` file is selected (default `,`). Enter the character your file uses to separate columns (e.g. `;` for German/Excel exports) before clicking **Parse file**. +Excel support covers `.xlsx` and `.xlsm` workbooks. LibrisLog reads the workbook's **active worksheet**: the first non-empty row must contain the column headers and every following row is treated as a record. Cell values are read as stored, so percentages, currency, and leading zeros are imported as displayed rather than recomputed, and formula cells use their cached result (a formula without a cached value is imported as empty). If a workbook has several worksheets, save the one you want to import as the active sheet, or export that sheet to CSV first. The parsed sheet name is shown next to the row and field counts after parsing. + ### Field Mapping When importing CSV, map source columns to LibrisLog fields: @@ -120,6 +123,7 @@ Available variables: Common import formats have predefined mappings: - **Goodreads Export** — Maps Goodreads CSV columns automatically +- **Bookstats Export** — Maps the German "Bookstats" Excel/CSV export, translating German reading/acquisition/medium values, converting Excel serial dates, and merging `Genre` and `Kategorie` into tags ### Validation diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 8cbdd7ae..a8b40e52 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -114,7 +114,7 @@ details and a list of supported dashboard integrations. Two data management tools are available: -- **Import / Export** — Export your library as JSON, CSV, or ZIP, or import from Goodreads CSV or generic CSV with field mapping and Python transforms. See [Import & Export](/guide/using-librislog/import-export). +- **Import / Export** — Export your library as JSON, CSV, or ZIP, or import from the Goodreads or Bookstats presets, Excel (XLSX), or generic CSV/JSON with field mapping and Python transforms. See [Import & Export](/guide/using-librislog/import-export). - **Data Hygiene** — Find books with missing metadata and batch-update them. See [Data Hygiene](/guide/using-librislog/data-hygiene). ## OIDC diff --git a/docs/releases.md b/docs/releases.md index d20ca5bc..275785df 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,15 +8,15 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/ ## Latest Release -::: tip ⭐ v1.8.0 — Camera & Zoom Control, Optional Telemetry -LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner, optional anonymous installation telemetry with a publicly verifiable census, a new Homer dashboard integration, and several usability and dependency fixes. +::: 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. ::: ### All releases | Version | Date | Type | |---|---|---| -| [v1.9.0](#v1-9-0-smarter-import-search-input-ux) | Unreleased | 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 | | [v1.6.0](#v1-6-0-reading-progress-possession-tracking) | 2026-08-23 | Feature release | @@ -37,9 +37,9 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner ## v1.9.0: Smarter Import Search & Input UX - + -**Summary:** Adds shareable read-only public profile pages with configurable access and content, groups duplicate import-search results into expandable edition groups, lets you collect search results in an import basket and import them all at once, supports multiple parallel import searches, makes running searches cancelable, adds configurable reading-date automation, introduces an adaptive date input with a native picker, adds optional book media and medium statistics, supports localized medium and possession searches, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. +**Summary:** Adds shareable read-only public profile pages with configurable access and content, groups duplicate import-search results into expandable edition groups, lets you collect search results in an import basket and import them all at once, supports multiple parallel import searches, makes running searches cancelable, adds Excel (XLSX) file import, adds a Bookstats import preset, adds configurable reading-date automation, introduces an adaptive date input with a native picker, adds optional book media and medium statistics, supports localized medium and possession searches, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. **Features** - 📚 **Edition groups in the import search**: results from different providers that describe the same book (same ISBN, or same title and authors) are now grouped into expandable entries with an "N results" badge. Compare the variants side by side and import the one you want; no result is dropped anymore. The selected edition is highlighted with a border and a "Selected" badge, and every edition row shows a pointer cursor, hover feedback, and a keyboard focus ring. See the [Library guide](/guide/using-librislog/library#how-results-are-grouped) for the exact grouping rules @@ -56,6 +56,8 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner - 🧺 **Import basket**: search results now offer an **Add to Basket** action next to the existing **Add** button. Collected books appear in a new **Basket** tab with a live count badge, where you can review them, remove individual entries, and import everything in one go. Each entry remembers the reading status, possession status, and medium that were selected when it was added. If some books fail during a basket import, the successful ones are imported and the failed ones stay in the basket so you can retry or remove them. The same book cannot be added twice - 🔎 **Parallel import searches**: open multiple independent search panels in the Add Book dialog and run different queries concurrently. Each panel keeps its own results and can add selected books to the shared import basket - 📅 **Configurable reading-date automation**: choose independently whether moving a book to Currently Reading, Read, or Did Not Finish should fill a missing start or finish date automatically. Existing dates are preserved, and disabling automation allows intentionally unknown dates without additional transition popups. See the [Profile guide](/guide/using-librislog/profile#reading-date-automation) +- 📥 **Excel (XLSX) data import**: the Data Import page now accepts `.xlsx` and `.xlsm` workbooks alongside CSV and JSON. LibrisLog reads the workbook's active worksheet, treats the first non-empty row as the header and every following row as a record, shows the parsed sheet name next to the row and field counts, and runs the result through the same mapping, preview, validation, and import flow as CSV. Dates are read as ISO strings, whole numbers stay integers, and empty rows are skipped. See the [Import & Export guide](/guide/using-librislog/import-export#supported-formats) +- 📥 **Bookstats import preset**: a new built-in, read-only mapping for the German Bookstats export. It translates German reading, acquisition, and medium values, converts Excel serial dates, reorders "Last, First" author names, maps the rating (with `0` as unrated), and merges `Genre` and `Kategorie` into tags. Load it from the saved-mappings dropdown like the Goodreads Export preset. See the [Import & Export guide](/guide/using-librislog/import-export#predefined-mappings) **Bug fixes** - 🗓️ **Timezone-correct daily page statistics**: pages read between two progress updates are now attributed to calendar days in the user's timezone instead of fixed 24h slots, so the pages-per-day view matches your local days. Your heatmap may shift slightly after the upgrade diff --git a/frontend/src/lib/components/DataImport.svelte b/frontend/src/lib/components/DataImport.svelte index 7186e18d..bbd8ee55 100644 --- a/frontend/src/lib/components/DataImport.svelte +++ b/frontend/src/lib/components/DataImport.svelte @@ -343,7 +343,7 @@ type="file" name="import-file" class="hidden" - accept=".csv,.json" + accept=".csv,.json,.xlsx,.xlsm" aria-label={$_('data.import.fileInputLabel')} onchange={(e) => { selectedFile = e.currentTarget.files?.[0] ?? null; @@ -374,6 +374,9 @@ {#if parsed}

{$_('data.import.fileSummary', { values: { rows: parsed.row_count, fields: parsed.source_fields.length } })} + {#if parsed.sheet} + {$_('data.import.sheetLabel', { values: { sheet: parsed.sheet } })} + {/if}

{/if} diff --git a/frontend/src/lib/components/DataImport.test.ts b/frontend/src/lib/components/DataImport.test.ts index df50591b..0eab0f91 100644 --- a/frontend/src/lib/components/DataImport.test.ts +++ b/frontend/src/lib/components/DataImport.test.ts @@ -2,14 +2,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; import { writable } from 'svelte/store'; import DataImport from './DataImport.svelte'; - -const mockParseImportFile = vi.fn(async (_file: File) => ({ - file_id: 'test-file-123', - format: 'csv' as const, - source_fields: ['Book Title', 'Author Name', 'ISBN'], - sample_rows: [{ 'Book Title': 'Dune', 'Author Name': 'Frank Herbert', 'ISBN': '978-3-16-148410-0' }], - row_count: 1 -})); +import type { DataImportParseResponse } from '$lib/types'; + +const mockParseImportFile = vi.fn( + async (_file: File): Promise => ({ + file_id: 'test-file-123', + format: 'csv', + source_fields: ['Book Title', 'Author Name', 'ISBN'], + sample_rows: [{ 'Book Title': 'Dune', 'Author Name': 'Frank Herbert', 'ISBN': '978-3-16-148410-0' }], + row_count: 1 + }) +); const mockSuggestMapping = vi.fn(async (_fileId: string) => ({ suggested_mapping: { title: 'Book Title', authors: 'Author Name', isbn: 'ISBN' }, db_fields: ['title', 'authors', 'isbn', 'publisher', 'page_count'] @@ -63,7 +66,7 @@ describe('DataImport', () => { it('renders title and description', () => { render(DataImport); expect(screen.getByRole('heading', { name: 'Import' })).toBeInTheDocument(); - expect(screen.getByText(/Upload one CSV or JSON file/)).toBeInTheDocument(); + expect(screen.getByText(/Upload one CSV, JSON, or Excel/)).toBeInTheDocument(); }); it('has dropzone for file upload', () => { @@ -172,4 +175,34 @@ describe('DataImport', () => { expect(screen.getByLabelText('Create 100% progress entry for books imported as \'Read\'')).toBeInTheDocument(); }); }); + + it('accepts xlsx and xlsm files in the file input', () => { + render(DataImport); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const accept = input.getAttribute('accept') ?? ''; + expect(accept).toContain('.xlsx'); + expect(accept).toContain('.xlsm'); + }); + + it('shows the parsed sheet name for xlsx files', async () => { + mockParseImportFile.mockResolvedValueOnce({ + file_id: 'xlsx-file-123', + format: 'xlsx' as const, + source_fields: ['Title'], + sample_rows: [{ Title: 'Dune' }], + row_count: 1, + sheet: 'Books' + }); + render(DataImport); + const file = new File(['test'], 'books.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + await fireEvent.change(input, { target: { files: [file] } }); + await fireEvent.click(screen.getByRole('button', { name: 'Parse file' })); + + await waitFor(() => { + expect(screen.getByText('Sheet: Books')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index ad6bcc1a..6cd14e19 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -8,7 +8,7 @@ const BACKEND_ERROR_MAP: Record = { 'A finished book must have an end date. Change the status if you want to remove the finish date.': 'error.dateFinishedRequiredForRead', 'Language must be a 2-letter ISO code (for example: EN, DE, FR).': 'error.invalidLanguageCode', 'Select at least one dataset to export.': 'error.exportNoDatasets', - 'Unsupported upload content type. Use CSV or JSON files.': 'error.importUnsupportedContentType', + 'Unsupported upload content type. Use CSV, JSON, or Excel (.xlsx) files.': 'error.importUnsupportedContentType', 'A mapping with this name already exists.': 'error.importMappingNameConflict', 'Import mapping not found.': 'error.importMappingNotFound', 'Confirmation phrase does not match.': 'error.invalidConfirmationPhrase', diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 30ffa27c..097f5d31 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -506,7 +506,7 @@ "publicProfileNotFound": "Dieser öffentliche Profil-Link ist nicht mehr gültig.", "publicProfileLoginRequired": "Dieses Profil ist nur für angemeldete Benutzer sichtbar. Bitte melde dich an, um es anzusehen.", "importMalformedEvent": "Während des Imports wurde ein fehlerhaftes Server-Ereignis empfangen.", - "importUnsupportedContentType": "Nicht unterstützter Upload-Inhaltstyp. Bitte CSV- oder JSON-Dateien verwenden.", + "importUnsupportedContentType": "Nicht unterstützter Upload-Inhaltstyp. Bitte CSV-, JSON- oder Excel-Dateien (.xlsx) verwenden.", "emailAlreadyRegistered": "Diese E-Mail-Adresse ist bereits registriert.", "batchUpdateFailed": "Stapelaktualisierung aufgrund eines unerwarteten Fehlers fehlgeschlagen. Es wurden keine Änderungen gespeichert.", "tooManyBooksSelected": "Zu viele Bücher ausgewählt. Bitte maximal {max} auf einmal auswählen.", @@ -520,7 +520,9 @@ "importMappingNameConflict": "Ein Mapping mit diesem Namen existiert bereits.", "importMappingNotFound": "Import-Mapping nicht gefunden.", "importFileNotFound": "Importdatei nicht gefunden. Bitte lade die Datei erneut hoch.", - "importInvalidDelimiter": "CSV-Trennzeichen muss ein einzelnes Zeichen sein." + "importInvalidDelimiter": "CSV-Trennzeichen muss ein einzelnes Zeichen sein.", + "importUnsupportedFileType": "Nicht unterstützter Dateityp. Bitte verwende eine CSV-, JSON- oder Excel-Datei (.xlsx).", + "importInvalidXlsxFile": "Die Excel-Datei konnte nicht gelesen werden. Stelle sicher, dass es sich um eine gültige .xlsx-Arbeitsmappe handelt." }, "oidc": { "orContinueWith": "oder weiter mit", @@ -578,7 +580,7 @@ }, "dataManagement": { "title": "Meine Daten verwalten", - "description": "Exportiere deine Bibliothek oder importiere Bücher aus CSV/JSON.", + "description": "Exportiere deine Bibliothek oder importiere Bücher aus CSV, JSON oder Excel.", "link": "Datenseite öffnen", "missingCoversDescription": "Fehlende Cover mit automatischen Vorschlägen schnell zuweisen.", "missingCoversLink": "Fehlende Cover verwalten" @@ -723,7 +725,8 @@ }, "import": { "title": "Import", - "description": "Lade eine CSV- oder JSON-Datei hoch, mappe Felder, simuliere und importiere dann.", + "description": "Lade eine CSV-, JSON- oder Excel-Datei (.xlsx) hoch, mappe Felder, simuliere und importiere dann.", + "sheetLabel": "Arbeitsblatt: {sheet}", "parse": "Datei einlesen", "parsing": "Lese ein...", "fileSummary": "Zeilen: {rows}, Felder: {fields}", @@ -783,9 +786,9 @@ "confirmImportTitle": "Import starten?", "confirmDestructive": "Dadurch werden Daten in deine Bibliothek geschrieben und es gibt kein automatisches Undo.", "deleteMappingConfirm": "Diese gespeicherte Zuordnung löschen?", - "dropzone": "CSV/JSON-Datei ziehen & ablegen, oder", + "dropzone": "CSV-, JSON- oder Excel-Datei ziehen & ablegen, oder", "browse": "durchsuchen", - "fileInputLabel": "CSV- oder JSON-Datei auswählen", + "fileInputLabel": "CSV-, JSON- oder Excel-Datei auswählen", "showLess": "Weniger anzeigen", "showAllIssues": "Alle Probleme anzeigen ({count})", "showAllFailures": "Alle fehlgeschlagenen Zeilen anzeigen ({count})", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index c9808a3a..9ef3a124 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -506,7 +506,7 @@ "publicProfileNotFound": "This public profile link is no longer valid.", "publicProfileLoginRequired": "This profile is only visible to logged in users. Please log in to view it.", "importMalformedEvent": "Received malformed server event during import.", - "importUnsupportedContentType": "Unsupported upload content type. Use CSV or JSON files.", + "importUnsupportedContentType": "Unsupported upload content type. Use CSV, JSON, or Excel (.xlsx) files.", "emailAlreadyRegistered": "This email address is already registered.", "userNotFound": "User not found.", "cannotChangeOwnRole": "You cannot change your own admin role.", @@ -520,7 +520,9 @@ "importMappingNameConflict": "A mapping with this name already exists.", "importMappingNotFound": "Import mapping not found.", "importFileNotFound": "Import file not found. Please upload the file again.", - "importInvalidDelimiter": "CSV delimiter must be a single character." + "importInvalidDelimiter": "CSV delimiter must be a single character.", + "importUnsupportedFileType": "Unsupported file type. Use a CSV, JSON, or Excel (.xlsx) file.", + "importInvalidXlsxFile": "Could not read the Excel file. Make sure it is a valid .xlsx workbook." }, "oidc": { "orContinueWith": "or continue with", @@ -578,7 +580,7 @@ }, "dataManagement": { "title": "Manage my data", - "description": "Export your library or import books from a CSV/JSON file.", + "description": "Export your library or import books from a CSV, JSON, or Excel file.", "link": "Import / Export", "missingCoversDescription": "Quickly assign missing covers with auto-suggestions.", "missingCoversLink": "Manage Missing Covers" @@ -723,11 +725,12 @@ }, "import": { "title": "Import", - "description": "Upload one CSV or JSON file, map fields, validate, then import.", + "description": "Upload one CSV, JSON, or Excel (.xlsx) file, map fields, validate, then import.", "delimiterLabel": "CSV delimiter", "parse": "Parse file", "parsing": "Parsing...", "fileSummary": "Rows: {rows}, fields: {fields}", + "sheetLabel": "Sheet: {sheet}", "mappingTitle": "Field mapping", "mappingActionsTitle": "Manage mappings", "mappingName": "Mapping name", @@ -785,9 +788,9 @@ "confirmImportTitle": "Start import?", "confirmDestructive": "This writes data to your library and cannot be auto-undone.", "deleteMappingConfirm": "Delete this saved mapping?", - "dropzone": "Drag & drop a CSV/JSON file, or", + "dropzone": "Drag & drop a CSV, JSON, or Excel file, or", "browse": "browse", - "fileInputLabel": "Choose CSV or JSON file", + "fileInputLabel": "Choose CSV, JSON, or Excel file", "showLess": "Show less", "showAllIssues": "Show all issues ({count})", "showAllFailures": "Show all failed rows ({count})", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index c05a57b6..f538a330 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -506,7 +506,7 @@ "publicProfileNotFound": "Este enlace de perfil público ya no es válido.", "publicProfileLoginRequired": "Este perfil solo es visible para los usuarios con sesión iniciada. Inicia sesión para verlo.", "importMalformedEvent": "Se recibió un evento de servidor malformado durante la importación.", - "importUnsupportedContentType": "Tipo de contenido no admitido. Usa archivos CSV o JSON.", + "importUnsupportedContentType": "Tipo de contenido no admitido. Usa archivos CSV, JSON o Excel (.xlsx).", "emailAlreadyRegistered": "Esta dirección de correo ya está registrada.", "userNotFound": "Usuario no encontrado.", "cannotChangeOwnRole": "No puedes cambiar tu propio rol de administrador.", @@ -520,7 +520,9 @@ "importMappingNameConflict": "Ya existe una asignación con este nombre.", "importMappingNotFound": "Asignación de importación no encontrada.", "importFileNotFound": "Archivo de importación no encontrado. Vuelve a subir el archivo.", - "importInvalidDelimiter": "El delimitador CSV debe ser un solo carácter." + "importInvalidDelimiter": "El delimitador CSV debe ser un solo carácter.", + "importUnsupportedFileType": "Tipo de archivo no admitido. Usa un archivo CSV, JSON o Excel (.xlsx).", + "importInvalidXlsxFile": "No se pudo leer el archivo de Excel. Asegúrate de que sea un libro .xlsx válido." }, "oidc": { "orContinueWith": "o continuar con", @@ -578,7 +580,7 @@ }, "dataManagement": { "title": "Gestionar mis datos", - "description": "Exporta tu biblioteca o importa libros desde un archivo CSV/JSON.", + "description": "Exporta tu biblioteca o importa libros desde un archivo CSV, JSON o Excel.", "link": "Importar / Exportar", "missingCoversDescription": "Asigna rápidamente portadas faltantes con sugerencias automáticas.", "missingCoversLink": "Gestionar portadas faltantes" @@ -723,7 +725,8 @@ }, "import": { "title": "Importar", - "description": "Sube un archivo CSV o JSON, asigna campos, valida y luego importa.", + "description": "Sube un archivo CSV, JSON o Excel (.xlsx), asigna campos, valida y luego importa.", + "sheetLabel": "Hoja: {sheet}", "parse": "Analizar archivo", "parsing": "Analizando...", "fileSummary": "Filas: {rows}, campos: {fields}", @@ -783,9 +786,9 @@ "confirmImportTitle": "¿Iniciar importación?", "confirmDestructive": "Esto escribe datos en tu biblioteca y no se puede deshacer automáticamente.", "deleteMappingConfirm": "¿Eliminar esta asignación guardada?", - "dropzone": "Arrastra y suelta un archivo CSV/JSON, o", + "dropzone": "Arrastra y suelta un archivo CSV, JSON o Excel, o", "browse": "examinar", - "fileInputLabel": "Elegir archivo CSV o JSON", + "fileInputLabel": "Elegir archivo CSV, JSON o Excel", "showLess": "Mostrar menos", "showAllIssues": "Mostrar todos los problemas ({count})", "showAllFailures": "Mostrar todas las filas fallidas ({count})", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index f8e60e50..060e218f 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -506,7 +506,7 @@ "publicProfileNotFound": "Ce lien de profil public n'est plus valide.", "publicProfileLoginRequired": "Ce profil n'est visible que par les utilisateurs connectés. Connecte-toi pour le voir.", "importMalformedEvent": "Événement serveur malformé reçu lors de l'importation.", - "importUnsupportedContentType": "Type de contenu non pris en charge. Utilise des fichiers CSV ou JSON.", + "importUnsupportedContentType": "Type de contenu non pris en charge. Utilise des fichiers CSV, JSON ou Excel (.xlsx).", "emailAlreadyRegistered": "Cette adresse e-mail est déjà enregistrée.", "userNotFound": "Utilisateur introuvable.", "cannotChangeOwnRole": "Tu ne peux pas modifier ton propre rôle d'administrateur.", @@ -520,7 +520,9 @@ "importMappingNameConflict": "Un mappage avec ce nom existe déjà.", "importMappingNotFound": "Mappage d'importation introuvable.", "importFileNotFound": "Fichier d'importation introuvable. Veuillez téléverser le fichier à nouveau.", - "importInvalidDelimiter": "Le séparateur CSV doit être un seul caractère." + "importInvalidDelimiter": "Le séparateur CSV doit être un seul caractère.", + "importUnsupportedFileType": "Type de fichier non pris en charge. Utilise un fichier CSV, JSON ou Excel (.xlsx).", + "importInvalidXlsxFile": "Impossible de lire le fichier Excel. Assure-toi qu'il s'agit d'un classeur .xlsx valide." }, "oidc": { "orContinueWith": "ou continuer avec", @@ -578,7 +580,7 @@ }, "dataManagement": { "title": "Gérer mes données", - "description": "Exporte ta bibliothèque ou importe des livres depuis un fichier CSV/JSON.", + "description": "Exporte ta bibliothèque ou importe des livres depuis un fichier CSV, JSON ou Excel.", "link": "Importer / Exporter", "missingCoversDescription": "Attribue rapidement les couvertures manquantes avec des suggestions automatiques.", "missingCoversLink": "Gérer les couvertures manquantes" @@ -723,7 +725,8 @@ }, "import": { "title": "Importer", - "description": "Téléverse un fichier CSV ou JSON, mappe les champs, valide, puis importe.", + "description": "Téléverse un fichier CSV, JSON ou Excel (.xlsx), mappe les champs, valide, puis importe.", + "sheetLabel": "Feuille : {sheet}", "parse": "Analyser le fichier", "parsing": "Analyse...", "fileSummary": "Lignes : {rows}, champs : {fields}", @@ -783,9 +786,9 @@ "confirmImportTitle": "Lancer l'importation ?", "confirmDestructive": "Cela écrit des données dans ta bibliothèque et ne peut pas être annulé automatiquement.", "deleteMappingConfirm": "Supprimer ce mappage enregistré ?", - "dropzone": "Glisse et dépose un fichier CSV/JSON, ou", + "dropzone": "Glisse et dépose un fichier CSV, JSON ou Excel, ou", "browse": "parcourir", - "fileInputLabel": "Choisir un fichier CSV ou JSON", + "fileInputLabel": "Choisir un fichier CSV, JSON ou Excel", "showLess": "Afficher moins", "showAllIssues": "Afficher tous les problèmes ({count})", "showAllFailures": "Afficher toutes les lignes échouées ({count})", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index e8142d2b..f65f73a4 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -506,7 +506,7 @@ "publicProfileNotFound": "此公开资料链接已失效。", "publicProfileLoginRequired": "此个人资料仅对已登录用户可见。请登录后查看。", "importMalformedEvent": "导入期间收到格式错误的服务器事件。", - "importUnsupportedContentType": "不支持的上传内容类型。请使用 CSV 或 JSON 文件。", + "importUnsupportedContentType": "不支持的上传内容类型。请使用 CSV、JSON 或 Excel(.xlsx)文件。", "emailAlreadyRegistered": "此邮箱地址已注册。", "userNotFound": "未找到用户。", "cannotChangeOwnRole": "你不能更改自己的管理员角色。", @@ -520,7 +520,9 @@ "importMappingNameConflict": "同名映射已存在。", "importMappingNotFound": "未找到导入映射。", "importFileNotFound": "未找到导入文件。请重新上传文件。", - "importInvalidDelimiter": "CSV 分隔符必须是单个字符。" + "importInvalidDelimiter": "CSV 分隔符必须是单个字符。", + "importUnsupportedFileType": "不支持的文件类型。请使用 CSV、JSON 或 Excel(.xlsx)文件。", + "importInvalidXlsxFile": "无法读取 Excel 文件。请确认它是有效的 .xlsx 工作簿。" }, "oidc": { "orContinueWith": "或继续使用", @@ -578,7 +580,7 @@ }, "dataManagement": { "title": "管理我的数据", - "description": "导出你的书库或从 CSV/JSON 文件导入图书。", + "description": "导出你的书库,或从 CSV、JSON 或 Excel 文件导入图书。", "link": "导入 / 导出", "missingCoversDescription": "使用自动建议快速分配缺失封面。", "missingCoversLink": "管理缺失封面" @@ -723,7 +725,8 @@ }, "import": { "title": "导入", - "description": "上传 CSV 或 JSON 文件,映射字段,验证,然后导入。", + "description": "上传 CSV、JSON 或 Excel(.xlsx)文件,映射字段,验证,然后导入。", + "sheetLabel": "工作表:{sheet}", "parse": "解析文件", "parsing": "解析中...", "fileSummary": "行数:{rows},字段:{fields}", @@ -783,9 +786,9 @@ "confirmImportTitle": "开始导入?", "confirmDestructive": "这将向你的书库写入数据,无法自动撤消。", "deleteMappingConfirm": "删除此已保存的映射?", - "dropzone": "拖放 CSV/JSON 文件,或", + "dropzone": "拖放 CSV、JSON 或 Excel 文件,或", "browse": "浏览", - "fileInputLabel": "选择 CSV 或 JSON 文件", + "fileInputLabel": "选择 CSV、JSON 或 Excel 文件", "showLess": "收起", "showAllIssues": "显示所有问题 ({count})", "showAllFailures": "显示所有失败行 ({count})", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 55f98d00..16903d78 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -377,10 +377,11 @@ export type DataExportFormat = 'csv' | 'json'; export interface DataImportParseResponse { file_id: string; - format: 'csv' | 'json'; + format: 'csv' | 'json' | 'xlsx'; source_fields: string[]; sample_rows: Record[]; row_count: number; + sheet?: string | null; } export interface DataImportMappingListItem { diff --git a/uv.lock b/uv.lock index 3ca62617..cb742d8d 100644 --- a/uv.lock +++ b/uv.lock @@ -412,6 +412,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/0c/77b89a0efe4a23a9b370f049ad4988a57232c2eb82b3577a6e1d8fe0e597/curl_cffi-0.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6bc754ad2dcb5287a157f34d615dde442e80e688289caedc953be8d92fe3f50b", size = 1779425, upload-time = "2026-08-25T11:51:10.4Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -434,6 +443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -671,10 +689,12 @@ dependencies = [ { name = "cachetools" }, { name = "cryptography" }, { name = "curl-cffi" }, + { name = "defusedxml" }, { name = "fastapi" }, { name = "fastapi-mail" }, { name = "httpx" }, { name = "itsdangerous" }, + { name = "openpyxl" }, { name = "passlib", extra = ["bcrypt"] }, { name = "playwright" }, { name = "pycountry" }, @@ -703,10 +723,12 @@ requires-dist = [ { name = "cachetools", specifier = ">=7.1.7" }, { name = "cryptography", specifier = ">=50.0.0" }, { name = "curl-cffi", specifier = ">=0.16.1" }, + { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "fastapi-mail", specifier = ">=1.6.8" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "playwright", specifier = ">=1.62.0" }, { name = "pycountry", specifier = ">=24.6.1" }, @@ -893,6 +915,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "orjson" version = "3.12.0"