diff --git a/.gitignore b/.gitignore index 87c957de..ab13a837 100644 --- a/.gitignore +++ b/.gitignore @@ -219,6 +219,7 @@ __marimo__/ /ideas.txt /backend/data/ +/backend/librislog.db /data/ /data-e2e/ /backend/data/ @@ -235,4 +236,4 @@ node_modules/ /.playwright-mcp /.sverklo .plan/ -/.opencode \ No newline at end of file +/.opencodebackend/librislog.db 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/alembic/versions/0a1b2c3d4e5f_normalize_statistics_ranges.py b/backend/alembic/versions/0a1b2c3d4e5f_normalize_statistics_ranges.py new file mode 100644 index 00000000..2298c45a --- /dev/null +++ b/backend/alembic/versions/0a1b2c3d4e5f_normalize_statistics_ranges.py @@ -0,0 +1,46 @@ +"""normalize statistics range settings + +Revision ID: 0a1b2c3d4e5f +Revises: d3e4f5a6b7c8 +Create Date: 2026-09-13 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "0a1b2c3d4e5f" +down_revision: Union[str, Sequence[str], None] = "d3e4f5a6b7c8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # The old rolling ranges have no direct equivalent in the calendar-based selector. + op.execute( + sa.text( + "UPDATE usersettings " + "SET statistics_range = 'alltime' " + "WHERE statistics_range IN ('6months', '30days')" + ) + ) + op.execute( + sa.text( + "UPDATE usersettings " + "SET statistics_range = 'this_year' " + "WHERE statistics_range = '1year'" + ) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + "UPDATE usersettings " + "SET statistics_range = '1year' " + "WHERE statistics_range = 'this_year'" + ) + ) diff --git a/backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py b/backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py new file mode 100644 index 00000000..e3f0976e --- /dev/null +++ b/backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py @@ -0,0 +1,29 @@ +"""add optional medium to books + +Revision ID: 7a8b9c0d1e2f +Revises: f3a5b7c9d1e2 +Create Date: 2026-09-08 23:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "7a8b9c0d1e2f" +down_revision: Union[str, Sequence[str], None] = "f3a5b7c9d1e2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("book") as batch_op: + batch_op.add_column(sa.Column("medium", sa.String(length=32), nullable=True)) + batch_op.create_index("ix_book_medium", ["medium"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("book") as batch_op: + batch_op.drop_index("ix_book_medium") + batch_op.drop_column("medium") diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py b/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py new file mode 100644 index 00000000..5e8c3584 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py @@ -0,0 +1,29 @@ +"""add raw token to public_profile_link + +Revision ID: b2c3d4e5f6a7 +Revises: c9a4b7d8e3f1 +Create Date: 2026-09-10 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, Sequence[str], None] = "c9a4b7d8e3f1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.add_column(sa.Column("token", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.drop_column("token") diff --git a/backend/alembic/versions/c9a4b7d8e3f1_add_public_profile_link_table.py b/backend/alembic/versions/c9a4b7d8e3f1_add_public_profile_link_table.py new file mode 100644 index 00000000..8b2c3a74 --- /dev/null +++ b/backend/alembic/versions/c9a4b7d8e3f1_add_public_profile_link_table.py @@ -0,0 +1,48 @@ +"""add public_profile_link table for shareable public profiles + +Revision ID: c9a4b7d8e3f1 +Revises: 7a8b9c0d1e2f +Create Date: 2026-09-09 23:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c9a4b7d8e3f1" +down_revision: Union[str, Sequence[str], None] = "7a8b9c0d1e2f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create the public_profile_link table.""" + op.create_table( + "public_profile_link", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("token_prefix", sa.String(length=255), nullable=False), + sa.Column("token_hash", sa.String(length=255), nullable=False), + sa.Column("audience", sa.String(length=32), nullable=False), + sa.Column("visibility_config_json", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_public_profile_link_user_id", "public_profile_link", ["user_id"], unique=False) + op.create_index("ix_public_profile_link_token_prefix", "public_profile_link", ["token_prefix"], unique=False) + op.create_index("ix_public_profile_link_token_hash", "public_profile_link", ["token_hash"], unique=True) + + +def downgrade() -> None: + """Drop the public_profile_link table.""" + op.drop_index("ix_public_profile_link_token_hash", table_name="public_profile_link") + op.drop_index("ix_public_profile_link_token_prefix", table_name="public_profile_link") + op.drop_index("ix_public_profile_link_user_id", table_name="public_profile_link") + op.drop_table("public_profile_link") \ No newline at end of file diff --git a/backend/alembic/versions/d3e4f5a6b7c8_add_language_to_public_profile_link.py b/backend/alembic/versions/d3e4f5a6b7c8_add_language_to_public_profile_link.py new file mode 100644 index 00000000..83c54eb0 --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_add_language_to_public_profile_link.py @@ -0,0 +1,29 @@ +"""add language to public_profile_link + +Revision ID: d3e4f5a6b7c8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-09-10 13:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, Sequence[str], None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.add_column(sa.Column("language", sa.String(length=10), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.drop_column("language") diff --git a/backend/alembic/versions/d7e8f9a0b1c2_add_reading_date_automation_to_usersettings.py b/backend/alembic/versions/d7e8f9a0b1c2_add_reading_date_automation_to_usersettings.py new file mode 100644 index 00000000..1ad11b3d --- /dev/null +++ b/backend/alembic/versions/d7e8f9a0b1c2_add_reading_date_automation_to_usersettings.py @@ -0,0 +1,33 @@ +"""add reading date automation settings + +Revision ID: d7e8f9a0b1c2 +Revises: 0a1b2c3d4e5f +Create Date: 2026-09-14 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "d7e8f9a0b1c2" +down_revision: Union[str, Sequence[str], None] = "0a1b2c3d4e5f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "usersettings", + sa.Column("auto_set_date_started", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "usersettings", + sa.Column("auto_set_date_finished", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + + +def downgrade() -> None: + op.drop_column("usersettings", "auto_set_date_finished") + op.drop_column("usersettings", "auto_set_date_started") diff --git a/backend/alembic/versions/f3a5b7c9d1e2_add_statistics_range_to_usersettings.py b/backend/alembic/versions/f3a5b7c9d1e2_add_statistics_range_to_usersettings.py new file mode 100644 index 00000000..92075b4a --- /dev/null +++ b/backend/alembic/versions/f3a5b7c9d1e2_add_statistics_range_to_usersettings.py @@ -0,0 +1,30 @@ +"""add statistics range to usersettings + +Revision ID: f3a5b7c9d1e2 +Revises: c3d4e5f6a7b8 +Create Date: 2026-09-08 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f3a5b7c9d1e2' +down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('usersettings', sa.Column('statistics_range', sa.String(length=20), nullable=False, server_default='alltime')) + op.add_column('usersettings', sa.Column('statistics_custom_from', sa.Date(), nullable=True)) + op.add_column('usersettings', sa.Column('statistics_custom_to', sa.Date(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('usersettings', 'statistics_custom_to') + op.drop_column('usersettings', 'statistics_custom_from') + op.drop_column('usersettings', 'statistics_range') diff --git a/backend/app/auth.py b/backend/app/auth.py index a623e78b..05d44258 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -142,6 +142,30 @@ def get_embed_token_prefix(token: str) -> str: return token[:12] +# --- Public profile share-link token utilities --- + +PUBLIC_PROFILE_TOKEN_PREFIX = "lp_" + + +def generate_public_profile_token() -> str: + """Generate a new random public profile token prefixed with 'lp_'.""" + return f"{PUBLIC_PROFILE_TOKEN_PREFIX}{secrets.token_urlsafe(32)}" + + +def hash_public_profile_token(value: str) -> str: + """Return a HMAC-SHA256 hex digest of a public profile token.""" + return hmac.new( + settings.api_key_encryption_key.encode("utf-8"), + value.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def get_public_profile_token_prefix(token: str) -> str: + """Return the first 12 characters of the public profile token (visible prefix).""" + return token[:12] + + # --- Password reset token utilities --- _password_reset_serializer = URLSafeTimedSerializer( diff --git a/backend/app/i18n/de.json b/backend/app/i18n/de.json index 30ef7d52..d97b8edc 100644 --- a/backend/app/i18n/de.json +++ b/backend/app/i18n/de.json @@ -9,6 +9,19 @@ "avg_pages": "Seiten/Buch" } }, + "medium": { + "print": "Print", + "ebook": "eBook", + "audiobook": "Hörbuch", + "comic_graphic_novel": "Comic / Graphic Novel", + "magazine_newspaper": "Magazin / Zeitung" + }, + "acquisition": { + "owned": "Im Besitz", + "borrowed": "Geliehen", + "digital_access": "Digital verfügbar", + "to_acquire": "Muss noch beschafft werden" + }, "email": { "passwordResetSubject": "Passwort zurücksetzen – LibrisLog", "passwordResetBody": "\n\n

Du hast das Zurücksetzen deines Passworts für dein LibrisLog-Konto beantragt.

\n

Klicke auf den Link unten, um dein Passwort zurückzusetzen. Dieser Link ist {duration_minutes} Minuten gültig.

\n

{reset_url}

\n

Falls du dies nicht angefordert hast, ignoriere bitte diese E-Mail.

\n\n" diff --git a/backend/app/i18n/en.json b/backend/app/i18n/en.json index 2bf70410..1cd122fa 100644 --- a/backend/app/i18n/en.json +++ b/backend/app/i18n/en.json @@ -9,6 +9,19 @@ "avg_pages": "Avg/Book" } }, + "medium": { + "print": "Print", + "ebook": "eBook", + "audiobook": "Audiobook", + "comic_graphic_novel": "Comic / Graphic Novel", + "magazine_newspaper": "Magazine / Newspaper" + }, + "acquisition": { + "owned": "Owned", + "borrowed": "Borrowed", + "digital_access": "Digital access", + "to_acquire": "Needs to be acquired" + }, "email": { "passwordResetSubject": "Password Reset – LibrisLog", "passwordResetBody": "\n\n

You have requested a password reset for your LibrisLog account.

\n

Click the link below to reset your password. This link is valid for {duration_minutes} minutes.

\n

{reset_url}

\n

If you did not request this, please ignore this email.

\n\n" diff --git a/backend/app/i18n/es.json b/backend/app/i18n/es.json index edc47b8e..ae54c1e1 100644 --- a/backend/app/i18n/es.json +++ b/backend/app/i18n/es.json @@ -9,6 +9,19 @@ "avg_pages": "Páginas/Libro" } }, + "medium": { + "print": "Impreso", + "ebook": "eBook", + "audiobook": "Audiolibro", + "comic_graphic_novel": "Cómic / Novela gráfica", + "magazine_newspaper": "Revista / Periódico" + }, + "acquisition": { + "owned": "En propiedad", + "borrowed": "Prestado", + "digital_access": "Acceso digital", + "to_acquire": "Por adquirir" + }, "email": { "passwordResetSubject": "Restablecer contraseña – LibrisLog", "passwordResetBody": "\n\n

Has solicitado un restablecimiento de contraseña para tu cuenta de LibrisLog.

\n

Haz clic en el enlace de abajo para restablecer tu contraseña. Este enlace es válido por {duration_minutes} minutos.

\n

{reset_url}

\n

Si no solicitaste esto, ignora este correo electrónico.

\n\n" diff --git a/backend/app/i18n/fr.json b/backend/app/i18n/fr.json index d1bdee09..d14d310a 100644 --- a/backend/app/i18n/fr.json +++ b/backend/app/i18n/fr.json @@ -9,6 +9,19 @@ "avg_pages": "Pages/Livre" } }, + "medium": { + "print": "Imprimé", + "ebook": "eBook", + "audiobook": "Livre audio", + "comic_graphic_novel": "Bande dessinée / Roman graphique", + "magazine_newspaper": "Magazine / Journal" + }, + "acquisition": { + "owned": "Possédé", + "borrowed": "Emprunté", + "digital_access": "Accès numérique", + "to_acquire": "À acquérir" + }, "email": { "passwordResetSubject": "Réinitialisation du mot de passe – LibrisLog", "passwordResetBody": "\n\n

Vous avez demandé une réinitialisation de mot de passe pour votre compte LibrisLog.

\n

Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe. Ce lien est valable {duration_minutes} minutes.

\n

{reset_url}

\n

Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.

\n\n" diff --git a/backend/app/i18n/zh.json b/backend/app/i18n/zh.json index 1ade7c15..bfc94c67 100644 --- a/backend/app/i18n/zh.json +++ b/backend/app/i18n/zh.json @@ -9,6 +9,19 @@ "avg_pages": "每本页数" } }, + "medium": { + "print": "纸质书", + "ebook": "电子书", + "audiobook": "有声书", + "comic_graphic_novel": "漫画 / 图像小说", + "magazine_newspaper": "杂志 / 报纸" + }, + "acquisition": { + "owned": "已拥有", + "borrowed": "借阅", + "digital_access": "数字版可用", + "to_acquire": "待获取" + }, "email": { "passwordResetSubject": "密码重置 – LibrisLog", "passwordResetBody": "\n\n

您已请求重置 LibrisLog 帐户的密码。

\n

点击下面的链接重置您的密码。此链接有效期为 {duration_minutes} 分钟。

\n

{reset_url}

\n

如果您没有请求此操作,请忽略此邮件。

\n\n" diff --git a/backend/app/main.py b/backend/app/main.py index fa734698..432219eb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,12 +13,12 @@ from app._build_info import __git_sha__, __version__ from app.config import settings from app.logging_config import configure_logging -from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, statistics, users +from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, share_links, statistics, users from app.services.cover_storage import cleanup_orphan_covers from app.services.data_import import cleanup_temp_files from app.services.telemetry import send_telemetry_once -_TELEMETRY_INTERVAL_SECONDS = 24 * 3600 +_TELEMETRY_INTERVAL_SECONDS = 23 * 3600 logger = logging.getLogger(__name__) @@ -189,6 +189,8 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: app.include_router(auth.router) app.include_router(users.router) app.include_router(profile.router) +app.include_router(share_links.router) +app.include_router(public_profile.router) app.include_router(oidc.router) app.include_router(progress.router) app.include_router(docs.router) diff --git a/backend/app/models.py b/backend/app/models.py index 67d2d656..88e5db2d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,8 +1,9 @@ """SQLModel ORM models for LibrisLog database tables.""" from enum import Enum +import re from typing import Optional -from datetime import datetime, timezone +from datetime import date, datetime, timezone import sqlalchemy as sa from pydantic import model_validator @@ -53,6 +54,33 @@ class AcquisitionStatus(str, Enum): to_acquire = "to_acquire" +def normalize_medium_key(value: str) -> str: + """Normalize a medium display value or enum key for comparisons.""" + return re.sub(r"[\s/]+", "_", value.strip().lower()) + + +class Medium(str, Enum): + """Enum of a book's physical or digital medium format.""" + + print = "Print" + ebook = "eBook" + audiobook = "Audiobook" + comic_graphic_novel = "Comic / Graphic Novel" + magazine_newspaper = "Magazine / Newspaper" + + @classmethod + def _missing_(cls, value: object) -> "Medium | None": + """Accept enum keys and normalized display values at API boundaries.""" + if not isinstance(value, str): + return None + normalized = normalize_medium_key(value) + for member in cls: + member_value = normalize_medium_key(member.value) + if normalized in {member.name, member_value}: + return member + return None + + class UserRole(str, Enum): """Enum of possible user roles.""" @@ -88,6 +116,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict: rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: ReadingStatus = Field(default=ReadingStatus.want_to_read, index=True) acquisition_status: AcquisitionStatus = Field(default=AcquisitionStatus.owned, index=True) + medium: Optional[Medium] = Field(default=None, index=True) user_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True) date_added: datetime = Field( default_factory=utcnow, @@ -192,6 +221,11 @@ class UserSettings(SQLModel, table=True): goal_books_per_year_enabled: bool = Field(default=False) goal_books_per_year: int = Field(default=25, ge=1) gamification_enabled: bool = Field(default=True) + auto_set_date_started: bool = Field(default=True) + auto_set_date_finished: bool = Field(default=True) + statistics_range: str = Field(default="alltime", max_length=20) + statistics_custom_from: Optional[date] = Field(default=None) + statistics_custom_to: Optional[date] = Field(default=None) class ApiKey(SQLModel, table=True): @@ -281,6 +315,44 @@ class EmbedToken(SQLModel, table=True): ) +class PublicProfileAudience(str, Enum): + """Who may access a public profile share link.""" + + public = "public" # everyone, including anonymous viewers + authenticated = "authenticated" # logged-in users only + + +class PublicProfileLink(SQLModel, table=True): + """A shareable public profile link owned by a user.""" + + __tablename__: str = "public_profile_link" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", index=True) + name: str = Field(max_length=255) + token_prefix: str = Field(index=True) + token: Optional[str] = Field(default=None, nullable=True) + token_hash: str = Field(index=True, unique=True) + audience: PublicProfileAudience = Field(default=PublicProfileAudience.public) + language: Optional[str] = Field(default=None, nullable=True) + visibility_config_json: str = Field( + default="{}", + sa_column=Column(sa.Text, default="{}"), + ) + expires_at: Optional[datetime] = Field( + default=None, + sa_column=Column(UtcDateTime, default=None), + ) + created_at: datetime = Field( + default_factory=utcnow, + sa_column=Column(UtcDateTime, default=utcnow), + ) + revoked_at: Optional[datetime] = Field( + default=None, + sa_column=Column(UtcDateTime, default=None), + ) + + class ImportMapping(SQLModel, table=True): """A saved column-mapping configuration for data import.""" diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index aceadb64..f311813c 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -12,7 +12,7 @@ from app.auth import require_user from app.config import settings from app.database import get_session -from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, ReadingProgress, ReadingStatus, Tag, User +from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, ReadingProgress, ReadingStatus, Tag, User, UserSettings from app.schemas import ( BookCreate, BookListResponse, @@ -60,23 +60,36 @@ def _utcnow() -> datetime: return utcnow() +def _reading_date_automation_settings(session: Session, user_id: int) -> tuple[bool, bool]: + """Return start/finish date automation preferences, defaulting to enabled.""" + settings = session.exec( + select(UserSettings).where(UserSettings.user_id == user_id) + ).first() + if settings is None: + return True, True + return settings.auto_set_date_started, settings.auto_set_date_finished + + def _apply_status_transition_dates( book: Book, target_status: ReadingStatus, update_data: dict, skip_auto_date_started: bool = False, + *, + auto_set_date_started: bool = True, + auto_set_date_finished: bool = True, ) -> None: """Auto-fill date_started / date_finished when transitioning to a new status.""" if target_status == book.reading_status: return - if target_status == ReadingStatus.currently_reading: + if target_status == ReadingStatus.currently_reading and auto_set_date_started: if skip_auto_date_started: update_data.setdefault("date_started", None) elif book.date_started is None and update_data.get("date_started") is None: update_data["date_started"] = _utcnow() - if target_status in (ReadingStatus.read, ReadingStatus.did_not_finish): + if target_status in (ReadingStatus.read, ReadingStatus.did_not_finish) and auto_set_date_finished: if update_data.get("date_finished") is None: update_data["date_finished"] = _utcnow() @@ -105,6 +118,7 @@ def _validate_date_finished_for_read( book: Book, update_data: dict, target_status: ReadingStatus, + auto_set_date_finished: bool = True, ) -> None: """Ensure date_finished is not explicitly cleared while the book is read.""" if "date_finished" not in update_data: @@ -113,7 +127,11 @@ def _validate_date_finished_for_read( return if book.date_finished is None: return - if book.reading_status == ReadingStatus.read and target_status == ReadingStatus.read: + if ( + auto_set_date_finished + and book.reading_status == ReadingStatus.read + and target_status == ReadingStatus.read + ): raise HTTPException(status_code=422, detail="A finished book must have an end date. Change the status if you want to remove the finish date.") @@ -152,11 +170,12 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None, authors: list[ def list_books( status: Optional[ReadingStatus] = Query(default=None), acquisition_status: Optional[AcquisitionStatus] = Query(default=None), + medium: Optional[Medium] = Query(default=None), q: Optional[str] = Query( default=None, description=( "Search phrase. Use : to restrict a term to a single field " - "(author, publisher, title, tag, language, possession, notes, description). " + "(author, publisher, title, tag, language, possession, medium, notes, description). " "Wrap multi-word values in double quotes (e.g. author:\"Marlen Haushofer\") and " "prefix any term with - to negate it (e.g. tag:cars -tag:audi)." ), @@ -179,8 +198,8 @@ def list_books( read → date_finished, did_not_finish → date_started (all descending). """ logger.debug( - "list_books — status=%r q=%r sort=%s order=%s smart_sort=%s", - status, q, sort, order, smart_sort, + "list_books — status=%r acquisition=%r medium=%r q=%r sort=%s order=%s smart_sort=%s", + status, acquisition_status, medium, q, sort, order, smart_sort, ) base_statement = select(Book).where(Book.user_id == current_user.id) @@ -190,6 +209,9 @@ def list_books( if acquisition_status is not None: base_statement = base_statement.where(Book.acquisition_status == acquisition_status) + if medium is not None: + base_statement = base_statement.where(Book.medium == medium) + if q: assert current_user.id is not None base_statement = apply_search_filter(base_statement, q, current_user.id) @@ -238,11 +260,14 @@ def list_books( book_ids = [b.id for b in books if b.id is not None] book_tags_map = load_tags_batch(session, book_ids) if book_ids else {} book_authors_map = load_authors_batch(session, book_ids) if book_ids else {} - return BookListResponse( - books=[ + book_reads: list[BookRead] = [] + for book in books: + assert book.id is not None + book_reads.append( _build_book_read_with_tags(book, book_tags_map.get(book.id), book_authors_map.get(book.id)) - for book in books - ], + ) + return BookListResponse( + books=book_reads, total=total, ) @@ -507,6 +532,9 @@ async def update_book( ) authors_provided = authors_payload is not None target_status = update_data.get("reading_status", book.reading_status) + auto_set_date_started, auto_set_date_finished = _reading_date_automation_settings( + session, current_user.id + ) # Download external cover URL -> local file. if "cover_url" in update_data and is_external_cover_url(update_data["cover_url"]): @@ -536,9 +564,20 @@ async def update_book( if not shared: delete_cover_file(old_filename, settings.covers_dir) - _apply_status_transition_dates(book, target_status, update_data) + _apply_status_transition_dates( + book, + target_status, + update_data, + auto_set_date_started=auto_set_date_started, + auto_set_date_finished=auto_set_date_finished, + ) _validate_dates(update_data) - _validate_date_finished_for_read(book, update_data, target_status) + _validate_date_finished_for_read( + book, + update_data, + target_status, + auto_set_date_finished=auto_set_date_finished, + ) book.sqlmodel_update(update_data) session.add(book) @@ -573,6 +612,7 @@ def transition_status( session: Session = Depends(get_session), ) -> StatusTransitionResponse: """Change a book's reading status with date-conflict detection and resolution.""" + assert current_user.id is not None logger.debug( "transition_status — id=%s new_status=%s force_date_started=%r force_date_finished=%r", book_id, transition.new_status, transition.force_date_started, transition.force_date_finished, @@ -584,6 +624,9 @@ def transition_status( conflict: DateConflict | None = None update_data: dict = {"reading_status": transition.new_status} now = _utcnow() + auto_set_date_started, auto_set_date_finished = _reading_date_automation_settings( + session, current_user.id + ) # date_finished handling is split into two passes: # 1. Inline below — conflict detection when moving TO read/did_not_finish @@ -598,17 +641,17 @@ def transition_status( and book.date_started is not None and not transition.skip_auto_date_started ): - if transition.force_date_started is None: + if transition.force_date_started is None and auto_set_date_started: conflict = DateConflict( field="date_started", existing_date=book.date_started, suggested_date=now, ) return StatusTransitionResponse(book=build_book_read(session, book), date_conflict=conflict) - update_data["date_started"] = transition.force_date_started if ( - book.date_finished is not None + transition.force_date_started is not None + and book.date_finished is not None and transition.force_date_started > book.date_finished ): conflict = DateConflict( @@ -638,6 +681,7 @@ def transition_status( and book.date_started is None and book.date_finished is not None and transition.force_date_started is None + and auto_set_date_started and not transition.skip_auto_date_started ): conflict = DateConflict( @@ -677,9 +721,21 @@ def transition_status( if transition.force_date_finished is not None: update_data["date_finished"] = transition.force_date_finished - _apply_status_transition_dates(book, transition.new_status, update_data, transition.skip_auto_date_started) + _apply_status_transition_dates( + book, + transition.new_status, + update_data, + transition.skip_auto_date_started, + auto_set_date_started=auto_set_date_started, + auto_set_date_finished=auto_set_date_finished, + ) _validate_dates(update_data) - _validate_date_finished_for_read(book, update_data, transition.new_status) + _validate_date_finished_for_read( + book, + update_data, + transition.new_status, + auto_set_date_finished=auto_set_date_finished, + ) book.sqlmodel_update(update_data) session.add(book) session.commit() 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/routers/import_.py b/backend/app/routers/import_.py index acde920c..8df096cd 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -155,6 +155,7 @@ async def import_book( blurb=c.blurb, reading_status=body.reading_status, acquisition_status=body.acquisition_status, + medium=body.medium, user_id=current_user.id, ) session.add(book) diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index 00bf1c2f..4358f08d 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -32,10 +32,12 @@ EmbedTokenRead, EmbedTokenUpdate, ProfileUpdate, + StatisticsRange, UserRead, UserSettingsRead, UserSettingsUpdate, ) +from app.services.statistics import MAX_CUSTOM_RANGE_DAYS from app.time_utils import utcnow from app.services.user_deletion import ( assert_not_last_admin, @@ -98,6 +100,7 @@ def get_settings( session.add(settings) session.commit() session.refresh(settings) + assert settings.user_id is not None return UserSettingsRead( user_id=settings.user_id, language=settings.language, @@ -113,6 +116,11 @@ def get_settings( goal_books_per_year_enabled=settings.goal_books_per_year_enabled, goal_books_per_year=settings.goal_books_per_year, gamification_enabled=settings.gamification_enabled, + auto_set_date_started=settings.auto_set_date_started, + auto_set_date_finished=settings.auto_set_date_finished, + statistics_range=StatisticsRange(settings.statistics_range), + statistics_custom_from=settings.statistics_custom_from, + statistics_custom_to=settings.statistics_custom_to, ) @@ -130,12 +138,24 @@ def update_settings( if not settings: settings = UserSettings(user_id=current_user.id, language="en") update_data = body.model_dump(exclude_unset=True) + if "statistics_range" in update_data and update_data["statistics_range"] is None: + raise HTTPException(status_code=422, detail="statistics_range cannot be null") + custom_from = update_data.get("statistics_custom_from", settings.statistics_custom_from) + custom_to = update_data.get("statistics_custom_to", settings.statistics_custom_to) + statistics_range = update_data.get("statistics_range", settings.statistics_range) + if statistics_range == "custom" and (custom_from is None or custom_to is None): + raise HTTPException(status_code=422, detail="Custom range requires both dates") + if custom_from is not None and custom_to is not None and custom_from > custom_to: + raise HTTPException(status_code=422, detail="statistics_custom_from cannot be after statistics_custom_to") + if custom_from is not None and custom_to is not None and (custom_to - custom_from).days > MAX_CUSTOM_RANGE_DAYS: + raise HTTPException(status_code=422, detail="Statistics custom range cannot exceed 25 years") settings.sqlmodel_update(update_data) if settings.theme != 'custom': settings.custom_theme = None session.add(settings) session.commit() session.refresh(settings) + assert settings.user_id is not None return UserSettingsRead( user_id=settings.user_id, language=settings.language, @@ -151,6 +171,11 @@ def update_settings( goal_books_per_year_enabled=settings.goal_books_per_year_enabled, goal_books_per_year=settings.goal_books_per_year, gamification_enabled=settings.gamification_enabled, + auto_set_date_started=settings.auto_set_date_started, + auto_set_date_finished=settings.auto_set_date_finished, + statistics_range=StatisticsRange(settings.statistics_range), + statistics_custom_from=settings.statistics_custom_from, + statistics_custom_to=settings.statistics_custom_to, ) @@ -351,7 +376,7 @@ def rotate_embed_token( token.revoked_at = now session.add(token) - + assert current_user.id is not None plain_token = generate_embed_token() new_token = EmbedToken( user_id=current_user.id, diff --git a/backend/app/routers/public_profile.py b/backend/app/routers/public_profile.py new file mode 100644 index 00000000..86ca5c31 --- /dev/null +++ b/backend/app/routers/public_profile.py @@ -0,0 +1,143 @@ +"""Public (unauthenticated) profile data endpoint. + +Validates a share-link token, checks expiry and audience rules, and returns a +whitelisted view of the owner's profile. Private account data (email, API +keys, settings, notes, blurbs, OIDC info) is never serialized here. +""" + +import logging + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, Security +from fastapi.security import APIKeyHeader +from sqlmodel import Session, col, select + +from app.auth import hash_public_profile_token, require_user +from app.database import get_session +from app.models import PublicProfileAudience, PublicProfileLink, User +from app.schemas import ( + PublicProfileBook, + PublicProfileResponse, + PublicProfileSectionKey, + PublicProfileUserInfo, + StatisticsRange, +) +from app.services.public_profile import ( + BOOK_SECTIONS, + build_public_books, + filter_statistics, + load_owner_books, + parse_visibility_config, +) +from app.services.statistics import compute_statistics +from app.time_utils import utcnow + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/public-profiles", tags=["public-profile"]) + +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +def get_optional_user( + request: Request, + x_api_key: str | None = Security(api_key_header), + x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"), + session: Session = Depends(get_session), +) -> User | None: + """Resolve the viewer if a session or API key is present, else None. + + This is intentionally non-fatal: public links marked ``public`` must be + viewable by anonymous visitors. Invalid credentials degrade to anonymous. + """ + if not x_api_key and request.session.get("user_id") is None: + return None + try: + return require_user( + request=request, + x_api_key=x_api_key, + x_csrf_token=x_csrf_token, + session=session, + ) + except HTTPException: + return None + + +@router.get("/{token}", response_model=PublicProfileResponse) +def get_public_profile( + token: str, + response: Response, + viewer: User | None = Depends(get_optional_user), + session: Session = Depends(get_session), +) -> PublicProfileResponse: + """Return the whitelisted public profile for a share-link token. + + Invalid, expired, or revoked tokens all yield HTTP 404 so that link + existence cannot be probed. Tokens restricted to logged-in users yield + HTTP 401 for anonymous viewers. + """ + _with_security_headers(response) + link = session.exec( + select(PublicProfileLink).where( + PublicProfileLink.token_hash == hash_public_profile_token(token), + col(PublicProfileLink.revoked_at).is_(None), + ) + ).first() + + if not link: + raise HTTPException(status_code=404, detail="Public profile not found") + + now = utcnow() + if link.expires_at is not None and link.expires_at < now: + logger.debug("Public profile link expired: id=%s", link.id) + raise HTTPException(status_code=404, detail="Public profile not found") + + if link.audience == PublicProfileAudience.authenticated and viewer is None: + raise HTTPException(status_code=401, detail="Login required to view this profile") + + owner = session.get(User, link.user_id) + if owner is None: + raise HTTPException(status_code=404, detail="Public profile not found") + assert owner.id is not None + + config = parse_visibility_config(link.visibility_config_json) + visible_sections = set(config.sections) + + books: list[PublicProfileBook] = [] + if visible_sections & set(BOOK_SECTIONS): + books = build_public_books(session, load_owner_books(session, owner.id)) + + statistics = None + if PublicProfileSectionKey.statistics in visible_sections: + full_stats = compute_statistics( + session, owner.id, range_value=StatisticsRange.alltime + ) + statistics = filter_statistics(full_stats, config.statistics) + + # The owner's name is only emitted when a section that renders it + # (username or user_info) is visible, so it cannot leak through the + # page title or share metadata otherwise. + show_name = bool( + visible_sections + & {PublicProfileSectionKey.username, PublicProfileSectionKey.user_info} + ) + + return PublicProfileResponse( + owner=PublicProfileUserInfo( + firstname=owner.firstname if show_name else None, + lastname=owner.lastname if show_name else None, + ), + audience=link.audience, + language=link.language, + expires_at=link.expires_at, + visibility_config=config, + books=books, + statistics=statistics, + ) + + +def _with_security_headers(response: Response) -> Response: + """Apply baseline security headers to the unauthenticated profile response.""" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + return response \ No newline at end of file diff --git a/backend/app/routers/share_links.py b/backend/app/routers/share_links.py new file mode 100644 index 00000000..12cb3924 --- /dev/null +++ b/backend/app/routers/share_links.py @@ -0,0 +1,165 @@ +"""Share-link management endpoints — CRUD for a user's public profile links.""" + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, col, select + +from app.auth import ( + generate_public_profile_token, + get_public_profile_token_prefix, + hash_public_profile_token, + require_user, +) +from app.database import get_session +from app.models import PublicProfileAudience, PublicProfileLink, User +from app.schemas import ( + PublicProfileLinkCreate, + PublicProfileLinkCreateResponse, + PublicProfileLinkRead, + PublicProfileLinkUpdate, + PublicProfileVisibilityConfig, + ShareLinkRevealResponse, +) +from app.services.public_profile import ( + parse_visibility_config, + serialize_visibility_config, +) +from app.time_utils import utcnow + +router = APIRouter(prefix="/api/profile/share-links", tags=["share-links"]) + + +def _to_read_model(link: PublicProfileLink) -> PublicProfileLinkRead: + """Convert a link model to its read schema, parsing the stored config.""" + assert link.id is not None + return PublicProfileLinkRead( + id=link.id, + name=link.name, + token_prefix=link.token_prefix, + audience=link.audience, + language=link.language, + visibility_config=parse_visibility_config(link.visibility_config_json), + expires_at=link.expires_at, + created_at=link.created_at, + ) + + +def _get_owned_link(link_id: int, user_id: int, session: Session) -> PublicProfileLink: + """Fetch a non-revoked link owned by *user_id*, raising 404 otherwise.""" + link = session.get(PublicProfileLink, link_id) + if not link or link.user_id != user_id or link.revoked_at is not None: + raise HTTPException(status_code=404, detail="Share link not found") + return link + + +def _ensure_future_expiry(expires_at: datetime | None) -> None: + """Reject expiry dates in the past so links cannot be created already dead.""" + if expires_at is not None and expires_at <= utcnow(): + raise HTTPException( + status_code=422, + detail="Expiry date must be in the future", + ) + + +@router.get("", response_model=list[PublicProfileLinkRead]) +def list_share_links( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> list[PublicProfileLinkRead]: + """List non-revoked share links for the current user.""" + assert current_user.id is not None + links = session.exec( + select(PublicProfileLink) + .where( + PublicProfileLink.user_id == current_user.id, + col(PublicProfileLink.revoked_at).is_(None), + ) + .order_by(col(PublicProfileLink.created_at).desc()) + ).all() + return [_to_read_model(link) for link in links] + + +@router.post("", response_model=PublicProfileLinkCreateResponse, status_code=201) +def create_share_link( + body: PublicProfileLinkCreate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> PublicProfileLinkCreateResponse: + """Create a new share link. The raw token is returned exactly once.""" + assert current_user.id is not None + _ensure_future_expiry(body.expires_at) + plain_token = generate_public_profile_token() + audience = PublicProfileAudience(body.audience or PublicProfileAudience.public) + link = PublicProfileLink( + user_id=current_user.id, + name=body.name, + token_prefix=get_public_profile_token_prefix(plain_token), + token=plain_token, + token_hash=hash_public_profile_token(plain_token), + audience=audience, + language=body.language, + visibility_config_json=serialize_visibility_config(body.visibility_config), + expires_at=body.expires_at, + ) + session.add(link) + session.commit() + session.refresh(link) + return PublicProfileLinkCreateResponse( + token=plain_token, + link=_to_read_model(link), + ) + + +@router.post("/{link_id}/reveal", response_model=ShareLinkRevealResponse) +def reveal_share_link( + link_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> ShareLinkRevealResponse: + """Return the raw token for a share link owned by the current user.""" + assert current_user.id is not None + link = _get_owned_link(link_id, current_user.id, session) + if not link.token: + raise HTTPException(status_code=404, detail="Token not available for legacy link") + return ShareLinkRevealResponse(token=link.token) + + +@router.patch("/{link_id}", response_model=PublicProfileLinkRead) +def update_share_link( + link_id: int, + body: PublicProfileLinkUpdate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> PublicProfileLinkRead: + """Update name, audience, visibility config, or expiry of a share link.""" + assert current_user.id is not None + link = _get_owned_link(link_id, current_user.id, session) + + update_data = body.model_dump(exclude_unset=True) + if "expires_at" in update_data and update_data["expires_at"] is not None: + _ensure_future_expiry(update_data["expires_at"]) + if "visibility_config" in update_data: + update_data["visibility_config_json"] = serialize_visibility_config( + body.visibility_config or PublicProfileVisibilityConfig() + ) + update_data.pop("visibility_config") + link.sqlmodel_update(update_data) + session.add(link) + session.commit() + session.refresh(link) + return _to_read_model(link) + + +@router.delete("/{link_id}", status_code=204) +def delete_share_link( + link_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> None: + """Revoke a share link. Subsequent public access returns 404.""" + assert current_user.id is not None + link = _get_owned_link(link_id, current_user.id, session) + link.revoked_at = utcnow() + session.add(link) + session.commit() \ No newline at end of file diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 900f25a0..6d011cb0 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -1,412 +1,42 @@ -"""Statistics dashboard — full stats, pages-per-day breakdown, and book-level fallback.""" +"""Statistics dashboard — full stats, pages-per-day breakdown, and book-level fallback. + +The heavy aggregation logic lives in :mod:`app.services.statistics`, which is +shared with the public profile endpoint. This router keeps only the +authentication layer and thin endpoint wrappers. +""" -import calendar -from collections import Counter, defaultdict from datetime import date, datetime, timedelta, timezone -from statistics import mean -from types import SimpleNamespace from typing import Optional -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import APIRouter, Depends, Query -from sqlalchemy import func from sqlmodel import Session, col, select from app.auth import require_user from app.database import get_session -from app.models import AcquisitionStatus, Author, Book, BookAuthor, ReadingProgress, ReadingStatus, User, UserSettings -from app.services.authors import join_authors, load_authors_batch +from app.models import Book, ReadingProgress, ReadingStatus, User, UserSettings from app.schemas import ( - AcquisitionStatusDistribution, DailyPages, DailyPagesResponse, GamificationResponse, - GoalProgress, - GoalType, - LanguageDistribution, - MonthlyBooks, - MonthlyPages, - PageBuckets, + StatisticsRange, StatisticsResponse, - StatusDistribution, - TopAuthor, - TopAuthorCover, - TopRatedBook, - YearlyBooks, +) +from app.services.statistics import ( + _compute_goal_progress, + _day_key, + _extract_book_level_daily_pages, + _extract_progress_daily_pages, + _naive_utc, + _user_timezone, + _zone_from_name, + compute_statistics, + current_streak, + longest_streak, ) router = APIRouter(prefix="/api/statistics", tags=["statistics"]) -def _zone_from_name(timezone_name: str | None) -> ZoneInfo: - """Return a ZoneInfo for *timezone_name*, falling back to UTC.""" - try: - return ZoneInfo(timezone_name or "UTC") - except ZoneInfoNotFoundError: - return ZoneInfo("UTC") - - -def _user_timezone(session: Session, user_id: int) -> ZoneInfo: - """Return the user's configured timezone, falling back to UTC.""" - settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() - return _zone_from_name(settings.timezone if settings else None) - - -def _month_key(dt: datetime, tz: ZoneInfo) -> str: - """Format a datetime as ``YYYY-MM`` in the given timezone.""" - local = dt.astimezone(tz) - return f"{local.year:04d}-{local.month:02d}" - - -def _month_range(start_key: str, end_key: str) -> list[str]: - """Generate a list of ``YYYY-MM`` keys from *start_key* to *end_key* inclusive.""" - start_year, start_month = map(int, start_key.split("-")) - end_year, end_month = map(int, end_key.split("-")) - keys: list[str] = [] - year, month = start_year, start_month - while (year < end_year) or (year == end_year and month <= end_month): - keys.append(f"{year:04d}-{month:02d}") - month += 1 - if month > 12: - month = 1 - year += 1 - return keys - - -def _clamp_window( - start: datetime, end: datetime, - window_start: datetime | None, window_end: datetime | None, -) -> tuple[datetime | None, datetime | None]: - """Clamp *start*/*end* to *window_start*/*window_end* if provided. - - Returns (clamped_start, clamped_end) or (None, None) when the span - does not overlap the window at all. - All returned datetimes are UTC-aware (matching the DB convention) - so callers can safely use .astimezone() and compare. - """ - if window_start is not None: - w_start = _naive_utc(window_start) - s = _naive_utc(start) - e = _naive_utc(end) - if e < w_start: - return (None, None) - if s < w_start: - start = w_start.replace(tzinfo=timezone.utc) - if window_end is not None: - w_end = _naive_utc(window_end) - s = _naive_utc(start) - e = _naive_utc(end) - if s > w_end: - return (None, None) - if e > w_end: - end = w_end.replace(tzinfo=timezone.utc) - return (start, end) - - -def _naive_utc(dt: datetime) -> datetime: - """Return a naive datetime representing the same instant as *dt* in UTC.""" - if dt.tzinfo is not None: - return dt.astimezone(timezone.utc).replace(tzinfo=None) - return dt - - -def _extract_progress_daily_pages( - entries: list, tz: ZoneInfo, - window_start: datetime | None = None, window_end: datetime | None = None, -) -> dict[str, float]: - """Distribute reading progress page-deltas across calendar days. - - When *window_start*/*window_end* are provided, only days within that - window are emitted. The daily average is still computed from the full - span so the values stay correct. - """ - daily: dict[str, float] = defaultdict(float) - grouped: dict[int, list] = {} - for entry in entries: - grouped.setdefault(entry.book_id, []).append(entry) - - for book_id in sorted(grouped): - book_entries = grouped[book_id] - book_entries.sort(key=lambda e: (e.created_at, e.page)) - for prev, curr in zip(book_entries, book_entries[1:]): - delta = curr.page - prev.page - if delta > 0: - day_diff = (curr.created_at - prev.created_at).days + 1 - if day_diff > 0: - daily_avg = delta / day_diff - start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) - if start is None or end is None: - continue - while start <= end: - date_key = start.astimezone(tz).strftime("%Y-%m-%d") - daily[date_key] += daily_avg - start += timedelta(days=1) - - return daily - - -def _extract_book_level_daily_pages( - books: list[Book], tz: ZoneInfo, - window_start: datetime | None = None, window_end: datetime | None = None, -) -> dict[str, float]: - """Distribute page counts across the reading period for books finished without progress entries. - - When *window_start*/*window_end* are provided, only days within that - window are emitted. The daily average is still computed from the full - span so the values stay correct. - """ - daily: dict[str, float] = defaultdict(float) - for book in books: - if not (book.date_started and book.date_finished and book.page_count): - continue - if book.date_finished < book.date_started: - continue - total_days = (book.date_finished - book.date_started).days + 1 - if total_days <= 0: - continue - daily_avg = book.page_count / total_days - start, end = _clamp_window(book.date_started, book.date_finished, window_start, window_end) - if start is None or end is None: - continue - while start <= end: - date_key = start.astimezone(tz).strftime("%Y-%m-%d") - daily[date_key] += daily_avg - start += timedelta(days=1) - return daily - - -def _allocate_daily_avg_across_months( - daily_avg: float, start: datetime, end: datetime, tz: ZoneInfo -) -> dict[str, float]: - """Spread a per-day value proportionally across months from *start* to *end* inclusive.""" - monthly: dict[str, float] = defaultdict(float) - current = start - while current <= end: - _, last_dom = calendar.monthrange(current.year, current.month) - period_end = min(current.replace(day=last_dom), end) - days = (period_end - current).days + 1 - month_key = _month_key(current, tz) - monthly[month_key] += daily_avg * days - current = period_end + timedelta(days=1) - return monthly - - -def _compute_pages_per_month_from_progress(entries: list, tz: ZoneInfo) -> dict[str, float]: - """Compute pages read per month from reading progress entries.""" - monthly: dict[str, float] = defaultdict(float) - grouped: dict[int, list] = {} - for entry in entries: - grouped.setdefault(entry.book_id, []).append(entry) - for book_id in sorted(grouped): - book_entries = sorted(grouped[book_id], key=lambda e: (e.created_at, e.page)) - for prev, curr in zip(book_entries, book_entries[1:]): - delta = curr.page - prev.page - if delta <= 0: - continue - day_diff = (curr.created_at - prev.created_at).days + 1 - if day_diff <= 0: - continue - m = _allocate_daily_avg_across_months(delta / day_diff, prev.created_at, curr.created_at, tz) - for k, v in m.items(): - monthly[k] += v - return monthly - - -def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> dict[str, float]: - """Compute pages read per month for finished books without progress entries.""" - monthly: dict[str, float] = defaultdict(float) - for book in books: - if not (book.date_started and book.date_finished and book.page_count): - continue - if book.date_finished < book.date_started: - continue - total_days = (book.date_finished - book.date_started).days + 1 - if total_days <= 0: - continue - m = _allocate_daily_avg_across_months( - book.page_count / total_days, book.date_started, book.date_finished, tz - ) - for k, v in m.items(): - monthly[k] += v - return monthly - - -def _day_key(dt: datetime, tz: ZoneInfo) -> str: - """Return the ``YYYY-MM-DD`` calendar day of *dt* in *tz*.""" - return dt.astimezone(tz).strftime("%Y-%m-%d") - - -def current_streak(active_dates: set[str], today: date) -> int: - """Return the number of consecutive active days ending at *today*. - - Today counts as the first day when it is active; otherwise the streak - starts at yesterday, so a not-yet-logged today does not break an ongoing - streak. The streak is 0 when neither today nor yesterday are active. - """ - streak = 0 - day = today - first = True - while True: - if day.isoformat() in active_dates: - streak += 1 - elif not first: - break - first = False - day -= timedelta(days=1) - return streak - - -def longest_streak(active_dates: set[str]) -> tuple[int, Optional[str], Optional[str]]: - """Return the longest consecutive run of active dates. - - Returns ``(length, start, end)`` with ``YYYY-MM-DD`` keys. Ties are - broken in favour of the most recent run. When there is no activity at - all the result is ``(0, None, None)``. - """ - if not active_dates: - return 0, None, None - ordered = sorted(active_dates) - best_len, best_start, best_end = 0, None, None - run_start = ordered[0] - run_len = 1 - prev = ordered[0] - for current in ordered[1:]: - if (date.fromisoformat(current) - date.fromisoformat(prev)).days == 1: - run_len += 1 - else: - if run_len >= best_len: - best_len, best_start, best_end = run_len, run_start, prev - run_start, run_len = current, 1 - prev = current - if run_len >= best_len: - best_len, best_start, best_end = run_len, run_start, prev - return best_len, best_start, best_end - - -def _pages_logged_on_day(entries: list, tz: ZoneInfo, day_key: str) -> int: - """Sum the positive page-deltas logged on *day_key*. - - A delta is the page gain between two consecutive progress entries of the - same book, attributed to the calendar day (in *tz*) of the later entry. - """ - grouped: dict[int, list] = {} - for entry in entries: - grouped.setdefault(entry.book_id, []).append(entry) - total = 0 - for book_entries in grouped.values(): - book_entries.sort(key=lambda e: (e.created_at, e.page)) - for prev, curr in zip(book_entries, book_entries[1:]): - delta = curr.page - prev.page - if delta > 0 and _day_key(curr.created_at, tz) == day_key: - total += delta - return total - - -def _compute_goal_progress( - tz: ZoneInfo, - settings: UserSettings, - today: datetime, - entries: list, - books: list, - book_ids_with_progress: set[int], -) -> list[GoalProgress]: - """Compute current progress for every enabled reading goal. - - Disabled goals are omitted from the response; the dashboard only shows - goals the user opted into. - """ - today_key = today.strftime("%Y-%m-%d") - current_month_key = today.strftime("%Y-%m") - current_year = today.year - - fallback_books = [ - b - for b in books - if b.id not in book_ids_with_progress - and b.reading_status == ReadingStatus.read - and b.date_started - and b.date_finished - and b.page_count - ] - - # Mirror get_statistics: anchor every book with progress at page 0 on its - # start date so the first progress delta is attributed to the reading span, - # keeping the pages-per-month goal consistent with the statistics chart. - virtual_entries = [ - SimpleNamespace(book_id=b.id, page=0, created_at=b.date_started) - for b in books - if b.id in book_ids_with_progress - and b.date_started - and not (b.reading_status == ReadingStatus.read and not b.date_finished) - ] - - goals_spec = [ - (GoalType.pages_per_day, settings.goal_pages_per_day_enabled, settings.goal_pages_per_day), - (GoalType.pages_per_month, settings.goal_pages_per_month_enabled, settings.goal_pages_per_month), - (GoalType.books_per_month, settings.goal_books_per_month_enabled, settings.goal_books_per_month), - (GoalType.books_per_year, settings.goal_books_per_year_enabled, settings.goal_books_per_year), - ] - - results: list[GoalProgress] = [] - for goal_type, enabled, target in goals_spec: - if not enabled: - continue - current = _goal_current_value( - goal_type, tz, today_key, current_month_key, current_year, - entries, books, fallback_books, virtual_entries, - ) - results.append( - GoalProgress(type=goal_type, target=target, current=current, reached=current >= target) - ) - return results - - -def _goal_current_value( - goal_type: GoalType, - tz: ZoneInfo, - today_key: str, - current_month_key: str, - current_year: int, - entries: list, - books: list, - fallback_books: list, - virtual_entries: list, -) -> int: - """Return the current value for a single reading goal.""" - if goal_type == GoalType.pages_per_day: - total = _pages_logged_on_day(entries, tz, today_key) - for b in fallback_books: - if _day_key(b.date_finished, tz) == today_key: - total += b.page_count - return total - - if goal_type == GoalType.pages_per_month: - monthly = _compute_pages_per_month_from_progress(entries + virtual_entries, tz) - for k, v in _compute_pages_per_month_from_books(fallback_books, tz).items(): - monthly[k] += v - return int(round(monthly.get(current_month_key, 0))) - - if goal_type == GoalType.books_per_month: - return sum( - 1 - for b in books - if b.reading_status == ReadingStatus.read - and b.date_finished is not None - and _month_key(b.date_finished, tz) == current_month_key - ) - - if goal_type == GoalType.books_per_year: - return sum( - 1 - for b in books - if b.reading_status == ReadingStatus.read - and b.date_finished is not None - and b.date_finished.astimezone(tz).year == current_year - ) - - return 0 - - @router.get("/gamification", response_model=GamificationResponse) def get_gamification( current_user: User = Depends(require_user), @@ -536,22 +166,20 @@ def get_pages_per_day( session.exec(select(Book).where(Book.user_id == current_user.id)).all() ) - virtual_entries = [] - for book in books: - if book.id not in all_book_ids_with_progress or not book.date_started: - continue - # Finished books without date_finished have no bounded reading - # period; skip to avoid spreading pages from date_started to - # today via a single import-created progress entry. - if book.reading_status == ReadingStatus.read and not book.date_finished: - continue - virtual_entries.append( - SimpleNamespace( - book_id=book.id, - page=0, - created_at=book.date_started, - ) + # Rebuild virtual entries with a simple namespace replacement. + from types import SimpleNamespace + + virtual_entries = [ + SimpleNamespace( + book_id=book.id, + page=0, + created_at=book.date_started, ) + for book in books + if book.id in all_book_ids_with_progress + and book.date_started + and not (book.reading_status == ReadingStatus.read and not book.date_finished) + ] all_progress_entries = list(progress_entries) + virtual_entries progress_daily = _extract_progress_daily_pages(all_progress_entries, tz, start_date_utc, end_date_utc) @@ -569,11 +197,11 @@ def get_pages_per_day( ] fallback_daily = _extract_book_level_daily_pages(fallback_books, tz, start_date_utc, end_date_utc) - combined: dict[str, float] = defaultdict(float) + combined: dict[str, float] = {} for k, v in progress_daily.items(): - combined[k] += v + combined[k] = combined.get(k, 0) + v for k, v in fallback_daily.items(): - combined[k] += v + combined[k] = combined.get(k, 0) + v start_date_str = start_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") @@ -593,304 +221,25 @@ def get_pages_per_day( @router.get("", response_model=StatisticsResponse) def get_statistics( + range_value: StatisticsRange = Query(default=StatisticsRange.alltime, alias="range"), + custom_from: Optional[date] = Query(default=None, alias="from"), + custom_to: Optional[date] = Query(default=None, alias="to"), current_user: User = Depends(require_user), session: Session = Depends(get_session), ) -> StatisticsResponse: - """Return the full statistics dashboard for the authenticated user.""" - assert current_user.id is not None - tz = _user_timezone(session, current_user.id) - now = datetime.now(tz) - current_month_key = f"{now.year:04d}-{now.month:02d}" - current_year = now.year - books = list(session.exec(select(Book).where(Book.user_id == current_user.id)).all()) - - total_authors = session.exec( - select(func.count()).select_from(Author).where(Author.user_id == current_user.id) - ).one() - - status_counts = Counter(book.reading_status for book in books) - status_distribution = StatusDistribution( - want_to_read=status_counts.get(ReadingStatus.want_to_read, 0), - currently_reading=status_counts.get(ReadingStatus.currently_reading, 0), - read=status_counts.get(ReadingStatus.read, 0), - did_not_finish=status_counts.get(ReadingStatus.did_not_finish, 0), - ) - - acquisition_counts = Counter(book.acquisition_status for book in books) - acquisition_status_distribution = AcquisitionStatusDistribution( - owned=acquisition_counts.get(AcquisitionStatus.owned, 0), - borrowed=acquisition_counts.get(AcquisitionStatus.borrowed, 0), - digital_access=acquisition_counts.get(AcquisitionStatus.digital_access, 0), - to_acquire=acquisition_counts.get(AcquisitionStatus.to_acquire, 0), - ) - - page_values = [book.page_count for book in books if book.page_count is not None] - avg_page_count = round(mean(page_values), 2) if page_values else None - - language_counts: Counter[str | None] = Counter(book.language for book in books) - language_distribution = [ - LanguageDistribution(language=language, count=count) - for language, count in sorted( - language_counts.items(), - key=lambda item: (-item[1], item[0] is None, item[0] or ""), - ) - ] - known_language_counts = [(code, count) for code, count in language_counts.items() if code] - known_language_counts.sort(key=lambda item: (-item[1], item[0])) - most_popular_language = known_language_counts[0][0] if known_language_counts else None - most_popular_language_count = known_language_counts[0][1] if known_language_counts else None - - pages_to_read = sum( - book.page_count or 0 - for book in books - if book.reading_status == ReadingStatus.want_to_read and book.page_count is not None - ) - pages_read = sum( - book.page_count or 0 - for book in books - if book.reading_status == ReadingStatus.read and book.page_count is not None - ) - - dnf_book_ids = [book.id for book in books if book.reading_status == ReadingStatus.did_not_finish and book.id is not None] - pages_wasted = 0 - if dnf_book_ids: - wasted_rows = session.exec( - select(ReadingProgress.book_id, func.max(ReadingProgress.page)) - .where( - ReadingProgress.user_id == current_user.id, - col(ReadingProgress.book_id).in_(dnf_book_ids), - ) - .group_by(col(ReadingProgress.book_id)) - ).all() - pages_wasted = int(sum((max_page or 0) for _, max_page in wasted_rows)) - - page_buckets = PageBuckets( - pages_to_read=int(pages_to_read), - pages_read=int(pages_read), - pages_wasted=pages_wasted, - ) - - finished_books = [ - book - for book in books - if book.reading_status == ReadingStatus.read and book.date_finished is not None - ] - - finished_books_per_month: Counter[str] = Counter() - for book in finished_books: - assert book.date_finished is not None - month = _month_key(book.date_finished, tz) - finished_books_per_month[month] += 1 - - progress_entries = list( - session.exec( - select(ReadingProgress) - .where(ReadingProgress.user_id == current_user.id) - .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) - ).all() - ) - - books_with_progress = {e.book_id for e in progress_entries} - - virtual_entries = [] - for book in books: - if book.id not in books_with_progress or not book.date_started: - continue - if book.reading_status == ReadingStatus.read and not book.date_finished: - continue - virtual_entries.append( - SimpleNamespace( - book_id=book.id, - page=0, - created_at=book.date_started, - ) - ) - - all_progress_entries = list(progress_entries) + virtual_entries - pages_read_per_month_counter = _compute_pages_per_month_from_progress(all_progress_entries, tz) - - fallback_books = [ - b - for b in books - if b.id not in books_with_progress - and b.reading_status == ReadingStatus.read - and b.date_started - and b.date_finished - and b.page_count - ] - fallback_monthly = _compute_pages_per_month_from_books(fallback_books, tz) - for k, v in fallback_monthly.items(): - pages_read_per_month_counter[k] += v - - if finished_books_per_month: - avg_books_per_month = round( - sum(finished_books_per_month.values()) / len(finished_books_per_month), - 2, - ) - busiest_month, busiest_month_count = min( - ( - (month, count) - for month, count in finished_books_per_month.items() - ), - key=lambda item: (-item[1], item[0]), - ) - month_keys = _month_range(min(finished_books_per_month), max(max(finished_books_per_month), current_month_key)) - books_finished_per_month = [ - MonthlyBooks(month=month, count=finished_books_per_month.get(month, 0)) for month in month_keys - ] - else: - avg_books_per_month = None - busiest_month = None - busiest_month_count = None - books_finished_per_month = [] - - if pages_read_per_month_counter: - all_months = set(pages_read_per_month_counter) | {current_month_key} - if finished_books_per_month: - all_months |= set(finished_books_per_month) - month_keys = _month_range(min(all_months), max(all_months)) - pages_read_per_month = [ - MonthlyPages(month=month, pages=int(round(pages_read_per_month_counter.get(month, 0)))) for month in month_keys - ] - else: - pages_read_per_month = [] - - if finished_books_per_month: - yearly_counts: Counter[int] = Counter() - for month_key, count in finished_books_per_month.items(): - yearly_counts[int(month_key.split("-")[0])] += count - year_start = min(yearly_counts) - year_end = max(max(yearly_counts), current_year) - books_finished_per_year = [ - YearlyBooks(year=year, count=yearly_counts.get(year, 0)) - for year in range(year_start, year_end + 1) - ] - else: - books_finished_per_year = [] + """Return the full statistics dashboard for the authenticated user. - author_count_label = func.count(func.distinct(BookAuthor.book_id)).label("cnt") - author_count_rows = session.exec( - select(Author.name, author_count_label) - .join(BookAuthor, col(BookAuthor.author_id) == col(Author.id)) - .join(Book, col(Book.id) == col(BookAuthor.book_id)) - .where(Book.user_id == current_user.id) - .group_by(col(Author.id)) - .order_by(author_count_label.desc(), col(Author.name).asc()) - .limit(3) - ).all() - author_counts = Counter({name: count for name, count in author_count_rows}) - - top_authors: list[TopAuthor] = [] - if author_counts: - top_author_counts = author_counts.most_common(3) - top_author_names = [name for name, _ in top_author_counts] - - covers_by_author: dict[str, list[TopAuthorCover]] = {} - for author_name in top_author_names: - max_slots = min(5, author_counts[author_name]) - book_ids_with_author = select(BookAuthor.book_id).join( - Author, col(Author.id) == col(BookAuthor.author_id) - ).where( - Author.user_id == current_user.id, - Author.name == author_name, - ) - cover_rows = session.exec( - select(Book.id, Book.title, Book.reading_status, Book.cover_url) - .where( - Book.user_id == current_user.id, - col(Book.id).in_(book_ids_with_author), - col(Book.cover_url).is_not(None), - ) - .order_by(col(Book.id)) - .limit(max_slots) - ).all() - results = [ - TopAuthorCover(book_id=book_id, title=title, reading_status=reading_status, cover_url=cover_url) - for book_id, title, reading_status, cover_url in cover_rows - if book_id is not None - ] - remaining = max_slots - len(results) - if remaining > 0: - no_cover_rows = session.exec( - select(Book.id, Book.title, Book.reading_status, Book.cover_url) - .where( - Book.user_id == current_user.id, - col(Book.id).in_(book_ids_with_author), - col(Book.cover_url).is_(None), - ) - .order_by(col(Book.id)) - .limit(remaining) - ).all() - results.extend( - TopAuthorCover(book_id=book_id, title=title, reading_status=reading_status, cover_url=cover_url) - for book_id, title, reading_status, cover_url in no_cover_rows - if book_id is not None - ) - covers_by_author[author_name] = results - - top_authors = [ - TopAuthor( - author=author_name, - book_count=author_count, - covers=covers_by_author.get(author_name, []), - ) - for author_name, author_count in top_author_counts - ] - - # --- Rating stats --- - books_with_rating = sum(1 for b in books if b.rating is not None) - books_without_rating = sum(1 for b in books if b.rating is None) - rating_values = [b.rating for b in books if b.rating is not None] - average_rating = round(mean(rating_values), 2) if rating_values else None - - rated_books = [b for b in books if b.rating is not None] - rated_book_ids = [b.id for b in rated_books if b.id is not None] - rated_authors_map = load_authors_batch(session, rated_book_ids) - - def _rating_sort_key(book: Book) -> tuple[int, float]: - assert book.rating is not None - return (book.rating, -(book.date_added or datetime.min).timestamp()) - - # Top rated: highest rating first; ties broken by newest-added first. - top_rated_books = [] - for b in sorted(rated_books, key=lambda x: (-_rating_sort_key(x)[0], _rating_sort_key(x)[1])): - assert b.id is not None - assert b.rating is not None - author_names = rated_authors_map.get(b.id, []) - top_rated_books.append( - TopRatedBook(book_id=b.id, title=b.title or "", author=join_authors(author_names), authors=author_names, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) - ) - - # Worst rated: lowest rating first; ties broken by newest-added first. - worst_rated_books = [] - for b in sorted(rated_books, key=_rating_sort_key): - assert b.id is not None - assert b.rating is not None - author_names = rated_authors_map.get(b.id, []) - worst_rated_books.append( - TopRatedBook(book_id=b.id, title=b.title or "", author=join_authors(author_names), authors=author_names, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) - ) - - return StatisticsResponse( - total_books=len(books), - total_authors=total_authors, - avg_books_per_month=avg_books_per_month, - busiest_month=busiest_month, - busiest_month_count=busiest_month_count, - avg_page_count=avg_page_count, - most_popular_language=most_popular_language, - most_popular_language_count=most_popular_language_count, - language_distribution=language_distribution, - status_distribution=status_distribution, - acquisition_status_distribution=acquisition_status_distribution, - page_buckets=page_buckets, - pages_read_per_month=pages_read_per_month, - books_finished_per_month=books_finished_per_month, - books_finished_per_year=books_finished_per_year, - top_authors=top_authors, - books_with_rating=books_with_rating, - books_without_rating=books_without_rating, - average_rating=average_rating, - top_rated_books=top_rated_books, - worst_rated_books=worst_rated_books, - ) + The *range* query parameter selects a shared time window for the three + trend charts (pages read per month, books finished per month/year). When + *range* is ``custom``, the ``from``/``to`` dates bound the window inclusive. + All other statistics (status/acquisition distributions, top authors, + ratings, page buckets) are computed over the full library. + """ + assert current_user.id is not None + return compute_statistics( + session, + current_user.id, + range_value=range_value, + custom_from=custom_from, + custom_to=custom_to, + ) \ No newline at end of file diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f2662452..6ebe2e57 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,7 @@ """Pydantic / SQLModel request and response schemas for the API.""" from typing import Optional, Any -from datetime import datetime +from datetime import date, datetime from enum import Enum from typing import Literal @@ -10,7 +10,7 @@ from sqlmodel import Field, SQLModel from sqlmodel._compat import SQLModelConfig -from app.models import AcquisitionStatus, ReadingStatus, UserRole +from app.models import AcquisitionStatus, Medium, ReadingStatus, PublicProfileAudience, UserRole class ReadingProgressCreate(SQLModel): @@ -74,6 +74,7 @@ def require_author(cls, data: Any) -> Any: rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: ReadingStatus = ReadingStatus.want_to_read acquisition_status: AcquisitionStatus = AcquisitionStatus.owned + medium: Optional[Medium] = None date_started: Optional[datetime] = None date_finished: Optional[datetime] = None @@ -96,6 +97,7 @@ class BookUpdate(SQLModel): rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: Optional[ReadingStatus] = None acquisition_status: Optional[AcquisitionStatus] = None + medium: Optional[Medium] = None date_started: Optional[datetime] = None date_finished: Optional[datetime] = None @@ -144,6 +146,7 @@ class BookImportRequest(SQLModel): candidate: BookImportCandidate reading_status: ReadingStatus = ReadingStatus.want_to_read acquisition_status: AcquisitionStatus = AcquisitionStatus.owned + medium: Optional[Medium] = None class BookRead(SQLModel): @@ -165,6 +168,7 @@ class BookRead(SQLModel): rating: Optional[int] reading_status: ReadingStatus acquisition_status: AcquisitionStatus + medium: Optional[Medium] = None date_added: datetime date_started: Optional[datetime] date_finished: Optional[datetime] @@ -224,6 +228,12 @@ class AcquisitionStatusDistribution(SQLModel): to_acquire: int +class MediumDistribution(SQLModel): + """Count of books per medium, including unset values.""" + medium: Optional[Medium] + count: int + + class PageBuckets(SQLModel): """Page count buckets for the statistics dashboard.""" pages_to_read: int @@ -249,6 +259,15 @@ class YearlyBooks(SQLModel): count: int +class StatisticsRange(str, Enum): + """Shared statistics time-range selector options.""" + alltime = "alltime" + this_year = "this_year" + last_year = "last_year" + three_years = "3years" + custom = "custom" + + class TopAuthor(SQLModel): """An author with the most books in the library.""" author: str @@ -288,6 +307,7 @@ class StatisticsResponse(SQLModel): language_distribution: list[LanguageDistribution] status_distribution: StatusDistribution acquisition_status_distribution: AcquisitionStatusDistribution + medium_distribution: list[MediumDistribution] page_buckets: PageBuckets pages_read_per_month: list[MonthlyPages] books_finished_per_month: list[MonthlyBooks] @@ -413,6 +433,11 @@ class UserSettingsRead(SQLModel): goal_books_per_year_enabled: bool goal_books_per_year: int gamification_enabled: bool + auto_set_date_started: bool + auto_set_date_finished: bool + statistics_range: StatisticsRange + statistics_custom_from: Optional[date] = None + statistics_custom_to: Optional[date] = None class UserSettingsUpdate(SQLModel): @@ -430,6 +455,18 @@ class UserSettingsUpdate(SQLModel): goal_books_per_year_enabled: Optional[bool] = None goal_books_per_year: Optional[int] = Field(default=None, ge=1) gamification_enabled: Optional[bool] = None + auto_set_date_started: Optional[bool] = None + auto_set_date_finished: Optional[bool] = None + statistics_range: Optional[StatisticsRange] = None + statistics_custom_from: Optional[date] = None + statistics_custom_to: Optional[date] = None + + @field_validator("auto_set_date_started", "auto_set_date_finished") + @classmethod + def validate_date_automation_setting(cls, value: Optional[bool]) -> Optional[bool]: + if value is None: + raise ValueError("Reading date automation settings cannot be null") + return value @field_validator('theme') @classmethod @@ -546,6 +583,7 @@ class HygieneAttribute(str, Enum): subtitle = "subtitle" page_count = "page_count" cover_url = "cover_url" + medium = "medium" class HygieneMissingBook(SQLModel): @@ -609,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): @@ -688,6 +727,7 @@ class DataImportPreviewRow(SQLModel): source: dict[str, Any] transformed: dict[str, Any] errors: list[str] + warnings: list[str] = Field(default_factory=list) class DataImportPreviewRequest(SQLModel): @@ -735,6 +775,145 @@ class EmbedTokenCreateResponse(SQLModel): embed_token: EmbedTokenRead +class PublicProfileSectionKey(str, Enum): + """Stable keys for the selectable sections of a public profile. + + Adding a new section is a small, contained change: add a member here, a + registry entry on the frontend, an i18n label/tooltip, and a render + component on the public page. Saved configs tolerate unknown keys. + """ + + username = "username" + user_info = "user_info" + currently_reading = "currently_reading" + last_read = "last_read" + reading_timeline = "reading_timeline" + full_library = "full_library" + statistics = "statistics" + + +class PublicProfileStatisticsKey(str, Enum): + """Selectable statistics exposed on a public profile.""" + + total_books = "total_books" + total_authors = "total_authors" + avg_books_per_month = "avg_books_per_month" + busiest_month = "busiest_month" + avg_page_count = "avg_page_count" + most_popular_language = "most_popular_language" + language_distribution = "language_distribution" + status_distribution = "status_distribution" + acquisition_status_distribution = "acquisition_status_distribution" + medium_distribution = "medium_distribution" + page_buckets = "page_buckets" + pages_read_per_month = "pages_read_per_month" + books_finished_per_month = "books_finished_per_month" + books_finished_per_year = "books_finished_per_year" + top_authors = "top_authors" + books_with_rating = "books_with_rating" + books_without_rating = "books_without_rating" + average_rating = "average_rating" + top_rated_books = "top_rated_books" + worst_rated_books = "worst_rated_books" + + +class PublicProfileVisibilityConfig(SQLModel): + """Whitelisted sections and, for statistics, the selected sub-keys.""" + + sections: list[PublicProfileSectionKey] = Field(default_factory=list) + statistics: list[PublicProfileStatisticsKey] = Field(default_factory=list) + + +class PublicProfileLinkCreate(SQLModel): + """Request body to create a new public profile share link.""" + + name: str = Field(min_length=1, max_length=255) + audience: Optional[PublicProfileAudience] = None + language: Optional[str] = Field(default=None, max_length=10) + visibility_config: PublicProfileVisibilityConfig = Field(default_factory=PublicProfileVisibilityConfig) + expires_at: Optional[datetime] = None + + +class PublicProfileLinkUpdate(SQLModel): + """Request body to partially update a public profile share link.""" + + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + audience: Optional[PublicProfileAudience] = None + language: Optional[str] = Field(default=None, max_length=10) + visibility_config: Optional[PublicProfileVisibilityConfig] = None + expires_at: Optional[datetime] = None + + +class PublicProfileLinkRead(SQLModel): + """Share-link read response (without the raw token value).""" + + id: int + name: str + token_prefix: str + audience: PublicProfileAudience + language: Optional[str] = None + visibility_config: PublicProfileVisibilityConfig + expires_at: Optional[datetime] = None + created_at: datetime + + +class PublicProfileLinkCreateResponse(SQLModel): + """Share-link creation response containing the raw token (shown once).""" + + token: str + link: PublicProfileLinkRead + + +class ShareLinkRevealResponse(SQLModel): + """Response for the reveal endpoint, returning the raw token.""" + + token: str + + +class PublicProfileUserInfo(SQLModel): + """Public-safe owner identity shown on a public profile. + + Both names are ``None`` when the owner has enabled no section that + displays them (neither ``username`` nor ``user_info``), so the owner's + identity cannot leak through the page title or share metadata. + """ + + firstname: str | None = None + lastname: str | None = None + + +class PublicProfileBook(SQLModel): + """Public-safe book data whitelisted for public profiles.""" + + id: int + title: str + subtitle: Optional[str] = None + authors: list[str] = Field(default_factory=list) + cover_url: Optional[str] = None + reading_status: ReadingStatus + page_count: int + language: Optional[str] = None + rating: Optional[int] = None + date_started: Optional[datetime] = None + date_finished: Optional[datetime] = None + + +class PublicProfileResponse(SQLModel): + """Data returned by the public profile endpoint. + + ``statistics`` is a dict keyed by the selected ``PublicProfileStatisticsKey`` + values so only the configured statistics are ever serialized. + """ + + owner: PublicProfileUserInfo + audience: PublicProfileAudience + language: Optional[str] = None + expires_at: Optional[datetime] = None + visibility_config: PublicProfileVisibilityConfig + books: list[PublicProfileBook] = Field(default_factory=list) + statistics: Optional[dict[str, Any]] = None + + class DataImportExecuteResult(SQLModel): """Import execution result summary.""" imported: int diff --git a/backend/app/services/book_import.py b/backend/app/services/book_import.py index 13b3be53..fbbe5082 100644 --- a/backend/app/services/book_import.py +++ b/backend/app/services/book_import.py @@ -205,7 +205,7 @@ async def _run_hc() -> None: for e in hc_events: yield e - results = _merge_and_deduplicate(ol_results, hc_results) + results = _merge_results(ol_results, hc_results) if not results: if not api_key: @@ -288,7 +288,7 @@ async def search( 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) - results = _merge_and_deduplicate(ol_results, hc_results) + results = _merge_results(ol_results, hc_results) if not results: if not api_key: @@ -829,33 +829,18 @@ def map_hardcover(edition: dict) -> BookImportCandidate | None: # ── Merge / Deduplicate ─────────────────────────────────────────────────────── -def _merge_and_deduplicate( +def _merge_results( primary: list[BookImportCandidate], secondary: list[BookImportCandidate], ) -> list[BookImportCandidate]: - """Merge two candidate lists, deduplicating by (isbn, page_count, language). + """Merge two candidate lists, preserving every candidate in input order. - Primary list items come first in the result. - Same ISBN with different page_count/language is kept as separate candidates. - When two candidates collide, the one with a cover image is preferred. + 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. """ - seen: dict[str, BookImportCandidate] = {} - - def _key(c: BookImportCandidate) -> str: - isbn = (c.isbn or "").replace("-", "").replace(" ", "") - pages = str(c.page_count or "") - lang = (c.language or "").upper() - return f"isbn:{isbn}|pages:{pages}|lang:{lang}" - - for c in primary + secondary: - k = _key(c) - existing = seen.get(k) - if existing is None: - seen[k] = c - elif existing.cover_url is None and c.cover_url is not None: - seen[k] = c - - return list(seen.values()) + return primary + secondary # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/backend/app/services/data_export.py b/backend/app/services/data_export.py index 18e97206..f105b517 100644 --- a/backend/app/services/data_export.py +++ b/backend/app/services/data_export.py @@ -33,6 +33,7 @@ "rating", "reading_status", "acquisition_status", + "medium", "date_added", "date_started", "date_finished", @@ -80,6 +81,7 @@ def _book_to_dict(session: Session, book: Book, export_format: str) -> dict: "rating": book.rating, "reading_status": book.reading_status.value, "acquisition_status": book.acquisition_status.value, + "medium": book.medium.value if book.medium else None, "date_added": _serialize_datetime(book.date_added), "date_started": _serialize_datetime(book.date_started), "date_finished": _serialize_datetime(book.date_finished), diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 89144b49..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,16 +6,22 @@ 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 from app.config import settings -from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User +from app.models import AcquisitionStatus, Book, Medium, ReadingProgress, ReadingStatus, User, normalize_medium_key from app.schemas import ImportFieldConfig logger = logging.getLogger(__name__) @@ -40,6 +46,7 @@ "rating", "reading_status", "acquisition_status", + "medium", "date_added", "date_started", "date_finished", @@ -81,6 +88,10 @@ "acquisition": "acquisition_status", "availability": "acquisition_status", "ownership": "acquisition_status", + "medium": "medium", + "book medium": "medium", + "format": "medium", + "media type": "medium", "date added": "date_added", "added": "date_added", "date started": "date_started", @@ -146,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. @@ -168,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") @@ -192,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") @@ -209,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) @@ -227,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, } @@ -307,6 +409,19 @@ def _parse_acquisition_status(value: object) -> AcquisitionStatus: raise ValueError(_format_value_error("acquisition_status", f"one of: {choices}", value)) from exc +def _parse_medium(value: object) -> Medium | None: + """Parse an optional book medium from an import row.""" + if value is None or not str(value).strip(): + return None + normalized = normalize_medium_key(str(value)) + for medium in Medium: + enum_value = normalize_medium_key(medium.value) + if normalized in {medium.name, enum_value}: + return medium + choices = ", ".join(medium.value for medium in Medium) + raise ValueError(_format_value_error("medium", f"one of: {choices}", value)) + + def _parse_year(value: object, field: str) -> int | None: """Parse a year value, accepting 4-digit integers and date strings.""" if value is None or value == "": @@ -565,6 +680,7 @@ def validate_import( reading_status = _parse_reading_status(row_data.get("reading_status")) if require_acquisition_status: _parse_acquisition_status(row_data.get("acquisition_status")) + _parse_medium(row_data.get("medium")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -667,6 +783,7 @@ def preview_import( for idx, row in enumerate(rows[:limit], start=1): row_errors: list[str] = [] + row_warnings: list[str] = [] row_data = _mapped_row(row, mapping, transform_cache, {"row": idx, "total": len(rows)}, row_errors) # Validate required fields and data types for preview @@ -685,6 +802,7 @@ def preview_import( reading_status = _parse_reading_status(row_data.get("reading_status")) if require_acquisition_status: _parse_acquisition_status(row_data.get("acquisition_status")) + _parse_medium(row_data.get("medium")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -722,7 +840,7 @@ def preview_import( ) if reading_status == ReadingStatus.read and not date_finished: - row_errors.append( + row_warnings.append( "Marked as 'read' but has no finished date; " "without a finish date the book will not count toward monthly statistics" ) @@ -737,6 +855,7 @@ def preview_import( "source": source_display, "transformed": transformed_display, "errors": row_errors, + "warnings": row_warnings, }) return {"preview_rows": preview_rows, "row_count": len(rows), "errors": []} @@ -807,6 +926,7 @@ async def execute_import( if require_acquisition_status else AcquisitionStatus.owned ) + medium = _parse_medium(row_data.get("medium")) language = _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) @@ -869,6 +989,7 @@ async def execute_import( rating=rating, reading_status=reading_status, acquisition_status=acquisition_status, + medium=medium, date_added=date_added or utcnow(), date_started=date_started, date_finished=date_finished, @@ -938,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, @@ -1003,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/app/services/public_profile.py b/backend/app/services/public_profile.py new file mode 100644 index 00000000..63ba0036 --- /dev/null +++ b/backend/app/services/public_profile.py @@ -0,0 +1,126 @@ +"""Shared helpers for public profile share links. + +Keeps the JSON-in-DB visibility configuration, the whitelisted book DTO, and +the statistics filter in one place so the authenticated management router and +the public (unauthenticated) endpoint cannot drift apart. +""" + +import json +from typing import Any + +from sqlmodel import Session, col, select + +from app.models import Book +from app.schemas import ( + PublicProfileBook, + PublicProfileSectionKey, + PublicProfileStatisticsKey, + PublicProfileVisibilityConfig, + StatisticsResponse, +) +from app.services.authors import load_authors_batch + + +def parse_visibility_config(raw: str | None) -> PublicProfileVisibilityConfig: + """Parse the stored JSON visibility config leniently. + + Unknown or invalid section/statistic keys are silently dropped so that + configs saved by a future version with more sections keep working after a + downgrade, and vice versa. + """ + if not raw: + return PublicProfileVisibilityConfig() + try: + data = json.loads(raw) + except (ValueError, TypeError): + return PublicProfileVisibilityConfig() + if not isinstance(data, dict): + return PublicProfileVisibilityConfig() + + sections = [ + key + for key in (data.get("sections", []) or []) + if key in PublicProfileSectionKey._value2member_map_ + ] + statistics = [ + key + for key in (data.get("statistics", []) or []) + if key in PublicProfileStatisticsKey._value2member_map_ + ] + return PublicProfileVisibilityConfig(sections=sections, statistics=statistics) + + +def serialize_visibility_config(config: PublicProfileVisibilityConfig) -> str: + """Serialize a visibility config for storage in the database.""" + return config.model_dump_json() + + +def filter_statistics( + full: StatisticsResponse, + keys: list[PublicProfileStatisticsKey], +) -> dict[str, Any]: + """Return only the requested statistics as a keyed dict. + + The response is keyed by the stable statistic keys so the frontend can + render exactly what the owner selected, and nothing else is leaked. + + Helper fields that annotate a requested statistic (for example + ``busiest_month_count`` for ``busiest_month``) are included alongside + their parent so the descriptions render correctly. + """ + data = full.model_dump() + result = {key.value: data[key.value] for key in keys if key.value in data} + for key in list(result): + companion = COMPANION_STATISTIC_FIELDS.get(key) + if companion is not None: + result[companion] = data.get(companion) + return result + + +COMPANION_STATISTIC_FIELDS = { + "busiest_month": "busiest_month_count", + "most_popular_language": "most_popular_language_count", +} + + +def build_public_books( + session: Session, + books: list[Book], +) -> list[PublicProfileBook]: + """Convert owned book rows into the whitelisted public DTO.""" + book_ids = [b.id for b in books if b.id is not None] + authors_map = load_authors_batch(session, book_ids) + result: list[PublicProfileBook] = [] + for book in books: + if book.id is None: + continue + result.append( + PublicProfileBook( + id=book.id, + title=book.title, + subtitle=book.subtitle, + authors=authors_map.get(book.id, []), + cover_url=book.cover_url, + reading_status=book.reading_status, + page_count=book.page_count, + language=book.language, + rating=book.rating, + date_started=book.date_started, + date_finished=book.date_finished, + ) + ) + return result + + +BOOK_SECTIONS = {"currently_reading", "last_read", "reading_timeline", "full_library"} + + +def load_owner_books(session: Session, user_id: int) -> list[Book]: + """Load all owned books ordered by date added (newest first).""" + return list( + session.exec( + select(Book) + .where(Book.user_id == user_id) + .order_by(col(Book.date_added).desc(), col(Book.id).desc()) + ).all() + ) \ No newline at end of file diff --git a/backend/app/services/search.py b/backend/app/services/search.py index cc1a757a..362d2cc2 100644 --- a/backend/app/services/search.py +++ b/backend/app/services/search.py @@ -16,7 +16,8 @@ import sqlalchemy as sa from sqlmodel import col, or_, select -from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Tag +from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, Tag, normalize_medium_key +from app.i18n import translate # Fields that can be targeted with a prefix. The keys are the canonical, # always-English prefix names; the values are the book model columns. @@ -30,11 +31,12 @@ # Possession is a special case: it maps to an exact enum comparison. POSSESSION_PREFIX = "possession" +MEDIUM_PREFIX = "medium" TAG_PREFIX = "tag" AUTHOR_PREFIX = "author" SUPPORTED_PREFIXES: frozenset[str] = frozenset( - [*FIELD_COLUMNS.keys(), POSSESSION_PREFIX, TAG_PREFIX, AUTHOR_PREFIX] + [*FIELD_COLUMNS.keys(), POSSESSION_PREFIX, MEDIUM_PREFIX, TAG_PREFIX, AUTHOR_PREFIX] ) # Default fields searched by an unprefixed term (unchanged from the previous @@ -171,17 +173,36 @@ def _unprefixed_condition(value: str, user_id: int) -> Any: def _possession_condition(value: str) -> Any | None: """Build the exact acquisition-status condition, or ``None`` if invalid.""" normalized = value.strip().lower().replace(" ", "_") - try: - status = AcquisitionStatus(normalized) - except ValueError: - return None - return Book.acquisition_status == status + for status in AcquisitionStatus: + localized_values = { + normalize_medium_key(translate(f"acquisition.{status.name}", locale)) + for locale in ("en", "de", "es", "fr", "zh") + } + if normalized in {status.name, status.value, *localized_values}: + return Book.acquisition_status == status + return None + + +def _medium_condition(value: str) -> Any | None: + """Build an exact medium condition, accepting display and key forms.""" + normalized = normalize_medium_key(value) + for medium in Medium: + enum_value = normalize_medium_key(medium.value) + localized_values = { + normalize_medium_key(translate(f"medium.{medium.name}", locale)) + for locale in ("en", "de", "es", "fr", "zh") + } + if normalized in {medium.name, enum_value, *localized_values}: + return Book.medium == medium + return None def _field_condition(field: str, value: str, user_id: int) -> Any | None: """Build the condition for a single field-specific term.""" if field == POSSESSION_PREFIX: return _possession_condition(value) + if field == MEDIUM_PREFIX: + return _medium_condition(value) if field == TAG_PREFIX: return _tag_condition(value, user_id) if field == AUTHOR_PREFIX: @@ -221,4 +242,4 @@ def apply_search_filter(statement: Any, query: str, user_id: int) -> Any: if conditions: return statement.where(sa.and_(*conditions)) - return statement \ No newline at end of file + return statement diff --git a/backend/app/services/statistics.py b/backend/app/services/statistics.py new file mode 100644 index 00000000..da1a9c77 --- /dev/null +++ b/backend/app/services/statistics.py @@ -0,0 +1,928 @@ +"""Shared statistics aggregation logic. + +The statistics computation lives here so that both the authenticated +statistics router and the public profile endpoint can reuse it for any user, +without coupling the public data path to FastAPI auth dependencies. +""" + +import calendar +from collections import Counter, defaultdict +from datetime import date, datetime, time, timedelta, timezone +from statistics import mean +from types import SimpleNamespace +from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import HTTPException +from sqlalchemy import func +from sqlmodel import Session, col, select + +from app.models import AcquisitionStatus, Author, Book, BookAuthor, Medium, ReadingProgress, ReadingStatus, UserSettings +from app.schemas import ( + AcquisitionStatusDistribution, + GoalProgress, + GoalType, + LanguageDistribution, + MediumDistribution, + MonthlyBooks, + MonthlyPages, + PageBuckets, + StatisticsRange, + StatisticsResponse, + StatusDistribution, + TopAuthor, + TopAuthorCover, + TopRatedBook, + YearlyBooks, +) +from app.services.authors import join_authors, load_authors_batch + +MAX_CUSTOM_RANGE_DAYS = 25 * 366 + + +def _zone_from_name(timezone_name: str | None) -> ZoneInfo: + """Return a ZoneInfo for *timezone_name*, falling back to UTC.""" + try: + return ZoneInfo(timezone_name or "UTC") + except ZoneInfoNotFoundError: + return ZoneInfo("UTC") + + +def _user_timezone(session: Session, user_id: int) -> ZoneInfo: + """Return the user's configured timezone, falling back to UTC.""" + settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() + return _zone_from_name(settings.timezone if settings else None) + + +def _month_key(dt: datetime, tz: ZoneInfo) -> str: + """Format a datetime as ``YYYY-MM`` in the given timezone.""" + local = dt.astimezone(tz) + return f"{local.year:04d}-{local.month:02d}" + + +def _month_range(start_key: str, end_key: str) -> list[str]: + """Generate a list of ``YYYY-MM`` keys from *start_key* to *end_key* inclusive.""" + start_year, start_month = map(int, start_key.split("-")) + end_year, end_month = map(int, end_key.split("-")) + keys: list[str] = [] + year, month = start_year, start_month + while (year < end_year) or (year == end_year and month <= end_month): + keys.append(f"{year:04d}-{month:02d}") + month += 1 + if month > 12: + month = 1 + year += 1 + return keys + + +def _clamp_window( + start: datetime, end: datetime, + window_start: datetime | None, window_end: datetime | None, +) -> tuple[datetime | None, datetime | None]: + """Clamp *start*/*end* to *window_start*/*window_end* if provided. + + Returns (clamped_start, clamped_end) or (None, None) when the span + does not overlap the window at all. + All returned datetimes are UTC-aware (matching the DB convention) + so callers can safely use .astimezone() and compare. + """ + if window_start is not None: + w_start = _naive_utc(window_start) + s = _naive_utc(start) + e = _naive_utc(end) + if e < w_start: + return (None, None) + if s < w_start: + start = w_start.replace(tzinfo=timezone.utc) + if window_end is not None: + w_end = _naive_utc(window_end) + s = _naive_utc(start) + e = _naive_utc(end) + if s > w_end: + return (None, None) + if e > w_end: + end = w_end.replace(tzinfo=timezone.utc) + return (start, end) + + +def _naive_utc(dt: datetime) -> datetime: + """Return a naive datetime representing the same instant as *dt* in UTC.""" + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + +def _statistics_window( + range_value: StatisticsRange, + custom_from: date | None, + custom_to: date | None, + tz: ZoneInfo, + now: datetime, +) -> tuple[datetime | None, datetime | None]: + """Return the inclusive statistics window as naive UTC datetimes. + + Returns ``(None, None)`` for "All time". For bounded ranges the window is + expressed in the user's timezone and converted to naive UTC to match the + DB filtering convention used by :func:`_clamp_window`. + + - Custom -> from start of the custom *from* day to end of the custom *to* + day (inclusive) in *tz*. + - This year -> the start of the current calendar year to ``now``. + - Last year -> the complete previous calendar year. + - Last 3 years -> the start of the calendar year two years ago to ``now``. + """ + if range_value == StatisticsRange.alltime: + return (None, None) + + if range_value == StatisticsRange.custom: + if custom_from is None or custom_to is None: + raise HTTPException(status_code=400, detail="Custom range requires both dates.") + if custom_from > custom_to: + raise HTTPException(status_code=400, detail="'from' cannot be after 'to'.") + if (custom_to - custom_from).days > MAX_CUSTOM_RANGE_DAYS: + raise HTTPException(status_code=400, detail="Custom range cannot exceed 25 years.") + start = datetime.combine(custom_from, time.min, tzinfo=tz) + end = datetime.combine(custom_to, time.max, tzinfo=tz) + return (_naive_utc(start), _naive_utc(end)) + + end = now + if range_value == StatisticsRange.this_year: + start = datetime(now.year, 1, 1, tzinfo=tz) + elif range_value == StatisticsRange.last_year: + start = datetime(now.year - 1, 1, 1, tzinfo=tz) + end = datetime(now.year - 1, 12, 31, 23, 59, 59, 999999, tzinfo=tz) + elif range_value == StatisticsRange.three_years: + start = datetime(now.year - 2, 1, 1, tzinfo=tz) + else: + start = now + return (_naive_utc(start), _naive_utc(end)) + + +def _extract_progress_daily_pages( + entries: list, tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> dict[str, float]: + """Distribute reading progress page-deltas across calendar days. + + When *window_start*/*window_end* are provided, only days within that + window are emitted. The daily average is still computed from the full + span so the values stay correct. + """ + daily: dict[str, float] = defaultdict(float) + grouped: dict[int, list] = {} + for entry in entries: + grouped.setdefault(entry.book_id, []).append(entry) + + for book_id in sorted(grouped): + book_entries = grouped[book_id] + book_entries.sort(key=lambda e: (e.created_at, e.page)) + for prev, curr in zip(book_entries, book_entries[1:]): + delta = curr.page - prev.page + if delta <= 0: + continue + prev_day = prev.created_at.astimezone(tz).date() + curr_day = curr.created_at.astimezone(tz).date() + day_diff = (curr_day - prev_day).days + 1 + if day_diff <= 0: + continue + daily_avg = delta / day_diff + start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) + if start is None or end is None: + continue + day = start.astimezone(tz).date() + last = end.astimezone(tz).date() + while day <= last: + daily[day.isoformat()] += daily_avg + day += timedelta(days=1) + + return daily + + +def _extract_book_level_daily_pages( + books: list[Book], tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> dict[str, float]: + """Distribute page counts across the reading period for books finished without progress entries. + + When *window_start*/*window_end* are provided, only days within that + window are emitted. The daily average is still computed from the full + span so the values stay correct. + """ + daily: dict[str, float] = defaultdict(float) + for book in books: + if not (book.date_started and book.date_finished and book.page_count): + continue + if book.date_finished < book.date_started: + continue + total_days = (book.date_finished - book.date_started).days + 1 + if total_days <= 0: + continue + daily_avg = book.page_count / total_days + start, end = _clamp_window(book.date_started, book.date_finished, window_start, window_end) + if start is None or end is None: + continue + while start <= end: + date_key = start.astimezone(tz).strftime("%Y-%m-%d") + daily[date_key] += daily_avg + start += timedelta(days=1) + return daily + + +def _allocate_daily_avg_across_months( + daily_avg: float, start: datetime, end: datetime, tz: ZoneInfo +) -> dict[str, float]: + """Spread a per-day value proportionally across months from *start* to *end* inclusive.""" + monthly: dict[str, float] = defaultdict(float) + current = start + while current <= end: + _, last_dom = calendar.monthrange(current.year, current.month) + period_end = min(current.replace(day=last_dom), end) + days = (period_end - current).days + 1 + month_key = _month_key(current, tz) + monthly[month_key] += daily_avg * days + current = period_end + timedelta(days=1) + return monthly + + +def _compute_pages_per_month_from_progress( + entries: list, tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> dict[str, float]: + """Compute pages read per month from reading progress entries. + + When *window_start*/*window_end* are provided, only the portion of each + reading span that overlaps the window is allocated to months. The daily + average is still computed from the full span so the values stay correct. + """ + monthly: dict[str, float] = defaultdict(float) + grouped: dict[int, list] = {} + for entry in entries: + grouped.setdefault(entry.book_id, []).append(entry) + for book_id in sorted(grouped): + book_entries = sorted(grouped[book_id], key=lambda e: (e.created_at, e.page)) + for prev, curr in zip(book_entries, book_entries[1:]): + delta = curr.page - prev.page + if delta <= 0: + continue + day_diff = (curr.created_at - prev.created_at).days + 1 + if day_diff <= 0: + continue + start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) + if start is None or end is None: + continue + m = _allocate_daily_avg_across_months(delta / day_diff, start, end, tz) + for k, v in m.items(): + monthly[k] += v + return monthly + + +def _compute_pages_per_month_from_books( + books: list[Book], tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> dict[str, float]: + """Compute pages read per month for finished books without progress entries. + + When *window_start*/*window_end* are provided, only the portion of each + book's reading period that overlaps the window is allocated to months. + The daily average is still computed from the full period so the values + stay correct. + """ + monthly: dict[str, float] = defaultdict(float) + for book in books: + if not (book.date_started and book.date_finished and book.page_count): + continue + if book.date_finished < book.date_started: + continue + total_days = (book.date_finished - book.date_started).days + 1 + if total_days <= 0: + continue + start, end = _clamp_window(book.date_started, book.date_finished, window_start, window_end) + if start is None or end is None: + continue + m = _allocate_daily_avg_across_months( + book.page_count / total_days, start, end, tz + ) + for k, v in m.items(): + monthly[k] += v + return monthly + + +def _day_key(dt: datetime, tz: ZoneInfo) -> str: + """Return the ``YYYY-MM-DD`` calendar day of *dt* in *tz*.""" + return dt.astimezone(tz).strftime("%Y-%m-%d") + + +def current_streak(active_dates: set[str], today: date) -> int: + """Return the number of consecutive active days ending at *today*. + + Today counts as the first day when it is active; otherwise the streak + starts at yesterday, so a not-yet-logged today does not break an ongoing + streak. The streak is 0 when neither today nor yesterday are active. + """ + streak = 0 + day = today + first = True + while True: + if day.isoformat() in active_dates: + streak += 1 + elif not first: + break + first = False + day -= timedelta(days=1) + return streak + + +def longest_streak(active_dates: set[str]) -> tuple[int, Optional[str], Optional[str]]: + """Return the longest consecutive run of active dates. + + Returns ``(length, start, end)`` with ``YYYY-MM-DD`` keys. Ties are + broken in favour of the most recent run. When there is no activity at + all the result is ``(0, None, None)``. + """ + if not active_dates: + return 0, None, None + ordered = sorted(active_dates) + best_len, best_start, best_end = 0, None, None + run_start = ordered[0] + run_len = 1 + prev = ordered[0] + for current in ordered[1:]: + if (date.fromisoformat(current) - date.fromisoformat(prev)).days == 1: + run_len += 1 + else: + if run_len >= best_len: + best_len, best_start, best_end = run_len, run_start, prev + run_start, run_len = current, 1 + prev = current + if run_len >= best_len: + best_len, best_start, best_end = run_len, run_start, prev + return best_len, best_start, best_end + + +def _pages_logged_on_day(entries: list, tz: ZoneInfo, day_key: str) -> int: + """Sum the positive page-deltas logged on *day_key*. + + A delta is the page gain between two consecutive progress entries of the + same book, attributed to the calendar day (in *tz*) of the later entry. + """ + grouped: dict[int, list] = {} + for entry in entries: + grouped.setdefault(entry.book_id, []).append(entry) + total = 0 + for book_entries in grouped.values(): + book_entries.sort(key=lambda e: (e.created_at, e.page)) + for prev, curr in zip(book_entries, book_entries[1:]): + delta = curr.page - prev.page + if delta > 0 and _day_key(curr.created_at, tz) == day_key: + total += delta + return total + + +def _compute_goal_progress( + tz: ZoneInfo, + settings: UserSettings, + today: datetime, + entries: list, + books: list, + book_ids_with_progress: set[int], +) -> list[GoalProgress]: + """Compute current progress for every enabled reading goal. + + Disabled goals are omitted from the result; the dashboard only shows + goals the user opted into. + """ + today_key = today.strftime("%Y-%m-%d") + current_month_key = today.strftime("%Y-%m") + current_year = today.year + + fallback_books = [ + b + for b in books + if b.id not in book_ids_with_progress + and b.reading_status == ReadingStatus.read + and b.date_started + and b.date_finished + and b.page_count + ] + + # Mirror compute_statistics: anchor every book with progress at page 0 on + # its start date so the first progress delta is attributed to the reading + # span, keeping the pages-per-month goal consistent with the statistics + # chart. + virtual_entries = [ + SimpleNamespace(book_id=b.id, page=0, created_at=b.date_started) + for b in books + if b.id in book_ids_with_progress + and b.date_started + and not (b.reading_status == ReadingStatus.read and not b.date_finished) + ] + + return _goal_results( + tz, today_key, current_month_key, current_year, + entries, books, fallback_books, virtual_entries, settings, + ) + + +def _goal_results( + tz: ZoneInfo, + today_key: str, + current_month_key: str, + current_year: int, + entries: list, + books: list, + fallback_books: list, + virtual_entries: list, + settings: UserSettings, +) -> list[GoalProgress]: + """Assemble enabled goal progress entries.""" + goals_spec = [ + (GoalType.pages_per_day, settings.goal_pages_per_day_enabled, settings.goal_pages_per_day), + (GoalType.pages_per_month, settings.goal_pages_per_month_enabled, settings.goal_pages_per_month), + (GoalType.books_per_month, settings.goal_books_per_month_enabled, settings.goal_books_per_month), + (GoalType.books_per_year, settings.goal_books_per_year_enabled, settings.goal_books_per_year), + ] + + results: list[GoalProgress] = [] + for goal_type, enabled, target in goals_spec: + if not enabled: + continue + current = _goal_current_value( + goal_type, tz, today_key, current_month_key, current_year, + entries, books, fallback_books, virtual_entries, + ) + results.append( + GoalProgress(type=goal_type, target=target, current=current, reached=current >= target) + ) + return results + + +def _goal_current_value( + goal_type: GoalType, + tz: ZoneInfo, + today_key: str, + current_month_key: str, + current_year: int, + entries: list, + books: list, + fallback_books: list, + virtual_entries: list, +) -> int: + """Return the current value for a single reading goal.""" + if goal_type == GoalType.pages_per_day: + total = _pages_logged_on_day(entries, tz, today_key) + for b in fallback_books: + if _day_key(b.date_finished, tz) == today_key: + total += b.page_count + return total + + if goal_type == GoalType.pages_per_month: + monthly = _compute_pages_per_month_from_progress(entries + virtual_entries, tz) + for k, v in _compute_pages_per_month_from_books(fallback_books, tz).items(): + monthly[k] += v + return int(round(monthly.get(current_month_key, 0))) + + if goal_type == GoalType.books_per_month: + return sum( + 1 + for b in books + if b.reading_status == ReadingStatus.read + and b.date_finished is not None + and _month_key(b.date_finished, tz) == current_month_key + ) + + if goal_type == GoalType.books_per_year: + return sum( + 1 + for b in books + if b.reading_status == ReadingStatus.read + and b.date_finished is not None + and b.date_finished.astimezone(tz).year == current_year + ) + + return 0 + + +def compute_statistics( + session: Session, + user_id: int, + range_value: StatisticsRange = StatisticsRange.alltime, + custom_from: Optional[date] = None, + custom_to: Optional[date] = None, +) -> StatisticsResponse: + """Compute the full statistics dashboard for *user_id*. + + Mirrors the authenticated ``/api/statistics`` endpoint so the public + profile can reuse the exact same aggregation for the link owner. + """ + if range_value == StatisticsRange.custom: + if custom_from is None or custom_to is None: + raise HTTPException( + status_code=400, + detail="Both 'from' and 'to' are required when range is 'custom'.", + ) + if custom_from > custom_to: + raise HTTPException( + status_code=400, + detail="'from' cannot be after 'to'.", + ) + else: + if custom_from is not None or custom_to is not None: + raise HTTPException( + status_code=400, + detail="'from'/'to' are only allowed when range is 'custom'.", + ) + + tz = _user_timezone(session, user_id) + now = datetime.now(tz) + window_start, window_end = _statistics_window( + range_value, custom_from, custom_to, tz, now + ) + current_month_key = f"{now.year:04d}-{now.month:02d}" + current_year = now.year + books = list(session.exec(select(Book).where(Book.user_id == user_id)).all()) + + total_authors = session.exec( + select(func.count()).select_from(Author).where(Author.user_id == user_id) + ).one() + + status_counts = Counter(book.reading_status for book in books) + status_distribution = StatusDistribution( + want_to_read=status_counts.get(ReadingStatus.want_to_read, 0), + currently_reading=status_counts.get(ReadingStatus.currently_reading, 0), + read=status_counts.get(ReadingStatus.read, 0), + did_not_finish=status_counts.get(ReadingStatus.did_not_finish, 0), + ) + + acquisition_counts = Counter(book.acquisition_status for book in books) + acquisition_status_distribution = AcquisitionStatusDistribution( + owned=acquisition_counts.get(AcquisitionStatus.owned, 0), + borrowed=acquisition_counts.get(AcquisitionStatus.borrowed, 0), + digital_access=acquisition_counts.get(AcquisitionStatus.digital_access, 0), + to_acquire=acquisition_counts.get(AcquisitionStatus.to_acquire, 0), + ) + + medium_distribution = [ + MediumDistribution( + medium=medium, + count=sum(1 for book in books if book.medium == medium), + ) + for medium in Medium + ] + unset_medium_count = sum(1 for book in books if book.medium is None) + if unset_medium_count: + medium_distribution.append(MediumDistribution(medium=None, count=unset_medium_count)) + + page_values = [book.page_count for book in books if book.page_count is not None] + avg_page_count = round(mean(page_values), 2) if page_values else None + + language_counts: Counter[str | None] = Counter(book.language for book in books) + language_distribution = [ + LanguageDistribution(language=language, count=count) + for language, count in sorted( + language_counts.items(), + key=lambda item: (-item[1], item[0] is None, item[0] or ""), + ) + ] + known_language_counts = [(code, count) for code, count in language_counts.items() if code] + known_language_counts.sort(key=lambda item: (-item[1], item[0])) + most_popular_language = known_language_counts[0][0] if known_language_counts else None + most_popular_language_count = known_language_counts[0][1] if known_language_counts else None + + pages_to_read = sum( + book.page_count or 0 + for book in books + if book.reading_status == ReadingStatus.want_to_read and book.page_count is not None + ) + pages_read = sum( + book.page_count or 0 + for book in books + if book.reading_status == ReadingStatus.read and book.page_count is not None + ) + + dnf_book_ids = [book.id for book in books if book.reading_status == ReadingStatus.did_not_finish and book.id is not None] + pages_wasted = 0 + if dnf_book_ids: + wasted_rows = session.exec( + select(ReadingProgress.book_id, func.max(ReadingProgress.page)) + .where( + ReadingProgress.user_id == user_id, + col(ReadingProgress.book_id).in_(dnf_book_ids), + ) + .group_by(col(ReadingProgress.book_id)) + ).all() + pages_wasted = int(sum((max_page or 0) for _, max_page in wasted_rows)) + + page_buckets = PageBuckets( + pages_to_read=int(pages_to_read), + pages_read=int(pages_read), + pages_wasted=pages_wasted, + ) + + all_finished_books = [ + book + for book in books + if book.reading_status == ReadingStatus.read and book.date_finished is not None + ] + finished_books_per_month_all_time: Counter[str] = Counter() + for book in all_finished_books: + assert book.date_finished is not None + finished_books_per_month_all_time[_month_key(book.date_finished, tz)] += 1 + + finished_books = all_finished_books + + if window_start is not None and window_end is not None: + finished_books = [ + book + for book in finished_books + if book.date_finished is not None + and _naive_utc(book.date_finished) >= window_start + and _naive_utc(book.date_finished) <= window_end + ] + + finished_books_per_month: Counter[str] = Counter() + for book in finished_books: + assert book.date_finished is not None + month = _month_key(book.date_finished, tz) + finished_books_per_month[month] += 1 + + # For bounded ranges the chart axis spans the whole selected window, so + # months/years outside any real data still appear (with zero counts). + if window_start is not None and window_end is not None: + window_start_aware = window_start.replace(tzinfo=timezone.utc) + window_end_aware = window_end.replace(tzinfo=timezone.utc) + window_start_month_key = _month_key(window_start_aware, tz) + window_end_month_key = _month_key(window_end_aware, tz) + window_start_year = window_start_aware.astimezone(tz).year + window_end_year = window_end_aware.astimezone(tz).year + else: + window_start_month_key = None + window_end_month_key = None + window_start_year = None + window_end_year = None + + if window_start is not None and window_end is not None: + # Only books with at least one progress entry inside the window can + # contribute pages to the window; load their full entry chains so the + # prev→curr deltas and day spans are complete. Mirrors pages-per-day. + book_ids_with_window_progress = set( + session.exec( + select(ReadingProgress.book_id) + .where( + ReadingProgress.user_id == user_id, + ReadingProgress.created_at >= window_start, + ) + .distinct() + ).all() + ) + if book_ids_with_window_progress: + progress_entries = list( + session.exec( + select(ReadingProgress) + .where( + ReadingProgress.user_id == user_id, + col(ReadingProgress.book_id).in_(book_ids_with_window_progress), + ) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) + ).all() + ) + else: + progress_entries = [] + else: + progress_entries = list( + session.exec( + select(ReadingProgress) + .where(ReadingProgress.user_id == user_id) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) + ).all() + ) + + # All book_ids with *any* progress entry — used to exclude books from the + # fallback computation and to build virtual entries. + all_book_ids_with_progress = set( + session.exec( + select(ReadingProgress.book_id) + .where(ReadingProgress.user_id == user_id) + .distinct() + ).all() + ) + + virtual_entries = [] + for book in books: + if book.id not in all_book_ids_with_progress or not book.date_started: + continue + if book.reading_status == ReadingStatus.read and not book.date_finished: + continue + virtual_entries.append( + SimpleNamespace( + book_id=book.id, + page=0, + created_at=book.date_started, + ) + ) + + all_progress_entries = list(progress_entries) + virtual_entries + pages_read_per_month_counter = _compute_pages_per_month_from_progress( + all_progress_entries, tz, window_start, window_end + ) + + fallback_books = [ + b + for b in books + if b.id not in all_book_ids_with_progress + and b.reading_status == ReadingStatus.read + and b.date_started + and b.date_finished + and b.page_count + ] + fallback_monthly = _compute_pages_per_month_from_books( + fallback_books, tz, window_start, window_end + ) + for k, v in fallback_monthly.items(): + pages_read_per_month_counter[k] += v + + if finished_books_per_month_all_time: + avg_books_per_month = round( + sum(finished_books_per_month_all_time.values()) / len(finished_books_per_month_all_time), + 2, + ) + busiest_month, busiest_month_count = min( + ( + (month, count) + for month, count in finished_books_per_month_all_time.items() + ), + key=lambda item: (-item[1], item[0]), + ) + else: + avg_books_per_month = None + busiest_month = None + busiest_month_count = None + + if finished_books_per_month or (window_start_month_key is not None and window_end_month_key is not None): + if window_start_month_key is not None and window_end_month_key is not None: + month_keys = _month_range(window_start_month_key, window_end_month_key) + else: + month_keys = _month_range(min(finished_books_per_month), max(max(finished_books_per_month), current_month_key)) + books_finished_per_month = [ + MonthlyBooks(month=month, count=finished_books_per_month.get(month, 0)) for month in month_keys + ] + else: + books_finished_per_month = [] + + if pages_read_per_month_counter or (window_start_month_key is not None and window_end_month_key is not None): + if window_start_month_key is not None and window_end_month_key is not None: + month_keys = _month_range(window_start_month_key, window_end_month_key) + else: + all_months = set(pages_read_per_month_counter) | {current_month_key} + if finished_books_per_month: + all_months |= set(finished_books_per_month) + month_keys = _month_range(min(all_months), max(all_months)) + pages_read_per_month = [ + MonthlyPages(month=month, pages=int(round(pages_read_per_month_counter.get(month, 0)))) for month in month_keys + ] + else: + pages_read_per_month = [] + + if finished_books_per_month or (window_start_year is not None and window_end_year is not None): + yearly_counts: Counter[int] = Counter() + for month_key, count in finished_books_per_month.items(): + yearly_counts[int(month_key.split("-")[0])] += count + if window_start_year is not None and window_end_year is not None: + year_start = window_start_year + year_end = window_end_year + else: + year_start = min(yearly_counts) if yearly_counts else current_year + year_end = max(max(yearly_counts), current_year) if yearly_counts else current_year + books_finished_per_year = [ + YearlyBooks(year=year, count=yearly_counts.get(year, 0)) + for year in range(year_start, year_end + 1) + ] + else: + books_finished_per_year = [] + + author_count_label = func.count(func.distinct(BookAuthor.book_id)).label("cnt") + author_count_rows = session.exec( + select(Author.name, author_count_label) + .join(BookAuthor, col(BookAuthor.author_id) == col(Author.id)) + .join(Book, col(Book.id) == col(BookAuthor.book_id)) + .where(Book.user_id == user_id) + .group_by(col(Author.id)) + .order_by(author_count_label.desc(), col(Author.name).asc()) + .limit(3) + ).all() + author_counts = Counter({name: count for name, count in author_count_rows}) + + top_authors: list[TopAuthor] = [] + if author_counts: + top_author_counts = author_counts.most_common(3) + top_author_names = [name for name, _ in top_author_counts] + + covers_by_author: dict[str, list[TopAuthorCover]] = {} + for author_name in top_author_names: + max_slots = min(5, author_counts[author_name]) + book_ids_with_author = select(BookAuthor.book_id).join( + Author, col(Author.id) == col(BookAuthor.author_id) + ).where( + Author.user_id == user_id, + Author.name == author_name, + ) + cover_rows = session.exec( + select(Book.id, Book.title, Book.reading_status, Book.cover_url) + .where( + Book.user_id == user_id, + col(Book.id).in_(book_ids_with_author), + col(Book.cover_url).is_not(None), + ) + .order_by(col(Book.id)) + .limit(max_slots) + ).all() + results = [ + TopAuthorCover(book_id=book_id, title=title, reading_status=reading_status, cover_url=cover_url) + for book_id, title, reading_status, cover_url in cover_rows + if book_id is not None + ] + remaining = max_slots - len(results) + if remaining > 0: + no_cover_rows = session.exec( + select(Book.id, Book.title, Book.reading_status, Book.cover_url) + .where( + Book.user_id == user_id, + col(Book.id).in_(book_ids_with_author), + col(Book.cover_url).is_(None), + ) + .order_by(col(Book.id)) + .limit(remaining) + ).all() + results.extend( + TopAuthorCover(book_id=book_id, title=title, reading_status=reading_status, cover_url=cover_url) + for book_id, title, reading_status, cover_url in no_cover_rows + if book_id is not None + ) + covers_by_author[author_name] = results + + top_authors = [ + TopAuthor( + author=author_name, + book_count=author_count, + covers=covers_by_author.get(author_name, []), + ) + for author_name, author_count in top_author_counts + ] + + # --- Rating stats --- + books_with_rating = sum(1 for b in books if b.rating is not None) + books_without_rating = sum(1 for b in books if b.rating is None) + rating_values = [b.rating for b in books if b.rating is not None] + average_rating = round(mean(rating_values), 2) if rating_values else None + + rated_books = [b for b in books if b.rating is not None] + rated_book_ids = [b.id for b in rated_books if b.id is not None] + rated_authors_map = load_authors_batch(session, rated_book_ids) + + def _rating_sort_key(book: Book) -> tuple[int, float]: + assert book.rating is not None + return (book.rating, -(book.date_added or datetime.min).timestamp()) + + # Top rated: highest rating first; ties broken by newest-added first. + top_rated_books = [] + for b in sorted(rated_books, key=lambda x: (-_rating_sort_key(x)[0], _rating_sort_key(x)[1])): + assert b.id is not None + assert b.rating is not None + author_names = rated_authors_map.get(b.id, []) + top_rated_books.append( + TopRatedBook(book_id=b.id, title=b.title or "", author=join_authors(author_names), authors=author_names, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + ) + + # Worst rated: lowest rating first; ties broken by newest-added first. + worst_rated_books = [] + for b in sorted(rated_books, key=_rating_sort_key): + assert b.id is not None + assert b.rating is not None + author_names = rated_authors_map.get(b.id, []) + worst_rated_books.append( + TopRatedBook(book_id=b.id, title=b.title or "", author=join_authors(author_names), authors=author_names, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + ) + + return StatisticsResponse( + total_books=len(books), + total_authors=total_authors, + avg_books_per_month=avg_books_per_month, + busiest_month=busiest_month, + busiest_month_count=busiest_month_count, + avg_page_count=avg_page_count, + most_popular_language=most_popular_language, + most_popular_language_count=most_popular_language_count, + language_distribution=language_distribution, + status_distribution=status_distribution, + acquisition_status_distribution=acquisition_status_distribution, + medium_distribution=medium_distribution, + page_buckets=page_buckets, + pages_read_per_month=pages_read_per_month, + books_finished_per_month=books_finished_per_month, + books_finished_per_year=books_finished_per_year, + top_authors=top_authors, + books_with_rating=books_with_rating, + books_without_rating=books_without_rating, + average_rating=average_rating, + top_rated_books=top_rated_books, + worst_rated_books=worst_rated_books, + ) 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_book_import.py b/backend/tests/test_book_import.py index 023fde88..2cfb7850 100644 --- a/backend/tests/test_book_import.py +++ b/backend/tests/test_book_import.py @@ -1006,22 +1006,23 @@ def test_hardcover_dedup_key_full() -> None: assert key == ("9781234567897", 300, "en") -# ── _merge_and_deduplicate ───────────────────────────────────────────────────── +# ── _merge_results ───────────────────────────────────────────────────────────── -def test_merge_and_deduplicate_cover_preference() -> None: +def test_merge_results_preserves_all_candidates() -> None: a = BookImportCandidate(title="A", isbn="123", cover_url=None, source="ol") b = BookImportCandidate(title="B", isbn="123", cover_url="https://x.jpg", source="gb") - result = bi._merge_and_deduplicate([a], [b]) - assert len(result) == 1 - assert result[0].cover_url == "https://x.jpg" - - -def test_merge_and_deduplicate_no_cover_override() -> None: - a = BookImportCandidate(title="A", isbn="123", cover_url="https://a.jpg", source="ol") - b = BookImportCandidate(title="B", isbn="123", cover_url="https://b.jpg", source="gb") - result = bi._merge_and_deduplicate([a], [b]) - assert len(result) == 1 - assert result[0].cover_url == "https://a.jpg" + result = bi._merge_results([a], [b]) + assert len(result) == 2 + assert result[0] is a + assert result[1] is b + + +def test_merge_results_preserves_order() -> None: + a = BookImportCandidate(title="A", isbn="123", source="ol") + b = BookImportCandidate(title="B", isbn="123", source="gb") + c = BookImportCandidate(title="C", isbn="456", source="hc") + result = bi._merge_results([a, b], [c]) + assert [r.title for r in result] == ["A", "B", "C"] # ── _pick_isbn ───────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index c2eb0c65..cca6d788 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -35,6 +35,39 @@ def test_create_book_returns_201(client: TestClient) -> None: assert data["reading_status"] == "want_to_read" +def test_create_and_filter_books_by_medium(client: TestClient) -> None: + created = _create_book(client, title="Audio", medium="Audiobook") + assert created["medium"] == "Audiobook" + assert _create_book(client, title="Print", medium="Print")["medium"] == "Print" + + response = client.get("/api/books?medium=Audiobook") + assert response.status_code == 200 + assert [book["title"] for book in response.json()["books"]] == ["Audio"] + + search = client.get("/api/books?q=medium:audiobook") + assert search.status_code == 200 + assert [book["title"] for book in search.json()["books"]] == ["Audio"] + + +def test_update_book_medium_can_be_set_and_cleared(client: TestClient) -> None: + book = _create_book(client, medium="Print") + updated = client.patch(f"/api/books/{book['id']}", json={"medium": "audiobook"}) + assert updated.status_code == 200 + assert updated.json()["medium"] == "Audiobook" + + cleared = client.patch(f"/api/books/{book['id']}", json={"medium": None}) + assert cleared.status_code == 200 + assert cleared.json()["medium"] is None + + +def test_create_book_rejects_invalid_medium(client: TestClient) -> None: + response = client.post( + "/api/books", + json={"title": "Invalid", "author": "Author", "page_count": 100, "medium": "vinyl"}, + ) + assert response.status_code == 422 + + def test_create_book_with_all_fields(client: TestClient) -> None: payload = { "title": "Dune", @@ -630,6 +663,96 @@ def test_update_book_sets_date_finished_when_moving_to_read(client: TestClient, assert resp.json()["date_finished"].startswith("2026-05-11T10:30:00") +def test_transition_status_respects_disabled_date_automation(client: TestClient) -> None: + settings_response = client.patch( + "/api/profile/settings", + json={"auto_set_date_started": False, "auto_set_date_finished": False}, + ) + assert settings_response.status_code == 200 + + book = _create_book(client, title="Undated transition") + reading = client.post( + f"/api/books/{book['id']}/transition-status", + json={"new_status": "currently_reading"}, + ) + assert reading.status_code == 200 + assert reading.json()["book"]["date_started"] is None + + finished = client.post( + f"/api/books/{book['id']}/transition-status", + json={"new_status": "read"}, + ) + assert finished.status_code == 200 + assert finished.json()["book"]["date_finished"] is None + + +def test_transition_status_respects_each_date_automation_setting_independently(client: TestClient) -> None: + settings_response = client.patch( + "/api/profile/settings", + json={"auto_set_date_started": False, "auto_set_date_finished": True}, + ) + assert settings_response.status_code == 200 + + book = _create_book(client, title="Independent start setting") + reading = client.post( + f"/api/books/{book['id']}/transition-status", + json={"new_status": "currently_reading"}, + ) + assert reading.status_code == 200 + assert reading.json()["book"]["date_started"] is None + + finished = client.post( + f"/api/books/{book['id']}/transition-status", + json={"new_status": "read"}, + ) + assert finished.status_code == 200 + assert finished.json()["book"]["date_finished"] is not None + + settings_response = client.patch( + "/api/profile/settings", + json={"auto_set_date_started": True, "auto_set_date_finished": False}, + ) + assert settings_response.status_code == 200 + + second_book = _create_book(client, title="Independent finish setting") + second_reading = client.post( + f"/api/books/{second_book['id']}/transition-status", + json={"new_status": "currently_reading"}, + ) + assert second_reading.status_code == 200 + assert second_reading.json()["book"]["date_started"] is not None + + second_finished = client.post( + f"/api/books/{second_book['id']}/transition-status", + json={"new_status": "read"}, + ) + assert second_finished.status_code == 200 + assert second_finished.json()["book"]["date_finished"] is None + + +def test_update_book_respects_disabled_date_automation(client: TestClient) -> None: + settings_response = client.patch( + "/api/profile/settings", + json={"auto_set_date_started": False, "auto_set_date_finished": False}, + ) + assert settings_response.status_code == 200 + + book = _create_book(client, title="Undated update") + reading = client.patch( + f"/api/books/{book['id']}", + json={"reading_status": "currently_reading"}, + ) + assert reading.status_code == 200 + assert reading.json()["date_started"] is None + + finished = client.patch( + f"/api/books/{book['id']}", + json={"reading_status": "read"}, + ) + assert finished.status_code == 200 + assert finished.json()["date_finished"] is None + + def test_update_book_does_not_override_existing_date_started(client: TestClient, monkeypatch: MonkeyPatch) -> None: book = _create_book( client, @@ -885,6 +1008,37 @@ def test_transition_status_chained_detects_started_after_finished(client: TestCl } +def test_transition_status_keeps_started_after_finished_conflict_with_auto_start_disabled( + client: TestClient, +) -> None: + settings_response = client.patch( + "/api/profile/settings", + json={"auto_set_date_started": False}, + ) + assert settings_response.status_code == 200 + + book = _create_book( + client, + title="Explicit invalid start", + reading_status="read", + date_started="2024-01-01", + date_finished="2024-02-02", + ) + response = client.post( + f"/api/books/{book['id']}/transition-status", + json={ + "new_status": "currently_reading", + "force_date_started": "2026-05-11T10:30:00Z", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["book"]["reading_status"] == "read" + assert data["book"]["date_started"] == "2024-01-01T00:00:00Z" + assert data["book"]["date_finished"] == "2024-02-02T00:00:00Z" + assert data["date_conflict"]["field"] == "started_after_finished" + + def test_transition_status_chained_option_a_clear_finished(client: TestClient, monkeypatch: MonkeyPatch) -> None: """Resolve started_after_finished with force_date_started + skip → clears date_finished.""" book = _create_book( @@ -1385,6 +1539,21 @@ def test_update_book_rejects_clearing_date_finished_for_read(client: TestClient) assert resp.json()["detail"] == "A finished book must have an end date. Change the status if you want to remove the finish date." +def test_update_book_allows_clearing_date_finished_when_automation_is_disabled(client: TestClient) -> None: + settings_response = client.patch( + "/api/profile/settings", + json={"auto_set_date_finished": False}, + ) + assert settings_response.status_code == 200 + + book = _create_book(client, title="Optional Finish Date", reading_status="read", date_finished="2024-06-01") + resp = client.patch(f"/api/books/{book['id']}", json={"date_finished": None}) + + assert resp.status_code == 200 + assert resp.json()["reading_status"] == "read" + assert resp.json()["date_finished"] is None + + def test_update_book_allows_clearing_date_finished_when_changing_status(client: TestClient) -> None: book = _create_book(client, title="Change Status", reading_status="read", date_finished="2024-06-01") diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index d04d291d..743d3d6c 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -24,7 +24,7 @@ def _parse_sse(text: str) -> list[dict[str, str | int | bool | None]]: def test_data_export_zip_contains_manifest_and_books_json(client: TestClient) -> None: create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read", "medium": "Print"}, ) assert create_resp.status_code == 201 @@ -46,12 +46,13 @@ def test_data_export_zip_contains_manifest_and_books_json(client: TestClient) -> assert manifest["counts"]["books"] == 1 books = json.loads(zf.read("books.json")) assert books[0]["title"] == "Dune" + assert books[0]["medium"] == "Print" def test_data_export_csv_format(client: TestClient) -> None: create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read", "medium": "Audiobook"}, ) assert create_resp.status_code == 201 @@ -68,6 +69,8 @@ def test_data_export_csv_format(client: TestClient) -> None: assert "tags.csv" in names books_csv = zf.read("books.csv").decode() assert "title,subtitle" in books_csv + assert "medium" in books_csv.splitlines()[0] + assert "Audiobook" in books_csv assert "Dune" in books_csv @@ -224,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 @@ -282,6 +287,36 @@ def test_data_import_validate_and_execute_continue_on_error(client: TestClient, assert complete["failed"] == 1 +def test_data_import_preview_allows_read_book_without_finished_date( + client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path, +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp_dir")) + csv_payload = "Title,Author,Status,Availability\nDune,Frank Herbert,read,owned\n" + parse_resp = client.post( + "/api/data/import/parse", + files={"file": ("books.csv", csv_payload, "text/csv")}, + ) + file_id = parse_resp.json()["file_id"] + + preview_resp = client.post( + "/api/data/import/preview", + json={ + "file_id": file_id, + "mapping": { + "title": {"source": "Title", "transform": None}, + "author": {"source": "Author", "transform": None}, + "reading_status": {"source": "Status", "transform": None}, + "acquisition_status": {"source": "Availability", "transform": None}, + }, + }, + ) + assert preview_resp.status_code == 200 + preview = preview_resp.json() + assert preview["errors"] == [] + assert preview["preview_rows"][0]["errors"] == [] + assert any("no finished date" in warning for warning in preview["preview_rows"][0]["warnings"]) + + def test_data_import_execute_rollback_all_rolls_back(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) csv_payload = "Title,Author\nDune,Frank Herbert\n,Missing\n" @@ -441,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: @@ -617,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 d9ea2869..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 @@ -1216,6 +1329,17 @@ def test_parse_acquisition_status_missing_value() -> None: di._parse_acquisition_status(" ") +def test_parse_medium_accepts_optional_display_and_key_values() -> None: + assert di._parse_medium(None) is None + assert di._parse_medium("") is None + audiobook = di._parse_medium("Audiobook") + comic = di._parse_medium("comic_graphic_novel") + assert audiobook is not None and audiobook.value == "Audiobook" + assert comic is not None and comic.value == "Comic / Graphic Novel" + with pytest.raises(ValueError, match="Invalid value for 'medium'"): + di._parse_medium("unknown") + + # ── _mapped_row ─────────────────────────────────────────────────────────────── def test_mapped_row_transform_execution_error() -> None: @@ -1482,7 +1606,8 @@ def test_preview_import_read_without_finished_date( result = di.preview_import( file_id, user, {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status")} ) - assert any("no finished date" in e for e in result["preview_rows"][0]["errors"]) + assert result["preview_rows"][0]["errors"] == [] + assert any("no finished date" in warning for warning in result["preview_rows"][0]["warnings"]) def test_preview_import_require_acquisition_status_invalid( @@ -1693,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/backend/tests/test_gamification.py b/backend/tests/test_gamification.py index 0d426199..eab4776a 100644 --- a/backend/tests/test_gamification.py +++ b/backend/tests/test_gamification.py @@ -6,7 +6,7 @@ from sqlmodel import Session, select from app.models import Book, ReadingProgress, UserSettings -from app.routers.statistics import current_streak, longest_streak +from app.services.statistics import current_streak, longest_streak def _create_book(client: Any, **overrides: Any) -> dict[str, Any]: diff --git a/backend/tests/test_hygiene.py b/backend/tests/test_hygiene.py index 8928b334..25defe0d 100644 --- a/backend/tests/test_hygiene.py +++ b/backend/tests/test_hygiene.py @@ -5,7 +5,7 @@ from pytest import MonkeyPatch from sqlmodel import Session, col, select -from app.models import Author, Book, BookAuthor, ReadingStatus, User +from app.models import Author, Book, BookAuthor, Medium, ReadingStatus, User from app.routers import hygiene as hygiene_router from app.services.authors import normalize_author_list @@ -28,6 +28,7 @@ def _create_book(session: Session, user_id: int, **overrides: object) -> Book: "blurb": "A test book.", "cover_url": None, "reading_status": ReadingStatus.want_to_read, + "medium": Medium.print, "user_id": user_id, } defaults.update(overrides) @@ -158,6 +159,19 @@ def test_missing_page_count_zero_treated_as_missing(self, client: TestClient, se assert data["total"] == 1 assert data["books"][0]["title"] == "Zero Pages" + def test_missing_medium(self, client: TestClient, session: Session) -> None: + """Medium is reported as missing when it has not been set.""" + user_id = 1 + _create_book(session, user_id, title="Print book", medium=Medium.print) + _create_book(session, user_id, title="Missing medium", medium=None) + + resp = client.get("/api/hygiene/missing?attributes=medium") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["books"][0]["title"] == "Missing medium" + assert data["total_missing_per_attribute"]["medium"] == 1 + class TestBatchUpdate: def test_batch_update_single_field(self, client: TestClient, session: Session) -> None: diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index f5d97559..60646a33 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -289,77 +289,44 @@ def test_map_hardcover_language_uppercased() -> None: assert result.language == "DE" -# ── _merge_and_deduplicate unit tests ──────────────────────────────────────── - -def _make_candidate(title: str, isbn: str | None = None, pages: int | None = None, lang: str | None = None) -> BookImportCandidate: - """Create a BookImportCandidate with default values for reuse in dedup tests.""" +# ── _merge_results unit tests ───────────────────────────────────────────────── + +def _make_candidate( + title: str, + isbn: str | None = None, + pages: int | None = None, + lang: str | None = None, + source: str = "open_library", +) -> BookImportCandidate: + """Create a BookImportCandidate with default values for reuse in merge tests.""" return BookImportCandidate( title=title, author="Author", isbn=isbn, page_count=pages, language=lang, - source="open_library", + source=source, ) -def test_merge_and_dedup_same_isbn_pages_lang() -> None: - a = _make_candidate("Dune", "9780441013593", 412, "EN") - b = _make_candidate("Dune", "9780441013593", 412, "EN") - result = book_import._merge_and_deduplicate([a], [b]) - assert len(result) == 1 - assert result[0].title == "Dune" - - -def test_merge_and_dedup_same_isbn_diff_pages() -> None: +def test_merge_results_preserves_all_candidates() -> None: a = _make_candidate("Dune", "9780441013593", 412, "EN") - b = _make_candidate("Dune HC", "9780441013593", 688, "EN") - result = book_import._merge_and_deduplicate([a], [b]) + b = _make_candidate("Dune", "9780441013593", 412, "EN", source="hardcover") + result = book_import._merge_results([a], [b]) assert len(result) == 2 + assert result[0] is a + assert result[1] is b -def test_merge_and_dedup_same_isbn_diff_lang() -> None: - a = _make_candidate("Dune", "9780441013593", 412, "EN") - b = _make_candidate("Dune DE", "9780441013593", 412, "DE") - result = book_import._merge_and_deduplicate([a], [b]) - assert len(result) == 2 - - -def test_merge_and_dedup_ol_first_order() -> None: +def test_merge_results_preserves_order() -> None: ol = _make_candidate("OL Book", "9781111111111", 200, "EN") - hc = _make_candidate("HC Book", "9782222222222", 300, "DE") - result = book_import._merge_and_deduplicate([ol], [hc]) - assert len(result) == 2 + hc = _make_candidate("HC Book", "9782222222222", 300, "DE", source="hardcover") + gb = _make_candidate("GB Book", "9783333333333", 250, "FR", source="google_books") + result = book_import._merge_results([ol], [hc, gb]) + assert len(result) == 3 assert result[0].title == "OL Book" assert result[1].title == "HC Book" - - -def test_merge_and_dedup_prefers_candidate_with_cover() -> None: - a = _make_candidate("No Cover", "9780441013593", 412, "EN") - b = _make_candidate("Has Cover", "9780441013593", 412, "EN") - b.cover_url = "https://example.com/cover.jpg" - result = book_import._merge_and_deduplicate([a], [b]) - assert len(result) == 1 - assert result[0].title == "Has Cover" - - -def test_merge_and_dedup_prefers_cover_when_primary_missing_cover() -> None: - a = _make_candidate("OL No Cover", "9780441013593", 412, "EN") - b = _make_candidate("HC Has Cover", "9780441013593", 412, "EN") - b.cover_url = "https://example.com/cover.jpg" - result = book_import._merge_and_deduplicate([a], [b]) - assert len(result) == 1 - assert result[0].title == "HC Has Cover" - - -def test_merge_and_dedup_keeps_primary_cover_when_both_have_cover() -> None: - a = _make_candidate("OL Cover", "9780441013593", 412, "EN") - a.cover_url = "https://ol-cover.jpg" - b = _make_candidate("HC Cover", "9780441013593", 412, "EN") - b.cover_url = "https://hc-cover.jpg" - result = book_import._merge_and_deduplicate([a], [b]) - assert len(result) == 1 - assert result[0].title == "OL Cover" + assert result[2].title == "GB Book" # ── _hardcover_dedup_key tests ─────────────────────────────────────────────── diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 5f0f383f..02cc06f8 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -42,6 +42,8 @@ def test_get_settings_creates_default_when_missing(client: TestClient, session: data = resp.json() assert data["language"] == "en" assert data["user_id"] == user.id + assert data["auto_set_date_started"] is True + assert data["auto_set_date_finished"] is True def test_update_settings_creates_default_when_missing(client: TestClient, session: Session) -> None: @@ -70,6 +72,87 @@ def test_update_settings_creates_default_when_missing(client: TestClient, sessio assert data["user_id"] == user.id +def test_statistics_range_settings_are_persisted(client: TestClient) -> None: + response = client.patch( + "/api/profile/settings", + json={ + "statistics_range": "custom", + "statistics_custom_from": "2026-01-01", + "statistics_custom_to": "2026-02-01", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["statistics_range"] == "custom" + assert data["statistics_custom_from"] == "2026-01-01" + assert data["statistics_custom_to"] == "2026-02-01" + + restored = client.get("/api/profile/settings") + assert restored.status_code == 200 + assert restored.json()["statistics_range"] == "custom" + + +def test_reading_date_automation_settings_are_persisted(client: TestClient) -> None: + response = client.patch( + "/api/profile/settings", + json={"auto_set_date_started": False, "auto_set_date_finished": False}, + ) + assert response.status_code == 200 + data = response.json() + assert data["auto_set_date_started"] is False + assert data["auto_set_date_finished"] is False + + restored = client.get("/api/profile/settings") + assert restored.status_code == 200 + assert restored.json()["auto_set_date_started"] is False + assert restored.json()["auto_set_date_finished"] is False + + partial = client.patch( + "/api/profile/settings", + json={"auto_set_date_started": True}, + ) + assert partial.status_code == 200 + assert partial.json()["auto_set_date_started"] is True + assert partial.json()["auto_set_date_finished"] is False + + null_value = client.patch( + "/api/profile/settings", + json={"auto_set_date_finished": None}, + ) + assert null_value.status_code == 422 + + +def test_statistics_range_settings_reject_invalid_dates(client: TestClient) -> None: + response = client.patch( + "/api/profile/settings", + json={ + "statistics_range": "custom", + "statistics_custom_from": "2026-03-01", + "statistics_custom_to": "2026-02-01", + }, + ) + assert response.status_code == 422 + + incomplete = client.patch( + "/api/profile/settings", + json={"statistics_range": "custom", "statistics_custom_from": "2026-01-01", "statistics_custom_to": None}, + ) + assert incomplete.status_code == 422 + + excessive = client.patch( + "/api/profile/settings", + json={ + "statistics_range": "custom", + "statistics_custom_from": "1900-01-01", + "statistics_custom_to": "2026-02-01", + }, + ) + assert excessive.status_code == 422 + + null_range = client.patch("/api/profile/settings", json={"statistics_range": None}) + assert null_range.status_code == 422 + + def test_reset_data_rolls_back_on_exception(client: TestClient, monkeypatch) -> None: """An exception during data reset should be propagated.""" import app.routers.profile as profile_module diff --git a/backend/tests/test_public_profile.py b/backend/tests/test_public_profile.py new file mode 100644 index 00000000..da836fab --- /dev/null +++ b/backend/tests/test_public_profile.py @@ -0,0 +1,480 @@ +"""Tests for public profile share links — management CRUD and the public endpoint.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlmodel import Session, select + +from app.auth import hash_public_profile_token +from app.models import AcquisitionStatus, Book, PublicProfileLink, ReadingStatus +from app.time_utils import utcnow + + +def _create_share_link(client: Any, **overrides: Any) -> dict[str, Any]: + """Create a share link via the API and return the JSON response.""" + payload = { + "name": "My Profile", + "visibility_config": { + "sections": ["username", "currently_reading", "statistics"], + "statistics": ["total_books", "status_distribution"], + }, + **overrides, + } + resp = client.post("/api/profile/share-links", json=payload) + assert resp.status_code == 201 + return resp.json() + + +def _public_profile(client: Any, token: str) -> Any: + """Call the public profile endpoint with the raw token.""" + return client.get(f"/api/public-profiles/{token}") + + +def _create_book(client: Any, title: str = "Book", **overrides: Any) -> dict[str, Any]: + """Create a book via the API and return the JSON response.""" + payload = {"title": title, "authors": ["Test Author"], "page_count": 100, **overrides} + resp = client.post("/api/books", json=payload) + assert resp.status_code == 201 + return resp.json() + + +def test_share_link_language_roundtrip(client: Any) -> None: + """Language is set on create, appears in list, and survives an update.""" + data = _create_share_link(client, language="de") + assert data["link"]["language"] == "de" + + listed = client.get("/api/profile/share-links") + assert listed.json()[0]["language"] == "de" + + link_id = data["link"]["id"] + resp = client.patch(f"/api/profile/share-links/{link_id}", json={"language": "fr"}) + assert resp.status_code == 200 + assert resp.json()["language"] == "fr" + + public = _public_profile(client, data["token"]) + assert public.status_code == 200 + assert public.json()["language"] == "fr" + + +def test_create_share_link_returns_token_once(client: Any) -> None: + data = _create_share_link(client) + assert data["token"].startswith("lp_") + assert data["link"]["name"] == "My Profile" + assert data["link"]["token_prefix"] == data["token"][:12] + assert data["link"]["visibility_config"]["sections"] == [ + "username", + "currently_reading", + "statistics", + ] + + # Listing must not expose the full token. + listed = client.get("/api/profile/share-links") + assert listed.status_code == 200 + items = listed.json() + assert len(items) == 1 + assert "token" not in items[0] + assert items[0]["token_prefix"] == data["token"][:12] + + +def test_list_share_links_isolated(client: Any, create_user_with_key: Any) -> None: + _create_share_link(client) + created = client.get("/api/profile/share-links").json() + assert len(created) == 1 + link_id = created[0]["id"] + + user_b, key_b = create_user_with_key(email="other@example.com") + + # User B's key cannot see A's links. + assert client.get( + "/api/profile/share-links", headers={"X-API-Key": key_b} + ).json() == [] + + # User B's key cannot modify or delete A's links. + assert ( + client.delete( + f"/api/profile/share-links/{link_id}", headers={"X-API-Key": key_b} + ).status_code + == 404 + ) + assert ( + client.patch( + f"/api/profile/share-links/{link_id}", + headers={"X-API-Key": key_b}, + json={"name": "Hijacked"}, + ).status_code + == 404 + ) + assert client.get("/api/profile/share-links").json()[0]["id"] == link_id + + +def _public_profile_with_client(client: Any, token: str, x_api_key: str | None = None) -> Any: + """Call public endpoint with an explicit API key header. + + The client fixture always attaches the owner's key; when *x_api_key* is + None that header is removed so the request is truly anonymous. + """ + if x_api_key is None: + client.headers.pop("X-API-Key", None) + return client.get(f"/api/public-profiles/{token}") + return client.get(f"/api/public-profiles/{token}", headers={"X-API-Key": x_api_key}) + + +def test_public_profile_success(client: Any) -> None: + _create_book(client, title="Currently Reading", reading_status="currently_reading") + _create_book(client, title="Finished", reading_status="read", date_finished=utcnow().isoformat()) + + data = _create_share_link(client) + token = data["token"] + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + body = resp.json() + assert body["owner"] == {"firstname": "Test", "lastname": "User"} + assert body["audience"] == "public" + assert body["visibility_config"]["sections"] == ["username", "currently_reading", "statistics"] + assert len(body["books"]) == 2 + assert {b["title"] for b in body["books"]} == {"Currently Reading", "Finished"} + assert "authors" in body["books"][0] + assert body["books"][0]["authors"] == ["Test Author"] + # Statistics are filtered to selected keys only. + assert set(body["statistics"].keys()) == {"total_books", "status_distribution"} + assert body["statistics"]["total_books"] == 2 + assert body["statistics"]["status_distribution"]["currently_reading"] == 1 + assert body["statistics"]["status_distribution"]["read"] == 1 + + +def test_public_profile_invalid_token_returns_404(client: Any) -> None: + resp = _public_profile_with_client(client, "lp_does-not-exist", x_api_key=None) + assert resp.status_code == 404 + + +def test_public_profile_expired_returns_404(client: Any, session: Session) -> None: + data = _create_share_link( + client, + expires_at=(utcnow() + timedelta(days=1)).isoformat(), + ) + token = data["token"] + link_id = data["link"]["id"] + + # Back-date the expiry in the DB (the API rejects past dates on write). + link = session.get(PublicProfileLink, link_id) + assert link is not None + link.expires_at = utcnow() - timedelta(days=1) + session.add(link) + session.commit() + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 404 + + +def test_public_profile_revoked_returns_404(client: Any) -> None: + data = _create_share_link(client) + token = data["token"] + link_id = data["link"]["id"] + + resp = client.delete(f"/api/profile/share-links/{link_id}") + assert resp.status_code == 204 + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 404 + + +def test_public_profile_authenticated_audience_blocks_anonymous(client: Any) -> None: + data = _create_share_link(client, audience="authenticated") + token = data["token"] + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 401 + + +def test_public_profile_audience_public_allows_anonymous_with_invalid_key(client: Any) -> None: + data = _create_share_link(client, audience="public") + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key="lk_invalid-key") + assert resp.status_code == 200 + + +def test_public_profile_authenticated_audience_allows_logged_in( + client: Any, + create_user_with_key: Any, +) -> None: + data = _create_share_link(client, audience="authenticated") + token = data["token"] + + user_b, key_b = create_user_with_key(email="viewer@example.com") + + assert key_b # viewer is a different logged-in user + resp = _public_profile_with_client(client, token, x_api_key=key_b) + assert resp.status_code == 200 + assert resp.json()["owner"]["firstname"] == "Test" + + +def test_public_profile_update_share_link(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + resp = client.patch( + f"/api/profile/share-links/{link_id}", + json={ + "name": "Renamed", + "audience": "authenticated", + "visibility_config": {"sections": ["full_library"], "statistics": []}, + "expires_at": (utcnow() + timedelta(days=30)).isoformat(), + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "Renamed" + assert body["audience"] == "authenticated" + assert body["visibility_config"]["sections"] == ["full_library"] + + +def test_public_profile_delete_revokes(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + resp = client.delete(f"/api/profile/share-links/{link_id}") + assert resp.status_code == 204 + + listed = client.get("/api/profile/share-links") + assert listed.json() == [] + + +def test_public_profile_cross_user_isolation(client: Any, session: Session, create_user_with_key: Any) -> None: + """A public profile only ever exposes the owner's books.""" + _create_book(client, title="Owner Book") + data = _create_share_link(client) + token = data["token"] + + user_b, _ = create_user_with_key(email="other@example.com") + # Insert a book owned by the other user directly into the DB. + session.add( + Book( + user_id=user_b.id, + title="Other User's Book", + page_count=50, + reading_status=ReadingStatus.want_to_read, + acquisition_status=AcquisitionStatus.owned, + ) + ) + session.commit() + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + assert [b["title"] for b in resp.json()["books"]] == ["Owner Book"] + + +def test_public_profile_never_returns_sensitive_fields(client: Any) -> None: + _create_book(client, title="Read", reading_status="read") + data = _create_share_link( + client, + visibility_config={ + "sections": ["username", "user_info", "full_library", "currently_reading", "last_read", "reading_timeline", "statistics"], + "statistics": [], + }, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + raw = resp.json() + + body_text = str(raw) + assert "email" not in body_text + assert "@" not in body_text + assert "password" not in body_text + assert "api_key" not in body_text + assert "notes" not in body_text + assert "blurb" not in body_text + assert "settings" not in body_text + + book = raw["books"][0] + assert "notes" not in book + assert "blurb" not in book + + +def test_public_profile_respects_visibility_config_for_books(client: Any) -> None: + """When no book section is enabled, the books payload is empty.""" + _create_book(client, title="A Book") + data = _create_share_link( + client, + visibility_config={"sections": ["username"], "statistics": []}, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + body = resp.json() + assert body["books"] == [] + assert body["statistics"] is None + + +def test_public_profile_did_not_finish_books_still_whitelisted(client: Any) -> None: + _create_book(client, title="DNF", reading_status="did_not_finish") + data = _create_share_link( + client, + visibility_config={"sections": ["full_library"], "statistics": []}, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + assert [b["title"] for b in resp.json()["books"]] == ["DNF"] + assert resp.json()["books"][0]["reading_status"] == "did_not_finish" + + +def test_public_profile_statistics_only_selected_keys(client: Any) -> None: + _create_book(client, title="Read", reading_status="read", rating=5) + data = _create_share_link( + client, + visibility_config={ + "sections": ["statistics"], + "statistics": ["average_rating", "total_authors"], + }, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + stats = resp.json()["statistics"] + assert set(stats.keys()) == {"average_rating", "total_authors"} + assert stats["average_rating"] == 5.0 + + +def test_public_profile_statistics_include_companion_counts(client: Any) -> None: + """Selected summary stats bring along the *_count fields that describe them.""" + _create_book( + client, + title="Read", + reading_status="read", + language="de", + date_finished=utcnow().isoformat(), + ) + data = _create_share_link( + client, + visibility_config={ + "sections": ["statistics"], + "statistics": [ + "busiest_month", + "most_popular_language", + "total_books", + ], + }, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + stats = resp.json()["statistics"] + assert set(stats.keys()) == { + "total_books", + "busiest_month", + "busiest_month_count", + "most_popular_language", + "most_popular_language_count", + } + assert stats["busiest_month_count"] is not None + assert stats["most_popular_language_count"] is not None + + +def test_public_profile_redacts_owner_name_when_hidden(client: Any) -> None: + """Names are never emitted unless a section renders them.""" + data = _create_share_link( + client, + visibility_config={"sections": ["full_library"], "statistics": []}, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + owner = resp.json()["owner"] + assert owner == {"firstname": None, "lastname": None} + + +def test_public_profile_sets_security_headers(client: Any) -> None: + data = _create_share_link(client) + token = data["token"] + resp = client.get(f"/api/public-profiles/{token}") + assert resp.status_code == 200 + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "DENY" + assert resp.headers["Referrer-Policy"] == "no-referrer" + + +def test_public_profile_rejects_past_expiry_on_create(client: Any) -> None: + resp = client.post( + "/api/profile/share-links", + json={ + "name": "Expired", + "expires_at": (utcnow() - timedelta(days=1)).isoformat(), + }, + ) + assert resp.status_code == 422 + assert "future" in resp.json()["detail"] + + +def test_public_profile_rejects_past_expiry_on_update(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + resp = client.patch( + f"/api/profile/share-links/{link_id}", + json={"expires_at": (utcnow() - timedelta(minutes=5)).isoformat()}, + ) + assert resp.status_code == 422 + assert "future" in resp.json()["detail"] + + # Clearing the expiry is still allowed. + resp = client.patch( + f"/api/profile/share-links/{link_id}", + json={"expires_at": None}, + ) + assert resp.status_code == 200 + assert resp.json()["expires_at"] is None + + +def test_reveal_share_link_returns_raw_token(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + resp = client.post(f"/api/profile/share-links/{link_id}/reveal") + assert resp.status_code == 200 + body = resp.json() + assert body["token"] == data["token"] + assert body["token"].startswith("lp_") + + +def test_reveal_share_link_404_for_other_user( + client: Any, create_user_with_key: Any +) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + user_b, key_b = create_user_with_key(email="other@example.com") + resp = client.post( + f"/api/profile/share-links/{link_id}/reveal", + headers={"X-API-Key": key_b}, + ) + assert resp.status_code == 404 + + +def test_reveal_share_link_404_for_revoked(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + # Revoke + client.delete(f"/api/profile/share-links/{link_id}") + + resp = client.post(f"/api/profile/share-links/{link_id}/reveal") + assert resp.status_code == 404 + + +def test_reveal_share_link_404_for_legacy_without_token(client: Any, session: Session) -> None: + """Legacy links with token=None cannot be revealed.""" + from app.auth import get_public_profile_token_prefix, hash_public_profile_token + from app.models import PublicProfileLink + + token_hash = hash_public_profile_token("lp_fake_legacy_token") + link = PublicProfileLink( + user_id=1, + name="Legacy", + token_prefix=get_public_profile_token_prefix("lp_fake_legacy_token"), + token_hash=token_hash, + ) + session.add(link) + session.commit() + session.refresh(link) + + resp = client.post(f"/api/profile/share-links/{link.id}/reveal") + assert resp.status_code == 404 \ No newline at end of file diff --git a/backend/tests/test_search_query.py b/backend/tests/test_search_query.py index a288f4c3..231d7f97 100644 --- a/backend/tests/test_search_query.py +++ b/backend/tests/test_search_query.py @@ -86,7 +86,7 @@ def test_parse_bare_prefix() -> None: def test_parse_all_supported_prefixes() -> None: - query = "author:a title:t publisher:p tag:g language:en possession:owned notes:n description:d" + query = "author:a title:t publisher:p tag:g language:en possession:owned medium:audiobook notes:n description:d" fields = [t.field for t in parse_search_query(query)] assert fields == [ "author", @@ -95,6 +95,7 @@ def test_parse_all_supported_prefixes() -> None: "tag", "language", "possession", + "medium", "notes", "description", ] @@ -104,6 +105,7 @@ def test_possession_condition_accepts_enum_values() -> None: from app.services.search import _possession_condition assert _possession_condition("owned") is not None + assert _possession_condition("Im Besitz") is not None assert _possession_condition("digital_access") is not None assert _possession_condition("to acquire") is not None assert _possession_condition("owned") is not None @@ -112,4 +114,18 @@ def test_possession_condition_accepts_enum_values() -> None: def test_possession_condition_rejects_unknown_value() -> None: from app.services.search import _possession_condition - assert _possession_condition("not-a-status") is None \ No newline at end of file + assert _possession_condition("not-a-status") is None + + +def test_medium_condition_accepts_display_and_key_values() -> None: + from app.services.search import _medium_condition + + assert _medium_condition("Audiobook") is not None + assert _medium_condition("Hörbuch") is not None + assert _medium_condition("comic_graphic_novel") is not None + assert _medium_condition("Comic / Graphic Novel") is not None + assert _medium_condition("unknown") is None + + +def test_parse_negated_medium_term() -> None: + assert _terms("-medium:print") == [("medium", "print", True)] diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index f44a9769..f586e953 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -10,7 +10,8 @@ from sqlmodel import Session, select from app.models import Book, ReadingProgress, ReadingStatus, UserSettings -from app.routers.statistics import _extract_book_level_daily_pages +from app.schemas import StatisticsRange +from app.services.statistics import _extract_book_level_daily_pages, _statistics_window def _create_book(client: Any, **overrides: Any) -> dict[str, Any]: @@ -403,7 +404,7 @@ def __sub__(self, other: object) -> MagicMock: def test_clamp_window_entirely_before() -> None: - from app.routers.statistics import _clamp_window + from app.services.statistics import _clamp_window start = datetime(2025, 1, 1, tzinfo=timezone.utc) end = datetime(2025, 1, 5, tzinfo=timezone.utc) @@ -413,7 +414,7 @@ def test_clamp_window_entirely_before() -> None: def test_clamp_window_start_before_window() -> None: - from app.routers.statistics import _clamp_window + from app.services.statistics import _clamp_window start = datetime(2025, 1, 5, tzinfo=timezone.utc) end = datetime(2025, 1, 15, tzinfo=timezone.utc) @@ -425,7 +426,7 @@ def test_clamp_window_start_before_window() -> None: def test_clamp_window_entirely_after() -> None: - from app.routers.statistics import _clamp_window + from app.services.statistics import _clamp_window start = datetime(2025, 1, 25, tzinfo=timezone.utc) end = datetime(2025, 1, 30, tzinfo=timezone.utc) @@ -435,7 +436,7 @@ def test_clamp_window_entirely_after() -> None: def test_clamp_window_end_after_window() -> None: - from app.routers.statistics import _clamp_window + from app.services.statistics import _clamp_window start = datetime(2025, 1, 15, tzinfo=timezone.utc) end = datetime(2025, 1, 25, tzinfo=timezone.utc) @@ -543,7 +544,7 @@ def test_statistics_includes_virtual_entry_for_non_read_book_with_progress(clien def test_compute_pages_per_month_skips_non_positive_delta() -> None: - from app.routers.statistics import _compute_pages_per_month_from_progress + from app.services.statistics import _compute_pages_per_month_from_progress entries = [ SimpleNamespace(book_id=1, page=100, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc)), @@ -556,7 +557,7 @@ def test_compute_pages_per_month_skips_non_positive_delta() -> None: def test_compute_pages_per_month_skips_non_positive_day_diff(monkeypatch: MonkeyPatch) -> None: import builtins - from app.routers.statistics import _compute_pages_per_month_from_progress + from app.services.statistics import _compute_pages_per_month_from_progress # Bypass internal sorting so we can feed prev/curr in the order needed. monkeypatch.setattr(builtins, "sorted", lambda iterable, **kwargs: list(iterable)) @@ -570,7 +571,7 @@ def test_compute_pages_per_month_skips_non_positive_day_diff(monkeypatch: Monkey def test_compute_pages_per_month_from_books_skips_invalid() -> None: - from app.routers.statistics import _compute_pages_per_month_from_books + from app.services.statistics import _compute_pages_per_month_from_books books = [ Book(id=1, title="No dates", reading_status=ReadingStatus.read, user_id=1), @@ -590,7 +591,7 @@ def test_compute_pages_per_month_from_books_skips_invalid() -> None: def test_compute_pages_per_month_from_books_skips_non_positive_total_days() -> None: """total_days <= 0 should be skipped even when date_finished is not < date_started.""" - from app.routers.statistics import _compute_pages_per_month_from_books + from app.services.statistics import _compute_pages_per_month_from_books class FakeDateTime: def __lt__(self, other: object) -> bool: @@ -615,7 +616,7 @@ def __sub__(self, other: object) -> MagicMock: def test_extract_progress_daily_pages_skips_outside_window() -> None: - from app.routers.statistics import _extract_progress_daily_pages + from app.services.statistics import _extract_progress_daily_pages entries = [ SimpleNamespace(book_id=1, page=0, created_at=datetime(2025, 1, 1, tzinfo=timezone.utc)), @@ -630,8 +631,32 @@ def test_extract_progress_daily_pages_skips_outside_window() -> None: assert result == {} +def test_extract_progress_daily_pages_splits_delta_across_calendar_days() -> None: + """A delta spanning two calendar days must be split, even when the span is <24h.""" + from app.services.statistics import _extract_progress_daily_pages + + entries = [ + SimpleNamespace(book_id=1, page=202, created_at=datetime(2026, 9, 2, 21, 16, tzinfo=timezone.utc)), + SimpleNamespace(book_id=1, page=320, created_at=datetime(2026, 9, 3, 20, 54, tzinfo=timezone.utc)), + ] + result = _extract_progress_daily_pages(entries, ZoneInfo("Europe/Berlin")) + assert result == {"2026-09-02": 59.0, "2026-09-03": 59.0} + + +def test_extract_progress_daily_pages_keeps_last_day_of_partial_span() -> None: + """The final calendar day must not be dropped when prev is later in the day than curr.""" + from app.services.statistics import _extract_progress_daily_pages + + entries = [ + SimpleNamespace(book_id=1, page=10, created_at=datetime(2026, 5, 1, 23, 0, tzinfo=timezone.utc)), + SimpleNamespace(book_id=1, page=30, created_at=datetime(2026, 5, 2, 22, 0, tzinfo=timezone.utc)), + ] + result = _extract_progress_daily_pages(entries, ZoneInfo("UTC")) + assert result == {"2026-05-01": 10.0, "2026-05-02": 10.0} + + def test_extract_book_level_daily_pages_skips_outside_window() -> None: - from app.routers.statistics import _extract_book_level_daily_pages + from app.services.statistics import _extract_book_level_daily_pages book = Book( title="Old", @@ -650,6 +675,26 @@ def test_extract_book_level_daily_pages_skips_outside_window() -> None: assert result == {} +def test_statistics_monthly_pages_clamp_to_selected_window() -> None: + from app.services.statistics import _compute_pages_per_month_from_books + + book = Book( + title="Windowed", + reading_status=ReadingStatus.read, + user_id=1, + page_count=100, + date_started=datetime(2026, 1, 1, tzinfo=timezone.utc), + date_finished=datetime(2026, 1, 10, tzinfo=timezone.utc), + ) + result = _compute_pages_per_month_from_books( + [book], + ZoneInfo("UTC"), + datetime(2026, 1, 6), + datetime(2026, 1, 10, 23, 59, 59), + ) + assert result == {"2026-01": 50.0} + + # ── Rating stats ───────────────────────────────────────────────────────── @@ -666,3 +711,76 @@ def test_statistics_top_and_worst_rated_books(client: Any) -> None: assert data["average_rating"] == 3.5 assert [b["title"] for b in data["top_rated_books"]] == ["Best", "Good", "Okay", "Bad"] assert [b["title"] for b in data["worst_rated_books"]] == ["Bad", "Okay", "Good", "Best"] + + +def test_statistics_range_filters_finished_books(client: Any) -> None: + now = datetime.now(timezone.utc) + _create_book( + client, + title="Outside", + reading_status="read", + date_started=f"{now.year - 1}-01-01T10:00:00+00:00", + date_finished=f"{now.year - 1}-01-02T10:00:00+00:00", + ) + _create_book( + client, + title="Inside", + reading_status="read", + date_started=(now - timedelta(days=5)).isoformat(), + date_finished=(now - timedelta(days=2)).isoformat(), + ) + + response = client.get("/api/statistics?range=this_year") + assert response.status_code == 200 + data = response.json() + assert sum(item["count"] for item in data["books_finished_per_month"]) == 1 + assert sum(item["count"] for item in data["books_finished_per_year"]) == 1 + + +def test_statistics_calendar_range_windows() -> None: + now = datetime(2026, 9, 13, 12, 0, tzinfo=timezone.utc) + tz = ZoneInfo("UTC") + + this_year_start, this_year_end = _statistics_window( + StatisticsRange.this_year, None, None, tz, now + ) + assert this_year_start == datetime(2026, 1, 1) + assert this_year_end == datetime(2026, 9, 13, 12, 0) + + last_year_start, last_year_end = _statistics_window( + StatisticsRange.last_year, None, None, tz, now + ) + assert last_year_start == datetime(2025, 1, 1) + assert last_year_end == datetime(2025, 12, 31, 23, 59, 59, 999999) + + three_years_start, three_years_end = _statistics_window( + StatisticsRange.three_years, None, None, tz, now + ) + assert three_years_start == datetime(2024, 1, 1) + assert three_years_end == datetime(2026, 9, 13, 12, 0) + + +def test_statistics_custom_range_and_validation(client: Any) -> None: + _create_book( + client, + title="Included", + reading_status="read", + date_started="2026-01-01T00:00:00Z", + date_finished="2026-02-01T00:00:00Z", + ) + _create_book( + client, + title="Excluded", + reading_status="read", + date_started="2026-03-01T00:00:00Z", + date_finished="2026-04-01T00:00:00Z", + ) + + response = client.get("/api/statistics?range=custom&from=2026-01-01&to=2026-02-28") + assert response.status_code == 200 + assert sum(item["count"] for item in response.json()["books_finished_per_month"]) == 1 + + assert client.get("/api/statistics?range=custom&from=2026-01-01").status_code == 400 + assert client.get("/api/statistics?range=custom&from=2026-03-01&to=2026-02-01").status_code == 400 + assert client.get("/api/statistics?range=alltime&from=2026-01-01&to=2026-02-01").status_code == 400 + assert client.get("/api/statistics?range=custom&from=1900-01-01&to=2026-02-01").status_code == 400 diff --git a/backend/tests/test_telemetry.py b/backend/tests/test_telemetry.py index aa1a9143..98aa461c 100644 --- a/backend/tests/test_telemetry.py +++ b/backend/tests/test_telemetry.py @@ -410,8 +410,8 @@ async def test_database_failure_is_swallowed(monkeypatch) -> None: @pytest.mark.anyio -async def test_heartbeat_sends_then_waits_24h() -> None: - """The heartbeat sends on start, then sleeps 24h and keeps going on failure.""" +async def test_heartbeat_sends_then_waits_interval() -> None: + """The heartbeat sends on start, then sleeps the configured interval and keeps going on failure.""" import app.main as main_module calls = 0 @@ -428,7 +428,7 @@ async def failing_send() -> None: await main_module._telemetry_heartbeat() assert calls == 2 - assert mock_sleep.call_args_list[0].args[0] == 24 * 3600 + assert mock_sleep.call_args_list[0].args[0] == main_module._TELEMETRY_INTERVAL_SECONDS @pytest.mark.anyio diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8b4d093d..2b09470f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,6 +13,7 @@ services: - ./data:/app/data - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro # only needed if you use custom certificates in you environment environment: + TELEMETRY_DISABLED: "true" # Development runs must not send telemetry. REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment restart: unless-stopped diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index e9e5a9a6..85da19ba 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -95,6 +95,7 @@ export default defineConfig({ { text: 'Embed API', link: '/api/integrations/embed-api' }, { text: 'Dashy', link: '/api/integrations/dashy' }, { text: 'Glance', link: '/api/integrations/glance' }, + { text: 'Heimdall', link: '/api/integrations/heimdall' }, { text: 'Home Assistant', link: '/api/integrations/homeassistant' }, { text: 'Homarr', link: '/api/integrations/homarr' }, { text: 'Homepage', link: '/api/integrations/homepage' }, diff --git a/docs/api/index.md b/docs/api/index.md index a972eb6a..0c76a000 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -61,9 +61,11 @@ The `author` field is marked as **deprecated** in the OpenAPI spec (visible in S When creating a book you must provide at least one author — either `authors` as a list, or the legacy `author` string. If both are sent, `authors` takes precedence. A request with neither (or with an empty `authors` list) is rejected with a `422` validation error. +Books have an optional nullable `medium` field. Accepted values are `Print`, `eBook`, `Audiobook`, `Comic / Graphic Novel`, and `Magazine / Newspaper`. Omit it or send `null` when the medium is not known. Books can be filtered with `GET /api/books?medium=Audiobook` or searched with `q=medium:audiobook`. + 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 \ @@ -172,4 +174,4 @@ Error responses include a JSON body with details: { "detail": "Book not found" } -``` \ No newline at end of file +``` diff --git a/docs/api/integrations/heimdall.md b/docs/api/integrations/heimdall.md new file mode 100644 index 00000000..e0ec5dcc --- /dev/null +++ b/docs/api/integrations/heimdall.md @@ -0,0 +1,46 @@ +# Heimdall + +LibrisLog can be integrated into [Heimdall](https://github.com/linuxserver/Heimdall), +an application dashboard and launcher for your self-hosted services, as a +[LibrisLog enhanced app](https://github.com/linuxserver/Heimdall-Apps). + +The enhanced app displays your reading statistics (books read, currently +reading, want-to-read, and total counts) directly on your Heimdall tile. + +## Prerequisites + +- A running LibrisLog instance reachable **from the Heimdall server** + (Heimdall fetches the statistics server-side, so no + [CORS](/guide/configuration#core-settings) configuration is needed) +- An [API key](/api/integrations/#api-keys) with access to the + statistics endpoint + +## Configuration + +1. In Heimdall, add a new item and pick **LibrisLog** as the application type + (it is listed as an *enhanced app*). +2. In the config section of the app, enter the address of your LibrisLog + instance in the **URL** field. The URL **must end with a trailing slash**: + + ``` + http:/// + ``` + +3. Enter your API key into the **Password (API key)** field. +4. Select which values the tile should display under **Stats to show**: + **Read**, **Reading**, **Want to read**, and/or **Total**. Hold + Ctrl (or Cmd) to select multiple entries. +5. Click **Test** to verify the connection. If everything is configured + correctly, Heimdall reports *"Successfully communicated with the API"*. + +::: tip The URL field needs a trailing slash +The config section of a Heimdall enhanced app can be confusing: the **URL** +field needs the address of your LibrisLog instance **followed by a slash**, +e.g. `http://192.168.1.100:8000/`. Without the trailing slash the app cannot +fetch your statistics: the tile stays empty and the **Test** button fails. + +::: + +## Result + +![Heimdall Widget](/screenshots/integrations-heimdall.png) diff --git a/docs/api/integrations/index.md b/docs/api/integrations/index.md index 055f0f8e..806e63f9 100644 --- a/docs/api/integrations/index.md +++ b/docs/api/integrations/index.md @@ -39,6 +39,9 @@ headers. For these integrations you need an **embed token**, used with the - [Glance](/api/integrations/glance) — Display your LibrisLog statistics on a [Glance](https://github.com/glanceapp/glance) dashboard using the custom API widget. +- [Heimdall](/api/integrations/heimdall): Display your LibrisLog statistics + on a [Heimdall](https://github.com/linuxserver/Heimdall) dashboard using the + LibrisLog enhanced app. - [Home Assistant](/api/integrations/homeassistant) — Expose your LibrisLog reading statistics as sensors in [Home Assistant](https://www.home-assistant.io/) using the RESTful diff --git a/docs/guide/database-layout.md b/docs/guide/database-layout.md index 8d96c83f..ca9b3ade 100644 --- a/docs/guide/database-layout.md +++ b/docs/guide/database-layout.md @@ -70,6 +70,7 @@ erDiagram integer rating varchar reading_status varchar acquisition_status + varchar medium integer user_id datetime date_added datetime date_started @@ -215,6 +216,7 @@ A book in the user's library. | `rating` | `INTEGER` | | ≥ 1; ≤ 5 | | `reading_status` | `VARCHAR` | NOT NULL, INDEX | default `want_to_read` | | `acquisition_status` | `VARCHAR` | NOT NULL, INDEX | default `owned` | +| `medium` | `VARCHAR` | INDEX, nullable | Stored as the enum key (`print`, `ebook`, `audiobook`, `comic_graphic_novel`, or `magazine_newspaper`) | | `user_id` | `INTEGER` | FK → user.id, INDEX | | | `date_added` | `DATETIME` | INDEX | UTC | | `date_started` | `DATETIME` | INDEX | UTC | @@ -296,6 +298,8 @@ Per-user settings such as language, timezone, and theme. | `timezone` | `VARCHAR(64)` | NOT NULL | default `UTC` | | `theme` | `VARCHAR(20)` | NOT NULL | default `light` | | `custom_theme` | `VARCHAR(30)` | | | +| `auto_set_date_started` | `BOOLEAN` | NOT NULL | default `true` | +| `auto_set_date_finished` | `BOOLEAN` | NOT NULL | default `true` | ### `book_author` diff --git a/docs/guide/developer-setup.md b/docs/guide/developer-setup.md index fb96b6b3..5367b826 100644 --- a/docs/guide/developer-setup.md +++ b/docs/guide/developer-setup.md @@ -64,7 +64,7 @@ Steps: uv sync cd backend uv run alembic upgrade head -uv run uvicorn app.main:app --reload --port 8000 +TELEMETRY_DISABLED=true uv run uvicorn app.main:app --reload --port 8000 ``` The backend runs on http://localhost:8000 with auto-reload on code changes. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index a9f4c719..289c58c5 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -7,6 +7,8 @@ Get LibrisLog running in minutes. - [Docker](https://docs.docker.com/get-docker/) (includes Docker Compose) - `curl` or `wget` (to download files) +> **Camera features need a secure context**: The ISBN barcode scanner (and any camera use) only works when the app is served over **HTTPS** or via `http://localhost`. If you access the app over plain `http://` on a remote address, the camera won't start. See the [library guide](/guide/using-librislog/library#isbn-barcode-scan) for details. + ## Setup Download the files, create your environment, and generate a secure encryption key. diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index 8b21b094..ecc02d04 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -15,7 +15,7 @@ The most common way to add books is by searching external sources: - **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)) 4. Select a result to import with full metadata and cover -5. Choose an availability value (owned, borrowed, digital access, or to acquire) before saving +5. Choose an availability value and, optionally, a medium (Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper) before saving ### ISBN Barcode Scan @@ -23,11 +23,11 @@ On mobile devices: 1. Tap the scan button in the import dialog 2. Point the camera at an ISBN barcode 3. The app detects the barcode and searches automatically -4. Pick the search result and select an availability value before saving +4. Pick the search result and select an availability value and optional medium before saving ### Manual Entry -If no search results are found, enter book details manually. Title, author, page count, and availability are required; all other fields are optional. +If no search results are found, enter book details manually. Title, author, page count, and availability are required; all other fields, including medium, are optional. Authors can be added as multiple values: type a name and press **Enter** (or pick a suggestion) to add a chip. A book can have any number of authors. Commas inside an author name (e.g. `Asimov, Isaac`) are preserved — they are not treated as separators. @@ -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: @@ -86,6 +89,8 @@ When importing CSV, map source columns to LibrisLog fields: `acquisition_status` is required for imports. Map it to one of `owned`, `borrowed`, `digital_access`, or `to_acquire`; use a transform when the source file uses different names. +The optional `medium` field can be mapped to `Print`, `eBook`, `Audiobook`, `Comic / Graphic Novel`, or `Magazine / Newspaper`. Existing exports include this field and preserve an unset medium as empty/null. + `date_added` is importable too — useful when migrating from another tool so the original "added to library" dates are preserved (the LibrisLog JSON export includes it, so exports round-trip losslessly). If a row has no `date_added`, the import timestamp is used. #### Authors are adaptive @@ -118,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/library.md b/docs/guide/using-librislog/library.md index a02c2c0a..c36f5969 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -19,6 +19,8 @@ Each status has its own tab in the library view, making it easy to browse your c Possession is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the possession filter to narrow the list without changing its newest-first order. +Each book can also have an optional medium: Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper. Use the medium filter to narrow the library, or search with `medium:audiobook`. A missing medium is valid when it is not known yet. + ![Library](/screenshots/library-read.png) ## Navigation @@ -39,7 +41,7 @@ Each book card shows: Clicking a book opens the detail dialog/drawer showing: - Full cover image -- Complete metadata (title, subtitle, author, ISBN, publisher, year, pages, language) +- Complete metadata (title, subtitle, author, ISBN, publisher, year, pages, language, medium) - Reading status badge - Star rating (clickable to change) - Reading progress slider (for books with page count) @@ -52,7 +54,7 @@ Clicking a book opens the detail dialog/drawer showing: ### Manual Entry -Use the "Add Book" button to manually enter book details. Fill in title, author, and optional fields like ISBN, publisher, page count, etc. +Use the "Add Book" button to manually enter book details. Fill in title, author, and optional fields like ISBN, publisher, page count, and medium. The medium can be left unset. A book can have **multiple authors**: type a name and press **Enter** to add it as a chip. Authors are shown joined with "; " throughout the app, so names written last-name-first (e.g. `"Doe, Jane"`) stay unambiguous. @@ -63,11 +65,40 @@ Search external sources for book metadata: - **Google Books** — Requires API key (set in `.env`) - **Hardcover.app** — Requires API token (set in `.env`) -The search automatically tries Open Library first, then falls back to other sources. For ISBN searches, all available sources are queried in parallel. +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. + +While a search is running, the **Search** button changes to **Cancel**, so you can stop the request at any time and refine your query. + +The search dialog also supports multiple parallel searches. Click **New parallel search** to open another independent search panel. Each panel keeps its own results while selected books can be added to the shared basket. Choose the possession and medium once above the panels; those values apply to books added from any search. + +#### How results are grouped + +Different providers often describe the same book slightly differently (title language, page count, publisher, cover). Instead of dropping these variants, LibrisLog keeps every result and groups the ones that represent the same book. Each group shows a **"N results"** badge with a **Show editions** toggle: expand it to review the individual records and pick the one you want to import. + +Results are grouped by this rule: + +- **Same ISBN**: if two results carry the same ISBN, they are grouped together. ISBN-10 and ISBN-13 forms of the same ISBN count as equal (e.g. `0441013597` and `9780441013593`). +- **No ISBN, same title + same authors**: results without an ISBN are grouped by a normalized title (case- and whitespace-insensitive) together with the same sorted author names. + +Consequences you may notice: + +- Two results with the *same title* but **different ISBNs** are **not** grouped: they are different editions (different language, publisher, or page count) and appear as separate entries. +- A result with an ISBN and a result without one are never grouped, even if the title and authors match. +- Results that differ only in metadata (cover, publisher, page count, description) but share an ISBN or title+author are grouped so you can compare them side by side. + +Because an ISBN can only be owned once per user, a group with a shared ISBN always represents a single book, so importing one variant is enough. ### ISBN Barcode Scan -On mobile devices, use the camera to scan ISBN barcodes. The app uses the device's camera with real-time barcode detection to quickly look up books. +Use the camera to scan ISBN barcodes. The app uses the device's camera with real-time barcode detection to quickly look up books. + +::: warning Requires a secure context + +Camera access is only available when LibrisLog is served in a **secure context**. A page is a secure context when it is served over **HTTPS** or from `http://localhost` (or `http://127.0.0.1`). Accessing the app via a plain `http://` address on a remote host — e.g. `http://192.168.1.10:8001` — is **not** a secure context, and the camera will not start. See [MDN: Secure contexts](https://developer.mozilla.org/en-US/docs/Web/Security/Dangerous_Contexts) for details. + +If the barcode scan button is hidden or the scanner shows a black box, your browser is likely blocking camera access because the app is not running in a secure context. Serve LibrisLog behind HTTPS (a reverse proxy with a TLS certificate) or access it via `localhost` to enable scanning. + +::: ## Editing Books diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 44411787..a8b40e52 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -43,6 +43,51 @@ The **"Show reading streaks & goals on dashboard"** switch above the goals disab See [Dashboard → Reading Streaks & Goals](/guide/using-librislog/dashboard#reading-streaks-goals) for details on how streaks and goal progress are calculated. +## Reading Date Automation + +By default, LibrisLog fills in missing reading dates when you change a book's status: + +- Moving a book to **Currently Reading** sets its start date to the current date and time if no start date exists. +- Moving a book to **Read** or **Did Not Finish** sets its finish date to the current date and time if no finish date exists. + +Existing dates entered by you are preserved and are never overwritten automatically. + +You can disable these behaviors independently in the **Reading Date Automation** section of your profile. When disabled, the status still changes, but the corresponding date remains empty. This is useful when you know a book's status but do not know when you started or finished it. + +Books without the relevant dates are omitted from date-based views and calculations, such as the reading timeline, finished-books-by-month charts, reading duration, and date-based goals. They remain included in status-based totals, such as the number of books marked **Read**. + +Disabling automation does not remove existing dates or disable date-conflict protection. It only prevents LibrisLog from creating a missing date automatically. + +## URL/Profile Sharing + +Create a read-only public view of your reading profile and share it with a URL. The shared page does not expose editing controls, notes, blurbs, or your email address. + +### Create a Profile URL + +1. Open **Profile** and scroll to **Share Profile**. +2. Click **Create New URL**. +3. Enter a name for the link, such as `Friends & family`. +4. Choose who can access it: + - **Everyone** — anyone with the URL can view the profile, without logging in. + - **Logged-in users only** — viewers must be signed in to LibrisLog. +5. Under **Content**, select the profile sections to share. Statistics can be enabled separately and configured by group. +6. Choose the language for this URL. The public page uses the link's language independently of your viewer's current UI language. +7. Under **Validity**, leave the link unlimited or set an expiration date. +8. Save the link. + +The complete URL is shown once after creation. Copy it immediately or open it in a new tab. For security, the full token is not shown in the link list unless you explicitly reveal it through the link actions. + +### Manage Existing URLs + +Each link appears in the **Share Profile** list with its name, access level, status, token prefix, and expiry information. The available actions are: + +- **Copy link** — copy the URL to the clipboard. +- **Open link** — open the read-only profile page in a new tab. +- **Edit** — change the access level, shared sections, statistics, language, or expiry date. +- **Delete** — revoke the link immediately. Anyone using it will lose access. + +Treat an **Everyone** URL like a public page: anyone who receives it can view the selected information until the link expires or is deleted. Create separate links when you want different audiences or different languages. + ## API Keys Create and manage API keys for headless access to the REST API. Each key can have an optional description. Keys are shown once at creation — copy it immediately, as it cannot be retrieved later. @@ -69,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/guide/using-librislog/search.md b/docs/guide/using-librislog/search.md index abdfefac..21afb826 100644 --- a/docs/guide/using-librislog/search.md +++ b/docs/guide/using-librislog/search.md @@ -14,6 +14,7 @@ Use `:` to search in a single field. The field prefixes are always | `language` | Language | `language:Japanese` | | `tag` | Tag name | `tag:fantasy` | | `possession` | Possession status | `possession:owned` | +| `medium` | Book medium | `medium:audiobook` | | `notes` | Private notes | `notes:"to reread"` | | `description` | Blurb / description | `description:"middle earth"` | @@ -23,7 +24,7 @@ The `author:` prefix matches **any** author assigned to a book — a book with m ### Possession values -The `possession` prefix matches the exact possession status. Accepted values include: +The `possession` prefix matches the exact possession status. The original enum keys are accepted, and localized display values are accepted too, such as German `Im Besitz` for `owned`. Accepted keys include: - `to_acquire` (or `to acquire`) - `owned` @@ -32,6 +33,12 @@ The `possession` prefix matches the exact possession status. Accepted values inc Example: `possession:"to acquire"` shows books you want to buy. +### Medium values + +The `medium` prefix matches the selected book medium. The original enum keys are `print`, `ebook`, `audiobook`, `comic_graphic_novel`, and `magazine_newspaper`. Localized display values are also accepted, such as German `Hörbuch` for `audiobook`. + +Example: `medium:audiobook` shows audiobook entries. The API list filter accepts both display values and normalized keys. + ## Negation Prefix a term with `-` to exclude matches. @@ -46,6 +53,7 @@ Separate terms with spaces. All terms are combined with **AND**. - `author:Murakami -title:Norwegian` — Murakami books except those whose title contains "Norwegian" - `tag:fantasy possession:owned` — owned fantasy books +- `medium:print possession:owned` — owned print books ## Plain text diff --git a/docs/public/screenshots/integrations-heimdall.png b/docs/public/screenshots/integrations-heimdall.png new file mode 100644 index 00000000..0d1b0c35 Binary files /dev/null and b/docs/public/screenshots/integrations-heimdall.png differ diff --git a/docs/releases.md b/docs/releases.md index 2f86aa1d..275785df 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,33 +8,70 @@ 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.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 | -| [v1.5.2](#v1-5-2-—-maintenance) | 2026-06-22 | Maintenance | -| [v1.5.1](#v1-5-1-—-maintenance) | 2026-06-22 | Maintenance | -| [v1.5.0](#v1-5-0-—-password-reset-usability) | 2026-06-22 | Feature release | -| [v1.4.0](#v1-4-0-—-embeddable-views-arm64) | 2026-06-14 | Feature release | -| [v1.3.1](#v1-3-1-—-maintenance) | 2026-06-09 | Maintenance | -| [v1.3.0](#v1-3-0-—-more-languages) | 2026-06-09 | Feature release | -| [v1.2.2](#v1-2-2-—-maintenance) | 2026-06-08 | Maintenance | -| [v1.2.1](#v1-2-1-—-import-reliability-multi-user-consistency) | 2026-06-08 | Feature release | -| [v1.2.0](#v1-2-0-—-startup-screen-update-checks) | 2026-06-01 | Feature release | -| [v1.1.1](#v1-1-1-—-maintenance) | 2026-06-01 | Maintenance | -| [v1.1.0](#v1-1-0-—-polish-missing-covers) | 2026-05-31 | Feature release | -| [v1.0.0](#v1-0-0-—-initial-release) | 2026-05-28 | Initial 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 | +| [v1.5.2](#v1-5-2-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.1](#v1-5-1-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.0](#v1-5-0-password-reset-usability) | 2026-06-22 | Feature release | +| [v1.4.0](#v1-4-0-embeddable-views-arm64) | 2026-06-14 | Feature release | +| [v1.3.1](#v1-3-1-maintenance) | 2026-06-09 | Maintenance | +| [v1.3.0](#v1-3-0-more-languages) | 2026-06-09 | Feature release | +| [v1.2.2](#v1-2-2-maintenance) | 2026-06-08 | Maintenance | +| [v1.2.1](#v1-2-1-import-reliability-multi-user-consistency) | 2026-06-08 | Feature release | +| [v1.2.0](#v1-2-0-startup-screen-update-checks) | 2026-06-01 | Feature release | +| [v1.1.1](#v1-1-1-maintenance) | 2026-06-01 | Maintenance | +| [v1.1.0](#v1-1-0-polish-missing-covers) | 2026-05-31 | Feature release | +| [v1.0.0](#v1-0-0-initial-release) | 2026-05-28 | Initial release | --- -## v1.8.0 — Camera & Zoom Control, Optional Telemetry +## 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 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 +- 🗂️ **Optional book medium**: classify books as Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper from manual entry, search import, and book editing. Mediums can be filtered in the library, searched with `medium:`, imported/exported, and reviewed in the statistics distribution +- 🌍 **Localized search values**: `medium:` and `possession:` searches accept both their original enum keys and localized display values, such as `medium:Hörbuch` and `possession:Im Besitz` +- 🛑 **Cancelable book search**: while an import search is running, the Search button becomes a Cancel button, so you can stop the request and refine your query +- ⌨️ **Escape closes dialogs and drawers**: overlays such as the sidebar, the book drawer, and the import modal can now be closed with the Escape key +- 📅 **Adaptive date input**: date fields in the book form now use a segmented year/month/day input that no longer assumes the month or day after the first keystroke, validates values as you type, and accepts pasting a complete date. A calendar button next to the field opens the native date picker +- 🌐 **Searchable timezone picker**: the timezone setting on the profile page is now a searchable dropdown covering all IANA timezones +- 📷 **Secure context detection in the barcode scanner**: if LibrisLog is served outside a secure context (plain HTTP on a remote host), the scan button is hidden and the scanner explains why the camera cannot start, instead of showing a black box. See the [Library guide](/guide/using-librislog/library#isbn-barcode-scan) for details +- 🎥 **Active camera name in the scanner**: the barcode scanner now shows the name of the active camera in a badge next to the switch button, so you always know which lens is being used +- 🔗 **Heimdall dashboard integration**: new documentation for the LibrisLog enhanced app, which shows your reading statistics directly on [Heimdall](https://github.com/linuxserver/Heimdall) tiles +- 🔗 **Shareable public profile pages**: create named, read-only profile URLs from the Profile page. Configure each link independently for public or logged-in-only access, selected profile sections and statistics, language, and an optional expiration date. Shared pages include responsive book cards, a mobile-safe reading timeline with incremental loading and hidden-book hints, full-library search with incremental loading, selectable 12-month/3-year/all-time trend ranges with value tooltips, distribution and rating panels, and the owner's generated avatar. Existing links can be copied, opened, edited, or revoked. The full URL token is only revealed on demand and is shown once after creation. See the [Profile guide](/guide/using-librislog/profile#urlprofile-sharing) for setup and security details +- 🧺 **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 +- 🕐 **Timezone-aware progress date editing**: editing a progress entry's date in the book detail view now interprets the value in your profile timezone instead of the browser's, so entries stay on the correct calendar day and streaks remain accurate +- 🏷️ **Better contrast for selected suggestion items**: the selected entry in tag and author suggestion dropdowns now has stronger contrast and a visible border in all themes +- ⚠️ **Undated read imports remain usable**: import previews show a non-blocking warning when a book is marked Read without a finish date, instead of treating the intentionally missing date as an import error + +**Breaking changes:** None. + +[Compare with v1.8.0](https://github.com/codebude/librislog/compare/v1.8.0...main) + +--- + +## v1.8.0: Camera & Zoom Control, Optional Telemetry @@ -57,7 +94,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.7.0 — Reading Streaks & Goals +## v1.7.0: Reading Streaks & Goals @@ -90,7 +127,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.6.0 — Reading Progress & Possession Tracking +## v1.6.0: Reading Progress & Possession Tracking @@ -112,7 +149,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.5.2 — Maintenance +## v1.5.2: Maintenance @@ -128,7 +165,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.5.1 — Maintenance +## v1.5.1: Maintenance @@ -143,7 +180,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.5.0 — Password Reset & Usability +## v1.5.0: Password Reset & Usability @@ -165,7 +202,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.4.0 — Embeddable Views & ARM64 +## v1.4.0: Embeddable Views & ARM64 @@ -187,7 +224,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.3.1 — Maintenance +## v1.3.1: Maintenance @@ -202,7 +239,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.3.0 — More Languages +## v1.3.0: More Languages @@ -219,7 +256,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.2.2 — Maintenance +## v1.2.2: Maintenance @@ -234,7 +271,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.2.1 — Import Reliability & Multi-User Consistency +## v1.2.1: Import Reliability & Multi-User Consistency @@ -256,7 +293,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.2.0 — Startup Screen & Update Checks +## v1.2.0: Startup Screen & Update Checks @@ -272,7 +309,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.1.1 — Maintenance +## v1.1.1: Maintenance @@ -289,7 +326,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.1.0 — Polish & Missing Covers +## v1.1.0: Polish & Missing Covers @@ -307,7 +344,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner --- -## v1.0.0 — Initial Release +## v1.0.0: Initial Release @@ -325,4 +362,4 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner - 🐳 Self-hosted via Docker Compose (SQLite, lightweight setup) - 🎨 Light/dark themes and responsive UI -[Full changelog](https://github.com/codebude/librislog/commits/v1.0.0) \ No newline at end of file +[Full changelog](https://github.com/codebude/librislog/commits/v1.0.0) diff --git a/frontend/e2e/specs/15-public-profile.spec.ts b/frontend/e2e/specs/15-public-profile.spec.ts new file mode 100644 index 00000000..0a169b58 --- /dev/null +++ b/frontend/e2e/specs/15-public-profile.spec.ts @@ -0,0 +1,190 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; +import { seedBooks } from '../fixtures/seed.api'; + +async function createShareLink( + page: Page, + name: string, + options: { sections?: string[]; language?: string } = {} +) { + const section = page.locator('#section-share-profile'); + await section.scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + + await section.locator('button.btn-primary').click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.locator('input[name="share-link-name"]').fill(name); + + if (options.sections) { + for (const sectionKey of options.sections) { + await dialog.locator(`input[name="share-link-section"][value="${sectionKey}"]`).check(); + } + } + + if (options.language) { + await dialog.locator('select[name="share-link-language"]').selectOption(options.language); + } + + await dialog.locator('button[type="submit"]').click(); + await expect(dialog).not.toBeVisible(); + + const urlText = await page.locator('#section-share-profile div.font-mono.break-all').first().textContent(); + expect(urlText).toMatch(/\/p\/lp_/); + return urlText!.trim(); +} + +async function openIncognito(browser: Browser, url: string): Promise { + const context = await browser.newContext(); + const publicPage = await context.newPage(); + await publicPage.goto(url); + await publicPage.waitForLoadState('networkidle'); + return publicPage; +} + +test.describe('Public Profile', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + + // The shared E2E database may have German persisted from spec 11.2. Force English so + // all assertions below are deterministic regardless of test order. + await page.goto('/profile'); + await page.waitForTimeout(1500); + await page.locator('select[name="language"]').selectOption('en'); + await page.locator('#section-language button[class*="btn-primary"]').click(); + await page.waitForTimeout(1000); + }); + + test('15.1 share link page shows selected sections and hides app chrome', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + // Spec 14 wipes the seed library; seed a couple of books so the configured + // sections (currently reading, full library + statistics) render real content + // on the public page. + await seedBooks(page, [ + { title: '1984', author: 'George Orwell', page_count: 328, reading_status: 'read', date_started: '2024-10-01', date_finished: '2024-10-20' }, + { title: 'The Three-Body Problem', author: 'Liu Cixin', page_count: 400, reading_status: 'currently_reading', date_started: '2025-01-15' } + ]); + + const shareUrl = await createShareLink(page, 'E2E Public Profile', { + sections: ['full_library'] + }); + + // The list entry persists the configured audience badge (default: Everyone) + await expect(page.locator('#section-share-profile')).toContainText('E2E Public Profile'); + + // App chrome (sidebar) is hidden on the public page even for logged-in users + await page.goto(shareUrl); + await page.waitForLoadState('networkidle'); + await expect(page.locator('aside')).toHaveCount(0); + + // Anonymous visitor sees the owner name, enabled sections, and no chrome + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByRole('heading', { name: /E2E Tester/ })).toBeVisible(); + await expect(publicPage.locator('aside')).toHaveCount(0); + + // full_library was selected explicitly and its content renders. Earlier specs + // may leave duplicate copies of the same title in the shared E2E DB, so + // assert presence (first match) rather than uniqueness. + await expect(publicPage.getByText('Full Library')).toBeVisible(); + const librarySection = publicPage.locator('section').filter({ hasText: 'Full Library' }); + await expect(librarySection.locator('.grid > div')).not.toHaveCount(0); + await expect(librarySection.getByText('1984', { exact: true }).first()).toBeVisible(); + + // currently_reading is on by default and renders the started-on date + const readingSection = publicPage.locator('section').filter({ hasText: 'Currently Reading' }); + await expect(readingSection.getByText('The Three-Body Problem', { exact: true }).first()).toBeVisible(); + await expect(readingSection.getByText('Started on', { exact: false }).first()).toBeVisible(); + + // statistics section is on by default and shows computed values + await expect(publicPage.getByText('Total Books')).toBeVisible(); + + // footer links the librislog word to GitHub and nothing else + const footer = publicPage.locator('footer'); + await expect(footer.getByRole('link', { name: 'LibrisLog' })).toBeVisible(); + await expect(footer.getByRole('link')).toHaveCount(1); + await publicPage.close(); + }); + + test('15.2 authenticated audience blocks anonymous viewers', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + const shareUrl = await createShareLink(page, 'Audience Test'); + + // Open the edit dialog and restrict access to logged-in users + const row = page.locator('#section-share-profile li').filter({ hasText: 'Audience Test' }); + await row.locator('button[aria-label="Edit"]').click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.locator('input[name="share-link-audience"][value="authenticated"]').check(); + await dialog.locator('button[type="submit"]').click(); + await expect(dialog).not.toBeVisible(); + + await expect(row).toContainText('Logged-in users only'); + + // A logged-in viewer can still open the page + await page.goto(shareUrl); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toBeVisible(); + + // Anonymous visitors are redirected to login + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByRole('heading', { name: 'Login required' })).toBeVisible(); + await expect(publicPage.getByRole('link', { name: 'Log in' })).toBeVisible(); + await publicPage.close(); + }); + + test('15.3 deleted share link returns not found', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + const shareUrl = await createShareLink(page, 'Delete Me Test'); + + const row = page.locator('#section-share-profile li').filter({ hasText: 'Delete Me Test' }); + await row.locator('button[aria-label="Delete"]').click(); + + const confirmDialog = page.locator('dialog.modal-open'); + await expect(confirmDialog).toBeVisible(); + await confirmDialog.locator('button.btn-error').click(); + await expect(confirmDialog).not.toBeVisible(); + + // The deleted link's row disappears from the list + await expect(page.locator('#section-share-profile li').filter({ hasText: 'Delete Me Test' })).toHaveCount(0); + + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByText('This public profile link is no longer valid.')).toBeVisible(); + await publicPage.close(); + }); + + test('15.4 share link language controls the public page locale', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + await seedBooks(page, [ + { title: '1984', author: 'George Orwell', page_count: 328, reading_status: 'currently_reading', date_started: '2024-10-01' } + ]); + + const shareUrl = await createShareLink(page, 'German Profile Link', { language: 'de' }); + + // Default language of the dialog follows the profile language ('en'); a German + // share link renders the public page entirely in German for anonymous viewers. + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByText('Aktuell gelesen')).toBeVisible(); + await expect(publicPage.getByText('Currently Reading')).toHaveCount(0); + await publicPage.close(); + + const dialog = page.getByRole('dialog'); + await page.locator('#section-share-profile').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + const row = page.locator('#section-share-profile li').filter({ hasText: 'German Profile Link' }); + await row.locator('button[aria-label="Edit"]').click(); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('select[name="share-link-language"]')).toHaveValue('de'); + await dialog.locator('button[aria-label="Close"]').click(); + }); +}); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ecb65371..2a7e478b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "@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", @@ -1033,6 +1034,20 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 16116bc9..27ad7106 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@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", diff --git a/frontend/src/app.css b/frontend/src/app.css index fd4fd9c2..7c62b8cd 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -61,6 +61,14 @@ --noise: 0; } +/* Keep dark cards calm while giving the page and nested surfaces clearer depth. */ +html[data-theme="dark"] { + --color-base-100: oklch(25.5% 0.018 255); + --color-base-200: oklch(19.5% 0.016 255); + --color-base-300: oklch(15.5% 0.014 255); + --color-base-content: oklch(86% 0.012 255); +} + html { scroll-behavior: smooth; } diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index ff1fb970..7c2eafd6 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -285,3 +285,44 @@ describe('api.statistics.gamification', () => { expect(body).toMatchObject({ goal_pages_per_day_enabled: true, goal_pages_per_day: 25 }); }); }); + +describe('api.import.searchStream', () => { + beforeEach(() => { + apiKey.set(null); + csrfToken.set(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes abort signal to fetch', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + body: new ReadableStream({ start(controller) { controller.close(); } }), + } as unknown as Response); + + const controller = new AbortController(); + const gen = api.import.searchStream('dune', 'title', 'auto', controller.signal); + await gen.next(); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBe(controller.signal); + }); + + it('builds the stream URL with query, type and mode', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + body: new ReadableStream({ start(controller) { controller.close(); } }), + } as unknown as Response); + + const gen = api.import.searchStream('dune', 'isbn', 'google_only'); + await gen.next(); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('/import/search/stream'); + expect(url).toContain('q=dune'); + expect(url).toContain('type=isbn'); + expect(url).toContain('mode=google_only'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3dcdd415..5a44e05f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -26,7 +26,9 @@ import type { DashboardQuote, GamificationResponse, StatisticsResponse, + StatisticsRange, LibraryStats, + Medium, ReadingProgressEntry, StatusTransitionRequest, StatusTransitionResponse, @@ -39,6 +41,12 @@ import type { SortOrder, OidcConfig, OidcLinkStatus, + PublicProfileAudience, + PublicProfileLink, + PublicProfileLinkCreateResponse, + PublicProfileResponse, + PublicProfileVisibilityConfig, + ShareLinkRevealResponse, User, UserCreateResponse, UserAdminUpdate, @@ -97,6 +105,35 @@ async function request(path: string, options?: RequestInit): Promise { return res.json() as Promise; } +async function publicRequest(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + ...options + }); + + const contentType = res.headers.get('content-type') ?? ''; + const isJson = contentType.includes('application/json'); + + if (!res.ok) { + const err = new Error(`HTTP ${res.status}`) as Error & { status?: number }; + err.status = res.status; + if (isJson) { + const detail = await res.json().catch(() => ({})); + err.message = detail?.detail ?? `HTTP ${res.status}`; + throw err; + } + const text = await res.text().catch(() => ''); + err.message = text || `HTTP ${res.status}`; + throw err; + } + if (res.status === 204) return undefined as T; + if (!isJson) { + throw new Error(`Unexpected non-JSON response for ${path}`); + } + return res.json() as Promise; +} + export const api = { auth: { setupRequired(): Promise<{ required: boolean }> { @@ -212,6 +249,49 @@ export const api = { return request(`/profile/embed-tokens/${id}`, { method: 'DELETE' }); }, + listShareLinks(): Promise { + return request('/profile/share-links'); + }, + + createShareLink(data: { + name: string; + audience: PublicProfileAudience; + language?: string | null; + visibility_config: PublicProfileVisibilityConfig; + expires_at?: string | null; + }): Promise { + return request('/profile/share-links', { + method: 'POST', + body: JSON.stringify(data) + }); + }, + + updateShareLink( + id: number, + data: Partial<{ + name: string; + audience: PublicProfileAudience; + language: string | null; + visibility_config: PublicProfileVisibilityConfig; + expires_at: string | null; + }> + ): Promise { + return request(`/profile/share-links/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); + }, + + deleteShareLink(id: number): Promise { + return request(`/profile/share-links/${id}`, { method: 'DELETE' }); + }, + + revealShareLink(id: number): Promise { + return request(`/profile/share-links/${id}/reveal`, { + method: 'POST' + }); + }, + resetData(confirmation: string): Promise { return request('/profile/reset-data', { method: 'POST', @@ -257,9 +337,18 @@ export const api = { } }, + publicProfile: { + get(token: string): Promise { + return publicRequest(`/public-profiles/${encodeURIComponent(token)}`); + } + }, + statistics: { - get(): Promise { - return request('/statistics'); + get(range: StatisticsRange = 'alltime', customFrom?: string | null, customTo?: string | null): Promise { + const params = new URLSearchParams({ range }); + if (customFrom) params.set('from', customFrom); + if (customTo) params.set('to', customTo); + return request(`/statistics?${params.toString()}`); }, getPagesPerDay(days: number = 365): Promise { @@ -336,6 +425,7 @@ export const api = { list(params?: { status?: ReadingStatus; acquisition_status?: AcquisitionStatus; + medium?: Medium; q?: string; has_cover?: boolean; sort?: SortField; @@ -347,6 +437,7 @@ export const api = { const qs = new URLSearchParams(); if (params?.status) qs.set('status', params.status); if (params?.acquisition_status) qs.set('acquisition_status', params.acquisition_status); + if (params?.medium) qs.set('medium', params.medium); if (params?.q) qs.set('q', params.q); if (params?.has_cover !== undefined) qs.set('has_cover', String(params.has_cover)); if (params?.sort) qs.set('sort', params.sort); @@ -473,21 +564,22 @@ export const api = { ); }, - importBook(candidate: BookImportCandidate, status: ReadingStatus, acquisitionStatus: AcquisitionStatus): Promise { + importBook(candidate: BookImportCandidate, status: ReadingStatus, acquisitionStatus: AcquisitionStatus, medium?: Medium | null): Promise { return request('/import', { method: 'POST', - body: JSON.stringify({ candidate, reading_status: status, acquisition_status: acquisitionStatus }) + body: JSON.stringify({ candidate, reading_status: status, acquisition_status: acquisitionStatus, medium }) }); }, async *searchStream( q: string, type: 'title' | 'isbn' = 'title', - mode: ImportSearchMode = 'auto' + mode: ImportSearchMode = 'auto', + signal?: AbortSignal ): AsyncGenerator { const res = await fetch( `${BASE}/import/search/stream?q=${encodeURIComponent(q)}&type=${type}&mode=${mode}`, - { headers: authHeaders() } + { headers: authHeaders(), signal } ); if (!res.ok || !res.body) { const detail = await res.json().catch(() => ({})); diff --git a/frontend/src/lib/components/AdaptiveDateInput.svelte b/frontend/src/lib/components/AdaptiveDateInput.svelte new file mode 100644 index 00000000..b97f61ed --- /dev/null +++ b/frontend/src/lib/components/AdaptiveDateInput.svelte @@ -0,0 +1,81 @@ + + +
+
+ + + +
+ + +
diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 9bb46695..a176ea5f 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -1,14 +1,15 @@ {#if open} @@ -136,6 +246,19 @@ class="tab {activeTab === 'import' ? 'tab-active' : ''}" onclick={() => (activeTab = 'import')} >{$_('addModal.searchImport')} + {#if activeTab === 'manual'} @@ -212,6 +335,15 @@ {/each} + + + +
+ {#each searchSessionIds as sessionId, index (sessionId)} +
+ {#if searchSessionIds.length > 1} +
+

{$_('import.parallelSearchLabel', { values: { number: sessionId } })}

+ +
+ {/if} + { + scannerOpen = true; + }} + scannedIsbn={index === 0 ? scannedIsbn : null} + onScannedHandled={index === 0 ? () => { scannedIsbn = null; } : undefined} + onImport={(book) => { + onAdded?.(book); + open = false; + reset(); + }} + /> +
+ {/each} +
+ {:else} + {/if} - - + {/if} diff --git a/frontend/src/lib/components/AddBookModal.test.ts b/frontend/src/lib/components/AddBookModal.test.ts index 698eff31..2feb67ed 100644 --- a/frontend/src/lib/components/AddBookModal.test.ts +++ b/frontend/src/lib/components/AddBookModal.test.ts @@ -2,16 +2,23 @@ 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 AddBookModal from './AddBookModal.svelte'; +import type { BookImportCandidate, SearchStage } from '$lib/types'; // Mock api const mockBooksCreate = vi.fn(); -const mockBooksList = vi.fn(async () => []); +const mockBooksList = vi.fn(async () => ({ books: [], total: 0 })); +const mockSearchStream = vi.fn(); +const mockImportBook = vi.fn(); vi.mock('$lib/api', () => ({ api: { books: { create: (...args: unknown[]) => mockBooksCreate(...args), list: () => mockBooksList() + }, + import: { + searchStream: (...args: unknown[]) => mockSearchStream(...args), + importBook: (...args: unknown[]) => mockImportBook(...args) } } })); @@ -93,6 +100,40 @@ describe('AddBookModal', () => { expect(importTab).toHaveClass('tab-active'); }); + it('can open multiple independent search panels', async () => { + render(AddBookModal, { props: { open: true } }); + await fireEvent.click(screen.getByRole('tab', { name: 'Search & Import' })); + await fireEvent.click(screen.getByRole('button', { name: 'New parallel search' })); + + expect(screen.getAllByPlaceholderText(/Search by title or author/)).toHaveLength(2); + expect(screen.getByRole('heading', { name: 'Search 1' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Search 2' })).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { name: /Search [12]/ }).map((heading) => heading.textContent)).toEqual([ + 'Search 2', + 'Search 1' + ]); + await waitFor(() => expect(document.activeElement).toBe(screen.getAllByPlaceholderText(/Search by title or author/)[0])); + }); + + it('starts searches in separate panels without waiting for each other', async () => { + mockSearchStream.mockImplementation(async function* (query: string) { + yield { stage: 'complete', results: [] } as SearchStage; + }); + render(AddBookModal, { props: { open: true } }); + await fireEvent.click(screen.getByRole('tab', { name: 'Search & Import' })); + await fireEvent.click(screen.getByRole('button', { name: 'New parallel search' })); + + const inputs = screen.getAllByPlaceholderText(/Search by title or author/); + await fireEvent.input(inputs[0], { target: { value: 'Dune' } }); + await fireEvent.input(inputs[1], { target: { value: 'Foundation' } }); + const searchButtons = screen.getAllByRole('button', { name: 'Search' }); + await fireEvent.click(searchButtons[0]); + await fireEvent.click(searchButtons[1]); + + await waitFor(() => expect(mockSearchStream).toHaveBeenCalledTimes(2)); + expect(mockSearchStream.mock.calls.map((call) => call[0])).toEqual(['Dune', 'Foundation']); + }); + it('closes modal when close button clicked', async () => { render(AddBookModal, { props: { open: true } }); const closeBtn = screen.getByRole('button', { name: /close/i }); @@ -277,4 +318,167 @@ describe('AddBookModal', () => { expect(titleInput).toHaveValue(''); }); + + describe('basket', () => { + function candidate( + id: number, + title: string, + options: Partial = {} + ): BookImportCandidate { + return { + title, + subtitle: null, + author: options.author ?? null, + authors: options.authors ?? [], + isbn: options.isbn ?? `978000000000${id}`, + cover_url: null, + publisher: null, + published_year: options.published_year ?? null, + page_count: null, + language: null, + tags: null, + blurb: null, + source: options.source ?? 'open_library' + }; + } + + async function searchAndAddToBasket(title: string, id: number, expectedCount: number) { + const book = candidate(id, title); + mockSearchStream.mockImplementation(async function* () { + yield { stage: 'complete', results: [book] } as SearchStage; + }); + const importTab = screen.getByRole('tab', { name: 'Search & Import' }); + await fireEvent.click(importTab); + const input = screen.getByPlaceholderText(/Search by title or author/); + await fireEvent.input(input, { target: { value: title } }); + await fireEvent.click(screen.getByRole('button', { name: 'Search' })); + await waitFor(() => { + expect(screen.getByText(title)).toBeInTheDocument(); + }); + const acquisitionSelect = screen.getByRole('combobox', { name: /Possession/i }); + // happy-dom does not implement :checked for