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
-
{$_('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