From 29b5632779503852ec21af036471737c33f4b3fa Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 12:06:02 +0200 Subject: [PATCH 01/10] First shot of url sharing --- ...4b7d8e3f1_add_public_profile_link_table.py | 48 + backend/app/auth.py | 24 + backend/app/main.py | 4 +- backend/app/models.py | 36 + backend/app/routers/profile.py | 2 +- backend/app/routers/public_profile.py | 142 +++ backend/app/routers/share_links.py | 147 +++ backend/app/routers/statistics.py | 962 +----------------- backend/app/schemas.py | 131 ++- backend/app/services/public_profile.py | 126 +++ backend/app/services/statistics.py | 947 +++++++++++++++++ backend/tests/test_gamification.py | 2 +- backend/tests/test_public_profile.py | 407 ++++++++ backend/tests/test_statistics.py | 28 +- frontend/e2e/specs/15-public-profile.spec.ts | 153 +++ frontend/src/lib/api.ts | 75 ++ .../lib/components/SegmentedDateInput.test.ts | 2 +- .../src/lib/components/ShareLinkDialog.svelte | 317 ++++++ .../lib/components/ShareLinkDialog.test.ts | 131 +++ frontend/src/lib/i18n/locales/de.json | 86 ++ frontend/src/lib/i18n/locales/en.json | 86 ++ frontend/src/lib/i18n/locales/es.json | 86 ++ frontend/src/lib/i18n/locales/fr.json | 86 ++ frontend/src/lib/i18n/locales/zh.json | 86 ++ .../src/lib/publicProfile/sections.test.ts | 107 ++ frontend/src/lib/publicProfile/sections.ts | 208 ++++ frontend/src/lib/types.ts | 81 ++ frontend/src/routes/+layout.svelte | 3 +- frontend/src/routes/p/[token]/+page.svelte | 590 +++++++++++ frontend/src/routes/profile/+page.svelte | 188 +++- frontend/src/routes/statistics/page.test.ts | 1 + 31 files changed, 4351 insertions(+), 941 deletions(-) create mode 100644 backend/alembic/versions/c9a4b7d8e3f1_add_public_profile_link_table.py create mode 100644 backend/app/routers/public_profile.py create mode 100644 backend/app/routers/share_links.py create mode 100644 backend/app/services/public_profile.py create mode 100644 backend/app/services/statistics.py create mode 100644 backend/tests/test_public_profile.py create mode 100644 frontend/e2e/specs/15-public-profile.spec.ts create mode 100644 frontend/src/lib/components/ShareLinkDialog.svelte create mode 100644 frontend/src/lib/components/ShareLinkDialog.test.ts create mode 100644 frontend/src/lib/publicProfile/sections.test.ts create mode 100644 frontend/src/lib/publicProfile/sections.ts create mode 100644 frontend/src/routes/p/[token]/+page.svelte 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/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/main.py b/backend/app/main.py index e42c91ef..432219eb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ 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 @@ -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 2b222440..b346f65b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -313,6 +313,42 @@ 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_hash: str = Field(index=True, unique=True) + audience: PublicProfileAudience = Field(default=PublicProfileAudience.public) + 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/profile.py b/backend/app/routers/profile.py index a34dfc37..5f4ef840 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -36,7 +36,7 @@ UserSettingsRead, UserSettingsUpdate, ) -from app.routers.statistics import MAX_CUSTOM_RANGE_DAYS +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, diff --git a/backend/app/routers/public_profile.py b/backend/app/routers/public_profile.py new file mode 100644 index 00000000..ee5582cb --- /dev/null +++ b/backend/app/routers/public_profile.py @@ -0,0 +1,142 @@ +"""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, + 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..e4f26fee --- /dev/null +++ b/backend/app/routers/share_links.py @@ -0,0 +1,147 @@ +"""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, +) +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, + 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_hash=hash_public_profile_token(plain_token), + audience=audience, + 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.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 523dd505..6d011cb0 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -1,506 +1,40 @@ -"""Statistics dashboard — full stats, pages-per-day breakdown, and book-level fallback.""" +"""Statistics dashboard — full stats, pages-per-day breakdown, and book-level fallback. -import calendar -from collections import Counter, defaultdict -from datetime import date, datetime, time, timedelta, timezone -from statistics import mean -from types import SimpleNamespace +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. +""" + +from datetime import date, datetime, timedelta, timezone from typing import Optional -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func +from fastapi import APIRouter, Depends, Query 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, Medium, 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, - MediumDistribution, - 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"]) -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 _subtract_months(dt: datetime, months: int) -> datetime: - """Return *dt* shifted back by *months*, clamping the day if needed.""" - year, month = dt.year, dt.month - months - while month <= 0: - month += 12 - year -= 1 - last_dom = calendar.monthrange(year, month)[1] - day = min(dt.day, last_dom) - return dt.replace(year=year, month=month, day=day) - - -def _subtract_years(dt: datetime, years: int) -> datetime: - """Return *dt* shifted back by *years*, handling Feb 29 gracefully.""" - year = dt.year - years - try: - return dt.replace(year=year) - except ValueError: - return dt.replace(year=year, month=2, day=28) - - -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*. - - Predefined -> ``now - delta`` (inclusive) 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.thirty_days: - start = now - timedelta(days=30) - elif range_value == StatisticsRange.six_months: - start = _subtract_months(now, 6) - elif range_value == StatisticsRange.one_year: - start = _subtract_years(now, 1) - elif range_value == StatisticsRange.three_years: - start = _subtract_years(now, 3) - 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 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) @@ -632,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) @@ -665,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") @@ -704,416 +236,10 @@ def get_statistics( ratings, page buckets) are computed over the full library. """ assert current_user.id is not None - - 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, current_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 == 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), - ) - - 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 == 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, - ) - - 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 == current_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 == current_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 == current_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 == current_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 == 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, - 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, - ) + 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 9ce1cb12..133b2a25 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -10,7 +10,7 @@ from sqlmodel import Field, SQLModel from sqlmodel._compat import SQLModelConfig -from app.models import AcquisitionStatus, Medium, ReadingStatus, UserRole +from app.models import AcquisitionStatus, Medium, ReadingStatus, PublicProfileAudience, UserRole class ReadingProgressCreate(SQLModel): @@ -762,6 +762,135 @@ 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 + 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 + 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 + 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 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 + 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/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/statistics.py b/backend/app/services/statistics.py new file mode 100644 index 00000000..bab3454a --- /dev/null +++ b/backend/app/services/statistics.py @@ -0,0 +1,947 @@ +"""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 _subtract_months(dt: datetime, months: int) -> datetime: + """Return *dt* shifted back by *months*, clamping the day if needed.""" + year, month = dt.year, dt.month - months + while month <= 0: + month += 12 + year -= 1 + last_dom = calendar.monthrange(year, month)[1] + day = min(dt.day, last_dom) + return dt.replace(year=year, month=month, day=day) + + +def _subtract_years(dt: datetime, years: int) -> datetime: + """Return *dt* shifted back by *years*, handling Feb 29 gracefully.""" + year = dt.year - years + try: + return dt.replace(year=year) + except ValueError: + return dt.replace(year=year, month=2, day=28) + + +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*. + - Predefined -> ``now - delta`` (inclusive) 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.thirty_days: + start = now - timedelta(days=30) + elif range_value == StatisticsRange.six_months: + start = _subtract_months(now, 6) + elif range_value == StatisticsRange.one_year: + start = _subtract_years(now, 1) + elif range_value == StatisticsRange.three_years: + start = _subtract_years(now, 3) + 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, + ) \ No newline at end of file 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_public_profile.py b/backend/tests/test_public_profile.py new file mode 100644 index 00000000..ef7ea42a --- /dev/null +++ b/backend/tests/test_public_profile.py @@ -0,0 +1,407 @@ +"""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_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 \ No newline at end of file diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index ff6b9f2e..c5552b78 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -10,7 +10,7 @@ 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.services.statistics import _extract_book_level_daily_pages def _create_book(client: Any, **overrides: Any) -> dict[str, Any]: @@ -403,7 +403,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 +413,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 +425,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 +435,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 +543,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 +556,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 +570,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 +590,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 +615,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)), @@ -632,7 +632,7 @@ def test_extract_progress_daily_pages_skips_outside_window() -> None: 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.routers.statistics import _extract_progress_daily_pages + 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)), @@ -644,7 +644,7 @@ def test_extract_progress_daily_pages_splits_delta_across_calendar_days() -> Non 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.routers.statistics import _extract_progress_daily_pages + 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)), @@ -655,7 +655,7 @@ def test_extract_progress_daily_pages_keeps_last_day_of_partial_span() -> None: 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", @@ -675,7 +675,7 @@ def test_extract_book_level_daily_pages_skips_outside_window() -> None: def test_statistics_monthly_pages_clamp_to_selected_window() -> None: - from app.routers.statistics import _compute_pages_per_month_from_books + from app.services.statistics import _compute_pages_per_month_from_books book = Book( title="Windowed", 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..8d4232ab --- /dev/null +++ b/frontend/e2e/specs/15-public-profile.spec.ts @@ -0,0 +1,153 @@ +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[] } = {}) { + 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(); + } + } + + 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 back to the project without leaking viewer chrome + await expect(publicPage.getByRole('link', { name: 'LibrisLog on GitHub' }).first()).toBeVisible(); + 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(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5011c216..13f621c7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -41,6 +41,11 @@ import type { SortOrder, OidcConfig, OidcLinkStatus, + PublicProfileAudience, + PublicProfileLink, + PublicProfileLinkCreateResponse, + PublicProfileResponse, + PublicProfileVisibilityConfig, User, UserCreateResponse, UserAdminUpdate, @@ -99,6 +104,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 }> { @@ -214,6 +248,41 @@ export const api = { return request(`/profile/embed-tokens/${id}`, { method: 'DELETE' }); }, + listShareLinks(): Promise { + return request('/profile/share-links'); + }, + + createShareLink(data: { + name: string; + audience: PublicProfileAudience; + 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; + 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' }); + }, + resetData(confirmation: string): Promise { return request('/profile/reset-data', { method: 'POST', @@ -259,6 +328,12 @@ export const api = { } }, + publicProfile: { + get(token: string): Promise { + return publicRequest(`/public-profiles/${encodeURIComponent(token)}`); + } + }, + statistics: { get(range: StatisticsRange = 'alltime', customFrom?: string | null, customTo?: string | null): Promise { const params = new URLSearchParams({ range }); diff --git a/frontend/src/lib/components/SegmentedDateInput.test.ts b/frontend/src/lib/components/SegmentedDateInput.test.ts index 9a3d5126..7df333f8 100644 --- a/frontend/src/lib/components/SegmentedDateInput.test.ts +++ b/frontend/src/lib/components/SegmentedDateInput.test.ts @@ -73,7 +73,7 @@ describe('SegmentedDateInput', () => { }); it('copies the full date on Ctrl/Cmd+C', async () => { - const writeText = vi.fn(async () => undefined); + const writeText = vi.fn<(text: string) => Promise>(async () => undefined); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } diff --git a/frontend/src/lib/components/ShareLinkDialog.svelte b/frontend/src/lib/components/ShareLinkDialog.svelte new file mode 100644 index 00000000..244eb894 --- /dev/null +++ b/frontend/src/lib/components/ShareLinkDialog.svelte @@ -0,0 +1,317 @@ + + +{#if open} + + + +{/if} \ No newline at end of file diff --git a/frontend/src/lib/components/ShareLinkDialog.test.ts b/frontend/src/lib/components/ShareLinkDialog.test.ts new file mode 100644 index 00000000..98b119a5 --- /dev/null +++ b/frontend/src/lib/components/ShareLinkDialog.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, waitFor } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; +import ShareLinkDialog from './ShareLinkDialog.svelte'; +import { defaultPublicProfileVisibilityConfig } from '$lib/publicProfile/sections'; +import type { PublicProfileLink } from '$lib/types'; + +vi.mock('$lib/i18n', () => { + const t = (key: string) => `[${key}]`; + return { _: readable(t) }; +}); + +function createLink(overrides?: Partial): PublicProfileLink { + const visibility_config = defaultPublicProfileVisibilityConfig(); + return { + id: 1, + name: 'Friends & family', + token_prefix: 'lp_9f2c81a4e7d3', + audience: 'public', + visibility_config, + expires_at: null, + created_at: '2026-01-01T00:00:00Z', + ...overrides + }; +} + +describe('ShareLinkDialog', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('renders a create dialog with defaults and disables save until a name is entered', async () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + const { container, unmount } = render(ShareLinkDialog, { + props: { open: true, link: null, onSave, onClose } + }); + + const dialog = container.querySelector('dialog'); + expect(dialog).toBeTruthy(); + expect(dialog!.getAttribute('aria-modal')).toBe('true'); + + const nameInput = container.querySelector('input[name="share-link-name"]')!; + expect(nameInput).toBeTruthy(); + const submit = container.querySelector('button[type="submit"]')!; + expect(submit.disabled).toBe(true); + + await fireEvent.input(nameInput, { target: { value: 'My profile' } }); + expect(submit.disabled).toBe(false); + + const checkedSections = [...container.querySelectorAll('input[name="share-link-section"]:checked')].map( + (i) => i.value + ); + expect(checkedSections).toEqual(defaultPublicProfileVisibilityConfig().sections); + + await fireEvent.click(submit); + expect(onSave).toHaveBeenCalledTimes(1); + const payload = onSave.mock.calls[0][0] as { + name: string; + audience: string; + expires_at: string | null; + visibility_config: { sections: string[]; statistics: string[] }; + }; + expect(payload.name).toBe('My profile'); + expect(payload.audience).toBe('public'); + expect(payload.visibility_config.sections).toEqual(defaultPublicProfileVisibilityConfig().sections); + expect(payload.visibility_config.statistics).toEqual(defaultPublicProfileVisibilityConfig().statistics); + expect(payload.expires_at).toBeNull(); + unmount(); + }); + + it('prefills an existing link and unchecking statistics clears selected statistics', async () => { + const link = createLink({ + name: 'Friends', + audience: 'authenticated', + visibility_config: { + sections: ['username', 'user_info', 'currently_reading', 'statistics'], + statistics: ['total_books', 'total_authors'] + }, + expires_at: '2026-12-31T12:00:00Z' + }); + const onSave = vi.fn(); + const onClose = vi.fn(); + const { container, unmount } = render(ShareLinkDialog, { + props: { open: true, link, onSave, onClose } + }); + + const nameInput = container.querySelector('input[name="share-link-name"]')!; + expect(nameInput.value).toBe('Friends'); + const authRadio = container.querySelector('input[name="share-link-audience"][value="authenticated"]')!; + expect(authRadio.checked).toBe(true); + + const statisticsCheckbox = [...container.querySelectorAll('input[name="share-link-section"]')].find( + (i) => i.value === 'statistics' + )!; + await fireEvent.click(statisticsCheckbox); + const statsCheckboxes = [...container.querySelectorAll('input[name="share-link-statistic"]')]; + expect(statsCheckboxes.every((c) => !c.checked)).toBe(true); + + await fireEvent.click(container.querySelector('button[type="submit"]')!); + const payload = onSave.mock.calls[0][0] as { + visibility_config: { sections: string[]; statistics: string[] }; + expires_at: string | null; + audience: string; + }; + expect(payload.visibility_config.statistics).toEqual([]); + expect(payload.visibility_config.sections).not.toContain('statistics'); + expect(payload.expires_at).toBe(new Date('2026-12-31T23:59:59').toISOString()); + unmount(); + }); + + it('closes on Escape', async () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + const { container, unmount } = render(ShareLinkDialog, { + props: { open: true, link: null, onSave, onClose } + }); + + const dialog = container.querySelector('dialog')!; + await fireEvent.keyDown(dialog, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('does not render anything when closed', () => { + const { container } = render(ShareLinkDialog, { + props: { open: false, link: null, onSave: vi.fn(), onClose: vi.fn() } + }); + expect(container.querySelector('dialog')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 4962dbdb..112dab34 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -489,6 +489,8 @@ "invalidConfirmationPhrase": "Bestätigungsphrase stimmt nicht überein.", "cannotDeleteLastAdmin": "Konto kann nicht gelöscht werden: du bist der letzte Administrator", "cannotDeleteOwnAccountHere": "Das eigene Konto kann hier nicht gelöscht werden. Verwende Profil > Gefahrenbereich.", + "publicProfileNotFound": "Dieser öffentliche Profil-Link ist nicht mehr gültig.", + "publicProfileLoginRequired": "Dieses Profil ist nur für angemeldete Benutzer sichtbar. Bitte melde dich an, um es anzusehen.", "importMalformedEvent": "Während des Imports wurde ein fehlerhaftes Server-Ereignis empfangen.", "importUnsupportedContentType": "Nicht unterstützter Upload-Inhaltstyp. Bitte CSV- oder JSON-Dateien verwenden.", "emailAlreadyRegistered": "Diese E-Mail-Adresse ist bereits registriert.", @@ -527,6 +529,12 @@ "profileSaveFailed": "Profil konnte nicht gespeichert werden", "passwordChangeSuccess": "Passwort geändert", "passwordChangeFailed": "Passwort konnte nicht geändert werden", + "shareProfile": { + "title": "Profil teilen", + "subtitle": "Veröffentliche eine schreibgeschützte Ansicht deines Leseprofils hinter einem Link.", + "createLink": "Neue URL erstellen", + "empty": "Noch keine öffentlichen Profil-Links vorhanden." + }, "goals": { "title": "Leseziele", "subtitle": "Aktiviere Ziele, um sie spielerisch auf dem Dashboard zu verfolgen.", @@ -578,6 +586,84 @@ } } }, + "publicProfile": { + "unlimited": "Unbegrenzt", + "validUntil": "Gültig bis", + "expiresAt": "Läuft ab am", + "expired": "Abgelaufen", + "active": "Aktiv", + "edit": "Bearbeiten", + "delete": "Löschen", + "deleteConfirm": "Diesen öffentlichen Profillink löschen? Alle mit dem Link verlieren den Zugriff.", + "openLink": "Link öffnen", + "copyLink": "Link kopieren", + "tokenShownOnce": "Kopiere diesen Link jetzt. Er wird nur einmal angezeigt.", + "linkCreated": "Öffentlicher Profil-Link erstellt.", + "linkDeleted": "Öffentlicher Profil-Link gelöscht.", + "saveFailed": "Der öffentliche Profil-Link konnte nicht gespeichert werden", + "loadFailed": "Öffentliche Profil-Links konnten nicht geladen werden", + "dialogTitleCreate": "Öffentlichen Profil-Link erstellen", + "dialogTitleEdit": "Öffentlichen Profil-Link bearbeiten", + "dialogName": "Name", + "dialogNamePlaceholder": "z. B. Freunde & Familie", + "accessGroup": "Zugriff", + "contentGroup": "Inhalt", + "validityGroup": "Gültigkeit", + "audiencePublic": "Alle", + "audienceAuthenticated": "Nur angemeldete Benutzer", + "audiencePublicTooltip": "Jeder mit dem Link kann das Profil ansehen, auch ohne Anmeldung.", + "audienceAuthenticatedTooltip": "Nur angemeldete Benutzer können das Profil ansehen.", + "selectAllSections": "Alle auswählen", + "selectNoneSections": "Keine auswählen", + "selectAllStats": "Alle auswählen", + "selectNoneStats": "Keine auswählen", + "statGroups": { + "summary": "Zusammenfassung", + "distribution": "Verteilung", + "trends": "Trends", + "ratings": "Bewertungen" + }, + "statistics": { + "totalAuthors": "Gesamtzahl Autoren" + }, + "sections": { + "username": "Benutzername", + "usernameTooltip": "Zeige den vollständigen Namen des Besitzers als Seitenüberschrift.", + "userInfo": "Benutzerinfo", + "userInfoTooltip": "Zeige eine kurze Profilkarte mit dem Namen des Besitzers.", + "currentlyReading": "Aktuell gelesen", + "currentlyReadingTooltip": "Zeige Bücher, die gerade gelesen werden.", + "currentlyReadingStarted": "Begonnen am {date}", + "lastRead": "Zuletzt gelesen", + "lastReadTooltip": "Zeige die zuletzt beendeten Bücher.", + "readingTimeline": "Leseverlauf", + "readingTimelineTooltip": "Zeige alle beendeten Bücher in einer chronologischen Zeitleiste.", + "fullLibrary": "Vollständige Bibliothek", + "fullLibraryTooltip": "Zeige die gesamte Bibliothek des Besitzers, durchsuchbar mit Seitenumbruch.", + "statistics": "Statistiken", + "statisticsTooltip": "Zeige Lesestatistiken (unten konfigurierbar)." + }, + "page": { + "loading": "Öffentliches Profil wird geladen...", + "notFound": "Dieser öffentliche Profil-Link ist nicht mehr gültig.", + "notFoundDesc": "Der Link ist möglicherweise abgelaufen oder wurde widerrufen.", + "loginRequired": "Anmeldung erforderlich", + "loginRequiredDesc": "Dieses Profil ist nur für angemeldete Benutzer sichtbar. Bitte melde dich an, um es anzusehen.", + "loginButton": "Anmelden", + "errorTitle": "Das öffentliche Profil konnte nicht geladen werden", + "errorDesc": "Beim Laden dieser Seite ist etwas schiefgelaufen. Bitte versuche es später erneut.", + "retry": "Erneut versuchen", + "showMore": "Mehr anzeigen", + "shareHint": "Bereitgestellt von LibrisLog", + "githubLink": "LibrisLog auf GitHub", + "expiresHint": "Dieser Link läuft am {date} ab.", + "finishedOn": "Beendet am {date}", + "emptyLibrary": "Diese Bibliothek ist leer.", + "noCurrentlyReading": "Es wird gerade nichts gelesen.", + "noLastRead": "Noch keine beendeten Bücher.", + "themeToggle": "Design umschalten" + } + }, "timeline": { "title": "Lese-Zeitleiste", "subtitle": "Eine chronologische Ansicht der Bücher, die du gelesen hast", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 9578d057..9475aa12 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -489,6 +489,8 @@ "invalidConfirmationPhrase": "Confirmation phrase does not match.", "cannotDeleteLastAdmin": "Cannot delete account: you are the last administrator", "cannotDeleteOwnAccountHere": "You cannot delete your own account here. Use Profile > Danger Zone.", + "publicProfileNotFound": "This public profile link is no longer valid.", + "publicProfileLoginRequired": "This profile is only visible to logged in users. Please log in to view it.", "importMalformedEvent": "Received malformed server event during import.", "importUnsupportedContentType": "Unsupported upload content type. Use CSV or JSON files.", "emailAlreadyRegistered": "This email address is already registered.", @@ -527,6 +529,12 @@ "profileSaveFailed": "Failed to save profile", "passwordChangeSuccess": "Password changed", "passwordChangeFailed": "Failed to change password", + "shareProfile": { + "title": "Share Profile", + "subtitle": "Publish a read-only view of your reading profile behind a link.", + "createLink": "Create New URL", + "empty": "No public profile links yet." + }, "goals": { "title": "Reading Goals", "subtitle": "Enable goals to track them playfully on your dashboard.", @@ -578,6 +586,84 @@ } } }, + "publicProfile": { + "unlimited": "Unlimited", + "validUntil": "Valid until", + "expiresAt": "Expires at", + "expired": "Expired", + "active": "Active", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Delete this public profile link? Anyone with the link will lose access.", + "openLink": "Open link", + "copyLink": "Copy link", + "tokenShownOnce": "Copy this link now. It is only shown once.", + "linkCreated": "Public profile link created.", + "linkDeleted": "Public profile link deleted.", + "saveFailed": "Failed to save the public profile link", + "loadFailed": "Failed to load public profile links", + "dialogTitleCreate": "Create a public profile link", + "dialogTitleEdit": "Edit public profile link", + "dialogName": "Name", + "dialogNamePlaceholder": "e.g. Friends & family", + "accessGroup": "Access", + "contentGroup": "Content", + "validityGroup": "Validity", + "audiencePublic": "Everyone", + "audienceAuthenticated": "Logged-in users only", + "audiencePublicTooltip": "Anyone with the link can view the profile, including people who are not logged in.", + "audienceAuthenticatedTooltip": "Only users who are logged in can view the profile.", + "selectAllSections": "Select all", + "selectNoneSections": "Select none", + "selectAllStats": "Select all", + "selectNoneStats": "Select none", + "statGroups": { + "summary": "Summary", + "distribution": "Distribution", + "trends": "Trends", + "ratings": "Ratings" + }, + "statistics": { + "totalAuthors": "Total Authors" + }, + "sections": { + "username": "Username", + "usernameTooltip": "Show the owner's full name as the page heading.", + "userInfo": "User info", + "userInfoTooltip": "Show a short profile card with the owner's name.", + "currentlyReading": "Currently Reading", + "currentlyReadingTooltip": "Show books that are currently being read.", + "currentlyReadingStarted": "Started on {date}", + "lastRead": "Last Read", + "lastReadTooltip": "Show the most recently finished books.", + "readingTimeline": "Reading Timeline", + "readingTimelineTooltip": "Show all finished books in a chronological timeline.", + "fullLibrary": "Full Library", + "fullLibraryTooltip": "Show the owner's entire library, browsable with pagination.", + "statistics": "Statistics", + "statisticsTooltip": "Show reading statistics (configured below)." + }, + "page": { + "loading": "Loading public profile...", + "notFound": "This public profile link is no longer valid.", + "notFoundDesc": "The link may have expired or been revoked.", + "loginRequired": "Login required", + "loginRequiredDesc": "This profile is only visible to logged in users. Please log in to view it.", + "loginButton": "Log in", + "errorTitle": "Could not load the public profile", + "errorDesc": "Something went wrong while loading this page. Please try again later.", + "retry": "Retry", + "showMore": "Show more", + "shareHint": "Powered by LibrisLog", + "githubLink": "LibrisLog on GitHub", + "expiresHint": "This link expires on {date}.", + "finishedOn": "Finished on {date}", + "emptyLibrary": "This library is empty.", + "noCurrentlyReading": "Nothing is currently being read.", + "noLastRead": "No finished books yet.", + "themeToggle": "Toggle theme" + } + }, "timeline": { "title": "Reading Timeline", "subtitle": "A chronological view of books you've finished reading", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index bb5cff1c..d3a08c4e 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -489,6 +489,8 @@ "invalidConfirmationPhrase": "La frase de confirmación no coincide.", "cannotDeleteLastAdmin": "No se puede eliminar la cuenta: eres el último administrador", "cannotDeleteOwnAccountHere": "No puedes eliminar tu propia cuenta aquí. Usa Perfil > Zona de peligro.", + "publicProfileNotFound": "Este enlace de perfil público ya no es válido.", + "publicProfileLoginRequired": "Este perfil solo es visible para los usuarios con sesión iniciada. Inicia sesión para verlo.", "importMalformedEvent": "Se recibió un evento de servidor malformado durante la importación.", "importUnsupportedContentType": "Tipo de contenido no admitido. Usa archivos CSV o JSON.", "emailAlreadyRegistered": "Esta dirección de correo ya está registrada.", @@ -527,6 +529,12 @@ "profileSaveFailed": "Error al guardar el perfil", "passwordChangeSuccess": "Contraseña cambiada", "passwordChangeFailed": "Error al cambiar la contraseña", + "shareProfile": { + "title": "Compartir perfil", + "subtitle": "Publica una vista de solo lectura de tu perfil de lectura detrás de un enlace.", + "createLink": "Crear nueva URL", + "empty": "Aún no hay enlaces de perfil público." + }, "goals": { "title": "Metas de lectura", "subtitle": "Activa metas para seguirlas de forma lúdica en el panel.", @@ -578,6 +586,84 @@ } } }, + "publicProfile": { + "unlimited": "Sin límite", + "validUntil": "Válido hasta", + "expiresAt": "Caduca el", + "expired": "Caducado", + "active": "Activo", + "edit": "Editar", + "delete": "Eliminar", + "deleteConfirm": "¿Eliminar este enlace de perfil público? Quienes tengan el enlace perderán el acceso.", + "openLink": "Abrir enlace", + "copyLink": "Copiar enlace", + "tokenShownOnce": "Copia este enlace ahora. Solo se muestra una vez.", + "linkCreated": "Enlace de perfil público creado.", + "linkDeleted": "Enlace de perfil público eliminado.", + "saveFailed": "No se pudo guardar el enlace de perfil público", + "loadFailed": "No se pudieron cargar los enlaces de perfil público", + "dialogTitleCreate": "Crear enlace de perfil público", + "dialogTitleEdit": "Editar enlace de perfil público", + "dialogName": "Nombre", + "dialogNamePlaceholder": "p. ej. Amigos y familia", + "accessGroup": "Acceso", + "contentGroup": "Contenido", + "validityGroup": "Validez", + "audiencePublic": "Todos", + "audienceAuthenticated": "Solo usuarios con sesión iniciada", + "audiencePublicTooltip": "Cualquier persona con el enlace puede ver el perfil, incluso sin iniciar sesión.", + "audienceAuthenticatedTooltip": "Solo los usuarios con sesión iniciada pueden ver el perfil.", + "selectAllSections": "Seleccionar todo", + "selectNoneSections": "Seleccionar ninguno", + "selectAllStats": "Seleccionar todo", + "selectNoneStats": "Seleccionar ninguno", + "statGroups": { + "summary": "Resumen", + "distribution": "Distribución", + "trends": "Tendencias", + "ratings": "Valoraciones" + }, + "statistics": { + "totalAuthors": "Total de autores" + }, + "sections": { + "username": "Nombre de usuario", + "usernameTooltip": "Mostrar el nombre completo del propietario como encabezado de la página.", + "userInfo": "Información del usuario", + "userInfoTooltip": "Mostrar una breve tarjeta de perfil con el nombre del propietario.", + "currentlyReading": "Leyendo actualmente", + "currentlyReadingTooltip": "Mostrar libros que se están leyendo actualmente.", + "currentlyReadingStarted": "Empezado el {date}", + "lastRead": "Últimos leídos", + "lastReadTooltip": "Mostrar los libros terminados más recientes.", + "readingTimeline": "Cronología de lectura", + "readingTimelineTooltip": "Mostrar todos los libros terminados en una cronología cronológica.", + "fullLibrary": "Biblioteca completa", + "fullLibraryTooltip": "Mostrar toda la biblioteca del propietario, con paginación.", + "statistics": "Estadísticas", + "statisticsTooltip": "Mostrar estadísticas de lectura (configurables abajo)." + }, + "page": { + "loading": "Cargando perfil público...", + "notFound": "Este enlace de perfil público ya no es válido.", + "notFoundDesc": "El enlace puede haber caducado o haber sido revocado.", + "loginRequired": "Inicio de sesión requerido", + "loginRequiredDesc": "Este perfil solo es visible para los usuarios con sesión iniciada. Inicia sesión para verlo.", + "loginButton": "Iniciar sesión", + "errorTitle": "No se pudo cargar el perfil público", + "errorDesc": "Algo salió mal al cargar esta página. Inténtalo de nuevo más tarde.", + "retry": "Reintentar", + "showMore": "Mostrar más", + "shareHint": "Desarrollado por LibrisLog", + "githubLink": "LibrisLog en GitHub", + "expiresHint": "Este enlace caduca el {date}.", + "finishedOn": "Terminado el {date}", + "emptyLibrary": "Esta biblioteca está vacía.", + "noCurrentlyReading": "No se está leyendo nada actualmente.", + "noLastRead": "Aún no hay libros terminados.", + "themeToggle": "Cambiar tema" + } + }, "timeline": { "title": "Cronología de lectura", "subtitle": "Una vista cronológica de los libros que has terminado", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index b7be1ecc..58299d10 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -489,6 +489,8 @@ "invalidConfirmationPhrase": "La phrase de confirmation ne correspond pas.", "cannotDeleteLastAdmin": "Impossible de supprimer le compte : tu es le dernier administrateur", "cannotDeleteOwnAccountHere": "Tu ne peux pas supprimer ton propre compte ici. Utilise Profil > Zone de danger.", + "publicProfileNotFound": "Ce lien de profil public n'est plus valide.", + "publicProfileLoginRequired": "Ce profil n'est visible que par les utilisateurs connectés. Connecte-toi pour le voir.", "importMalformedEvent": "Événement serveur malformé reçu lors de l'importation.", "importUnsupportedContentType": "Type de contenu non pris en charge. Utilise des fichiers CSV ou JSON.", "emailAlreadyRegistered": "Cette adresse e-mail est déjà enregistrée.", @@ -527,6 +529,12 @@ "profileSaveFailed": "Échec de l'enregistrement du profil", "passwordChangeSuccess": "Mot de passe modifié", "passwordChangeFailed": "Échec de la modification du mot de passe", + "shareProfile": { + "title": "Partager le profil", + "subtitle": "Publie une vue en lecture seule de ton profil de lecture derrière un lien.", + "createLink": "Créer une nouvelle URL", + "empty": "Aucun lien de profil public pour le moment." + }, "goals": { "title": "Objectifs de lecture", "subtitle": "Activez des objectifs pour les suivre de façon ludique sur votre tableau de bord.", @@ -578,6 +586,84 @@ } } }, + "publicProfile": { + "unlimited": "Sans limite", + "validUntil": "Valable jusqu'au", + "expiresAt": "Expire le", + "expired": "Expiré", + "active": "Actif", + "edit": "Modifier", + "delete": "Supprimer", + "deleteConfirm": "Supprimer ce lien de profil public ? Les personnes ayant le lien perdront l’accès.", + "openLink": "Ouvrir le lien", + "copyLink": "Copier le lien", + "tokenShownOnce": "Copie ce lien maintenant. Il n'est affiché qu'une seule fois.", + "linkCreated": "Lien de profil public créé.", + "linkDeleted": "Lien de profil public supprimé.", + "saveFailed": "Impossible d'enregistrer le lien de profil public", + "loadFailed": "Impossible de charger les liens de profil public", + "dialogTitleCreate": "Créer un lien de profil public", + "dialogTitleEdit": "Modifier le lien de profil public", + "dialogName": "Nom", + "dialogNamePlaceholder": "p. ex. Amis et famille", + "accessGroup": "Accès", + "contentGroup": "Contenu", + "validityGroup": "Validité", + "audiencePublic": "Tout le monde", + "audienceAuthenticated": "Utilisateurs connectés uniquement", + "audiencePublicTooltip": "Toute personne disposant du lien peut voir le profil, même sans être connectée.", + "audienceAuthenticatedTooltip": "Seuls les utilisateurs connectés peuvent voir le profil.", + "selectAllSections": "Tout sélectionner", + "selectNoneSections": "Ne rien sélectionner", + "selectAllStats": "Tout sélectionner", + "selectNoneStats": "Ne rien sélectionner", + "statGroups": { + "summary": "Résumé", + "distribution": "Répartition", + "trends": "Tendances", + "ratings": "Notes" + }, + "statistics": { + "totalAuthors": "Nombre total d'auteurs" + }, + "sections": { + "username": "Nom d'utilisateur", + "usernameTooltip": "Afficher le nom complet du propriétaire comme titre de la page.", + "userInfo": "Informations utilisateur", + "userInfoTooltip": "Afficher une courte carte de profil avec le nom du propriétaire.", + "currentlyReading": "En cours de lecture", + "currentlyReadingTooltip": "Afficher les livres actuellement en cours de lecture.", + "currentlyReadingStarted": "Commencé le {date}", + "lastRead": "Dernières lectures", + "lastReadTooltip": "Afficher les livres terminés les plus récents.", + "readingTimeline": "Chronologie de lecture", + "readingTimelineTooltip": "Afficher tous les livres terminés dans une chronologie.", + "fullLibrary": "Bibliothèque complète", + "fullLibraryTooltip": "Afficher toute la bibliothèque du propriétaire, navigable avec pagination.", + "statistics": "Statistiques", + "statisticsTooltip": "Afficher les statistiques de lecture (configurables ci-dessous)." + }, + "page": { + "loading": "Chargement du profil public...", + "notFound": "Ce lien de profil public n'est plus valide.", + "notFoundDesc": "Le lien a peut-être expiré ou a été révoqué.", + "loginRequired": "Connexion requise", + "loginRequiredDesc": "Ce profil n'est visible que par les utilisateurs connectés. Connecte-toi pour le voir.", + "loginButton": "Se connecter", + "errorTitle": "Impossible de charger le profil public", + "errorDesc": "Une erreur s'est produite lors du chargement de cette page. Réessaie plus tard.", + "retry": "Réessayer", + "showMore": "Afficher plus", + "shareHint": "Propulsé par LibrisLog", + "githubLink": "LibrisLog sur GitHub", + "expiresHint": "Ce lien expire le {date}.", + "finishedOn": "Terminé le {date}", + "emptyLibrary": "Cette bibliothèque est vide.", + "noCurrentlyReading": "Aucune lecture en cours.", + "noLastRead": "Aucun livre terminé pour le moment.", + "themeToggle": "Changer de thème" + } + }, "timeline": { "title": "Chronologie de lecture", "subtitle": "Une vue chronologique des livres que tu as terminés", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 7a770072..e7d7c0b3 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -489,6 +489,8 @@ "invalidConfirmationPhrase": "确认短语不匹配。", "cannotDeleteLastAdmin": "无法删除账户:你是最后一个管理员", "cannotDeleteOwnAccountHere": "你不能在这里删除自己的账户。请使用“个人资料” > “危险区域”。", + "publicProfileNotFound": "此公开资料链接已失效。", + "publicProfileLoginRequired": "此个人资料仅对已登录用户可见。请登录后查看。", "importMalformedEvent": "导入期间收到格式错误的服务器事件。", "importUnsupportedContentType": "不支持的上传内容类型。请使用 CSV 或 JSON 文件。", "emailAlreadyRegistered": "此邮箱地址已注册。", @@ -527,6 +529,12 @@ "profileSaveFailed": "保存个人资料失败", "passwordChangeSuccess": "密码已更改", "passwordChangeFailed": "更改密码失败", + "shareProfile": { + "title": "分享个人资料", + "subtitle": "通过链接发布你的阅读资料的只读视图。", + "createLink": "创建新链接", + "empty": "暂无公开资料链接。" + }, "goals": { "title": "阅读目标", "subtitle": "启用目标,即可在仪表板上趣味追踪进度。", @@ -578,6 +586,84 @@ } } }, + "publicProfile": { + "unlimited": "无期限", + "validUntil": "有效期至", + "expiresAt": "有效期至", + "expired": "已过期", + "active": "有效", + "edit": "编辑", + "delete": "删除", + "deleteConfirm": "删除此公开主页链接?拥有该链接的人将失去访问权限。", + "openLink": "打开链接", + "copyLink": "复制链接", + "tokenShownOnce": "请立即复制此链接。它只显示一次。", + "linkCreated": "公开资料链接已创建。", + "linkDeleted": "公开资料链接已删除。", + "saveFailed": "无法保存公开资料链接", + "loadFailed": "无法加载公开资料链接", + "dialogTitleCreate": "创建公开资料链接", + "dialogTitleEdit": "编辑公开资料链接", + "dialogName": "名称", + "dialogNamePlaceholder": "例如:亲友", + "accessGroup": "访问权限", + "contentGroup": "内容", + "validityGroup": "有效期", + "audiencePublic": "所有人", + "audienceAuthenticated": "仅限已登录用户", + "audiencePublicTooltip": "任何拥有链接的人都可以查看此资料,包括未登录的用户。", + "audienceAuthenticatedTooltip": "只有已登录用户才能查看此资料。", + "selectAllSections": "全选", + "selectNoneSections": "取消全选", + "selectAllStats": "全选", + "selectNoneStats": "取消全选", + "statGroups": { + "summary": "摘要", + "distribution": "分布", + "trends": "趋势", + "ratings": "评分" + }, + "statistics": { + "totalAuthors": "作者总数" + }, + "sections": { + "username": "用户名", + "usernameTooltip": "将所有者的全名显示为页面标题。", + "userInfo": "用户信息", + "userInfoTooltip": "显示包含所有者姓名的简短资料卡片。", + "currentlyReading": "正在阅读", + "currentlyReadingTooltip": "显示当前正在阅读的图书。", + "currentlyReadingStarted": "开始于 {date}", + "lastRead": "最近阅读", + "lastReadTooltip": "显示最近读完的图书。", + "readingTimeline": "阅读时间线", + "readingTimelineTooltip": "按时序显示所有已读完的图书。", + "fullLibrary": "完整书库", + "fullLibraryTooltip": "显示所有者的整个书库,可翻页浏览。", + "statistics": "统计", + "statisticsTooltip": "显示阅读统计(可在下方配置)。" + }, + "page": { + "loading": "正在加载公开资料...", + "notFound": "此公开资料链接已失效。", + "notFoundDesc": "该链接可能已过期或被撤销。", + "loginRequired": "需要登录", + "loginRequiredDesc": "此个人资料仅对已登录用户可见。请登录后查看。", + "loginButton": "登录", + "errorTitle": "无法加载公开资料", + "errorDesc": "加载此页面时出现问题。请稍后重试。", + "retry": "重试", + "showMore": "显示更多", + "shareHint": "由 LibrisLog 提供支持", + "githubLink": "LibrisLog on GitHub", + "expiresHint": "此链接将于 {date} 过期。", + "finishedOn": "完成于 {date}", + "emptyLibrary": "此书库为空。", + "noCurrentlyReading": "当前没有正在阅读的图书。", + "noLastRead": "暂无已读完的图书。", + "themeToggle": "切换主题" + } + }, "timeline": { "title": "阅读时间线", "subtitle": "你已完成阅读的图书的时间线视图", diff --git a/frontend/src/lib/publicProfile/sections.test.ts b/frontend/src/lib/publicProfile/sections.test.ts new file mode 100644 index 00000000..d73f4d23 --- /dev/null +++ b/frontend/src/lib/publicProfile/sections.test.ts @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import en from '$lib/i18n/locales/en.json'; +import type { PublicProfileSectionKey, PublicProfileStatisticsKey } from '$lib/types'; +import { + PUBLIC_PROFILE_SECTIONS, + PUBLIC_PROFILE_STATISTICS, + PUBLIC_PROFILE_STATISTICS_GROUPS, + defaultPublicProfileVisibilityConfig +} from './sections'; + +// Compile-time guard: registries must stay assignable to the backend-driven unions. +const sectionKeys: PublicProfileSectionKey[] = PUBLIC_PROFILE_SECTIONS.map((s) => s.key); +const statisticKeys: PublicProfileStatisticsKey[] = PUBLIC_PROFILE_STATISTICS.map((s) => s.key); + +const ALL_SECTIONS: PublicProfileSectionKey[] = [ + 'username', + 'user_info', + 'currently_reading', + 'last_read', + 'reading_timeline', + 'full_library', + 'statistics' +]; + +const ALL_STATISTICS: PublicProfileStatisticsKey[] = [ + 'total_books', + 'total_authors', + 'avg_books_per_month', + 'busiest_month', + 'avg_page_count', + 'most_popular_language', + 'language_distribution', + 'status_distribution', + 'acquisition_status_distribution', + 'medium_distribution', + 'page_buckets', + 'pages_read_per_month', + 'books_finished_per_month', + 'books_finished_per_year', + 'top_authors', + 'books_with_rating', + 'books_without_rating', + 'average_rating', + 'top_rated_books', + 'worst_rated_books' +]; + +function lookup(obj: unknown, path: string): unknown { + return path.split('.').reduce((acc, part) => (acc as Record)?.[part], obj); +} + +describe('public profile registries', () => { + it('sections cover the full union with unique keys', () => { + expect(new Set(sectionKeys).size).toBe(sectionKeys.length); + expect([...sectionKeys].sort()).toEqual([...ALL_SECTIONS].sort()); + }); + + it('statistics cover the full union with unique keys', () => { + expect(new Set(statisticKeys).size).toBe(statisticKeys.length); + expect([...statisticKeys].sort()).toEqual([...ALL_STATISTICS].sort()); + }); + + it('every statistic belongs to a declared group', () => { + const groups = [...new Set(PUBLIC_PROFILE_STATISTICS.map((s) => s.group))].sort(); + expect(groups).toEqual(['distribution', 'ratings', 'summary', 'trends']); + }); + + it('default visibility config derives from defaultOn flags', () => { + const cfg = defaultPublicProfileVisibilityConfig(); + expect(cfg.sections).toEqual(PUBLIC_PROFILE_SECTIONS.filter((s) => s.defaultOn).map((s) => s.key)); + expect(cfg.statistics).toEqual( + PUBLIC_PROFILE_STATISTICS.filter((s) => s.defaultOn).map((s) => s.key) + ); + }); + + it('every referenced i18n key exists in en.json', () => { + const missing: string[] = []; + for (const s of PUBLIC_PROFILE_SECTIONS) { + for (const key of [s.i18nKey, s.tooltipKey]) { + if (lookup(en, key) === undefined) missing.push(key); + } + } + for (const g of PUBLIC_PROFILE_STATISTICS_GROUPS) { + if (lookup(en, g.labelKey) === undefined) missing.push(g.labelKey); + } + for (const s of PUBLIC_PROFILE_STATISTICS) { + if (lookup(en, s.i18nKey) === undefined) missing.push(s.i18nKey); + } + expect(missing).toEqual([]); + }); + + it('every i18n literal used by the public page renders a real string', () => { + const src = readFileSync( + resolve(process.cwd(), 'src/routes/p/[token]/+page.svelte'), + 'utf-8' + ); + const literals = [ + ...new Set( + [...src.matchAll(/\$_\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]) + ) + ]; + const missing = literals.filter((key) => lookup(en, key) === undefined); + expect(missing).toEqual([]); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/publicProfile/sections.ts b/frontend/src/lib/publicProfile/sections.ts new file mode 100644 index 00000000..d683fb1c --- /dev/null +++ b/frontend/src/lib/publicProfile/sections.ts @@ -0,0 +1,208 @@ +import type { + PublicProfileSectionKey, + PublicProfileStatisticsKey, + PublicProfileVisibilityConfig +} from '$lib/types'; + +export interface PublicProfileSectionDef { + key: PublicProfileSectionKey; + i18nKey: string; + tooltipKey: string; + /** Enabled by default when a new share link is created. */ + defaultOn: boolean; +} + +export const PUBLIC_PROFILE_SECTIONS: PublicProfileSectionDef[] = [ + { + key: 'username', + i18nKey: 'publicProfile.sections.username', + tooltipKey: 'publicProfile.sections.usernameTooltip', + defaultOn: true + }, + { + key: 'user_info', + i18nKey: 'publicProfile.sections.userInfo', + tooltipKey: 'publicProfile.sections.userInfoTooltip', + defaultOn: true + }, + { + key: 'currently_reading', + i18nKey: 'publicProfile.sections.currentlyReading', + tooltipKey: 'publicProfile.sections.currentlyReadingTooltip', + defaultOn: true + }, + { + key: 'last_read', + i18nKey: 'publicProfile.sections.lastRead', + tooltipKey: 'publicProfile.sections.lastReadTooltip', + defaultOn: false + }, + { + key: 'reading_timeline', + i18nKey: 'publicProfile.sections.readingTimeline', + tooltipKey: 'publicProfile.sections.readingTimelineTooltip', + defaultOn: false + }, + { + key: 'full_library', + i18nKey: 'publicProfile.sections.fullLibrary', + tooltipKey: 'publicProfile.sections.fullLibraryTooltip', + defaultOn: false + }, + { + key: 'statistics', + i18nKey: 'publicProfile.sections.statistics', + tooltipKey: 'publicProfile.sections.statisticsTooltip', + defaultOn: true + } +]; + +export type PublicProfileStatisticsGroup = 'summary' | 'distribution' | 'trends' | 'ratings'; + +export interface PublicProfileStatisticsDef { + key: PublicProfileStatisticsKey; + i18nKey: string; + group: PublicProfileStatisticsGroup; + defaultOn: boolean; +} + +export const PUBLIC_PROFILE_STATISTICS_GROUPS: { + group: PublicProfileStatisticsGroup; + labelKey: string; +}[] = [ + { group: 'summary', labelKey: 'publicProfile.statGroups.summary' }, + { group: 'distribution', labelKey: 'statistics.sectionDistributions' }, + { group: 'trends', labelKey: 'statistics.sectionCharts' }, + { group: 'ratings', labelKey: 'statistics.ratingStats' } +]; + +export const PUBLIC_PROFILE_STATISTICS: PublicProfileStatisticsDef[] = [ + { + key: 'total_books', + i18nKey: 'statistics.totalBooksAndAuthors', + group: 'summary', + defaultOn: true + }, + { + key: 'total_authors', + i18nKey: 'publicProfile.statistics.totalAuthors', + group: 'summary', + defaultOn: true + }, + { + key: 'avg_books_per_month', + i18nKey: 'statistics.avgBooksPerMonth', + group: 'summary', + defaultOn: false + }, + { + key: 'busiest_month', + i18nKey: 'statistics.busiestMonth', + group: 'summary', + defaultOn: false + }, + { + key: 'avg_page_count', + i18nKey: 'statistics.avgPageCount', + group: 'summary', + defaultOn: false + }, + { + key: 'most_popular_language', + i18nKey: 'statistics.mostPopularLanguage', + group: 'summary', + defaultOn: false + }, + { + key: 'language_distribution', + i18nKey: 'statistics.languageDistribution', + group: 'distribution', + defaultOn: false + }, + { + key: 'status_distribution', + i18nKey: 'statistics.statusDistribution', + group: 'distribution', + defaultOn: true + }, + { + key: 'acquisition_status_distribution', + i18nKey: 'statistics.acquisitionStatusDistribution', + group: 'distribution', + defaultOn: false + }, + { + key: 'medium_distribution', + i18nKey: 'statistics.mediumDistribution', + group: 'distribution', + defaultOn: false + }, + { + key: 'page_buckets', + i18nKey: 'statistics.pageBuckets', + group: 'distribution', + defaultOn: false + }, + { + key: 'pages_read_per_month', + i18nKey: 'statistics.pagesReadPerMonth', + group: 'trends', + defaultOn: false + }, + { + key: 'books_finished_per_month', + i18nKey: 'statistics.booksFinishedPerMonth', + group: 'trends', + defaultOn: false + }, + { + key: 'books_finished_per_year', + i18nKey: 'statistics.booksFinishedPerYear', + group: 'trends', + defaultOn: false + }, + { + key: 'top_authors', + i18nKey: 'statistics.topAuthors', + group: 'ratings', + defaultOn: false + }, + { + key: 'books_with_rating', + i18nKey: 'statistics.booksWithRating', + group: 'ratings', + defaultOn: false + }, + { + key: 'books_without_rating', + i18nKey: 'statistics.booksWithoutRating', + group: 'ratings', + defaultOn: false + }, + { + key: 'average_rating', + i18nKey: 'statistics.averageRating', + group: 'ratings', + defaultOn: false + }, + { + key: 'top_rated_books', + i18nKey: 'statistics.topRated', + group: 'ratings', + defaultOn: false + }, + { + key: 'worst_rated_books', + i18nKey: 'statistics.worstRated', + group: 'ratings', + defaultOn: false + } +]; + +/** Default visibility used when creating a new share link. */ +export function defaultPublicProfileVisibilityConfig(): PublicProfileVisibilityConfig { + return { + sections: PUBLIC_PROFILE_SECTIONS.filter((s) => s.defaultOn).map((s) => s.key), + statistics: PUBLIC_PROFILE_STATISTICS.filter((s) => s.defaultOn).map((s) => s.key) + }; +} \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 2f0c76e4..ce13f92f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -461,6 +461,87 @@ export interface HygieneBatchUpdateResponse { skipped_ids: number[]; } +export type PublicProfileAudience = 'public' | 'authenticated'; + +export type PublicProfileSectionKey = + | 'username' + | 'user_info' + | 'currently_reading' + | 'last_read' + | 'reading_timeline' + | 'full_library' + | 'statistics'; + +export type PublicProfileStatisticsKey = + | 'total_books' + | 'total_authors' + | 'avg_books_per_month' + | 'busiest_month' + | 'avg_page_count' + | 'most_popular_language' + | 'language_distribution' + | 'status_distribution' + | 'acquisition_status_distribution' + | 'medium_distribution' + | 'page_buckets' + | 'pages_read_per_month' + | 'books_finished_per_month' + | 'books_finished_per_year' + | 'top_authors' + | 'books_with_rating' + | 'books_without_rating' + | 'average_rating' + | 'top_rated_books' + | 'worst_rated_books'; + +export interface PublicProfileVisibilityConfig { + sections: PublicProfileSectionKey[]; + statistics: PublicProfileStatisticsKey[]; +} + +export interface PublicProfileLink { + id: number; + name: string; + token_prefix: string; + audience: PublicProfileAudience; + visibility_config: PublicProfileVisibilityConfig; + expires_at: string | null; + created_at: string; +} + +export interface PublicProfileLinkCreateResponse { + token: string; + link: PublicProfileLink; +} + +export interface PublicProfileUserInfo { + firstname: string; + lastname: string; +} + +export interface PublicProfileBook { + id: number; + title: string; + subtitle: string | null; + authors: string[]; + cover_url: string | null; + reading_status: ReadingStatus; + page_count: number | null; + language: string | null; + rating: number | null; + date_started: string | null; + date_finished: string | null; +} + +export interface PublicProfileResponse { + owner: PublicProfileUserInfo; + audience: PublicProfileAudience; + expires_at: string | null; + visibility_config: PublicProfileVisibilityConfig; + books: PublicProfileBook[]; + statistics: Record | null; +} + export type DataImportEvent = | { event: 'start'; total_rows: number } | { event: 'progress'; processed: number; total: number; percent: number } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 21abfb20..a05710fe 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -42,7 +42,8 @@ pathname.startsWith('/setup') || pathname.startsWith('/login') || pathname.startsWith('/reset-password') || - pathname.startsWith('/auth/oidc') + pathname.startsWith('/auth/oidc') || + pathname.startsWith('/p/') ); } diff --git a/frontend/src/routes/p/[token]/+page.svelte b/frontend/src/routes/p/[token]/+page.svelte new file mode 100644 index 00000000..a1526c26 --- /dev/null +++ b/frontend/src/routes/p/[token]/+page.svelte @@ -0,0 +1,590 @@ + + + + + {profile && (hasSection('username') || hasSection('user_info')) + ? `${profile.owner.firstname} ${profile.owner.lastname} - ${$_('app.title')}` + : $_('app.title')} + + + +
+
+ +
+ +
+ {#if status === 'loading'} +
+ +

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

+
+ {:else if status === 'not_found'} +
+
+

🔗

+

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

+

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

+
+
+ {:else if status === 'login_required'} +
+
+

🔒

+

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

+

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

+ {$_('publicProfile.page.loginButton')} +
+
+ {:else if status === 'error'} +
+
+

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

+

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

+ +
+
+ {:else if profile} + {#if profile.expires_at} +

+ {$_('publicProfile.page.expiresHint', { values: { date: new Date(profile.expires_at).toLocaleDateString() } })} +

+ {/if} + + {#if hasSection('username')} +

{profile.owner.firstname} {profile.owner.lastname}

+ {/if} + + {#if hasSection('user_info')} +
+
+

{$_('publicProfile.sections.userInfo')}

+

+ {profile.owner.firstname} {profile.owner.lastname} +

+
+
+ {/if} + + {#if hasSection('currently_reading')} +
+
+

{$_('publicProfile.sections.currentlyReading')}

+ {#if currentlyReading.length === 0} +

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

+ {:else} +
    + {#each currentlyReading as book} +
  • +
    + {#if book.cover_url} + {$_('book.coverOf', + {/if} +
    +
    +

    {book.title}

    +

    {formatAuthors(book.authors)}

    + {#if book.date_started} +

    {$_('publicProfile.sections.currentlyReadingStarted', { values: { date: formatDate(book.date_started, tz) } })}

    + {/if} +
    +
  • + {/each} +
+ {/if} +
+
+ {/if} + + {#if hasSection('last_read')} +
+
+

{$_('publicProfile.sections.lastRead')}

+ {#if lastRead.length === 0} +

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

+ {:else} +
    + {#each lastRead as book} +
  • +
    + {#if book.cover_url} + {$_('book.coverOf', + {/if} +
    +
    +

    {book.title}

    +

    {formatAuthors(book.authors)}

    + {#if book.date_finished} +

    {$_('publicProfile.page.finishedOn', { values: { date: formatDate(book.date_finished, tz) } })}

    + {/if} +
    +
  • + {/each} +
+ {#if lastReadAll.length > lastRead.length} + + {/if} + {/if} +
+
+ {/if} + + {#if hasSection('reading_timeline')} +
+
+

{$_('publicProfile.sections.readingTimeline')}

+ {#if timelineBooks.length === 0} +

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

+ {:else} +
    + {#each timelineMonths as [monthKey, books]} +
  1. +
    + {formatMonthLabel(monthKey)} +
    +
    + {#each books as book} +

    {book.title} {formatAuthors(book.authors)}

    + {/each} +
    +
    +
  2. + {/each} +
+ {/if} +
+
+ {/if} + + {#if hasSection('full_library')} +
+
+

{$_('publicProfile.sections.fullLibrary')}

+ {#if (profile.books ?? []).length === 0} +

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

+ {:else} +
+ {#each (profile.books ?? []).slice(0, libraryLimit) as book} +
+
+ {#if book.cover_url} + {$_('book.coverOf', + {/if} +
+

{book.title}

+

{formatAuthors(book.authors)}

+ {$_(STATUS_LABEL_KEYS[book.reading_status])} +
+ {/each} +
+ {#if (profile.books?.length ?? 0) > libraryLimit} + + {/if} + {/if} +
+
+ {/if} + + {#if hasSection('statistics') && profile.statistics} +
+ {#if summaryStats.length > 0} +
+
+

{$_('publicProfile.statGroups.summary')}

+
+ {#each summaryStats as key, i} +
+
{$_(PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey)}
+ {#if key === 'busiest_month'} +
{formatMonthLabel(statValue(key) as string)}
+
{$_('statistics.booksCount', { values: { count: formatNumber(statValue(key + '_count') as number) } })}
+ {:else if key === 'most_popular_language'} +
{(statValue(key) as string) ? formatLanguageCode(statValue(key) as string, appLocale) : '-'}
+
{formatNumber(statValue(key + '_count') as number)}
+ {:else if i === 0} +
{formatNumber(statValue(key) as number)}
+ {:else} +
{formatNumber(statValue(key) as number)}
+ {/if} +
+ {/each} +
+
+
+ {/if} + + {#if distributionStats.length > 0} +
+
+

{$_('publicProfile.statGroups.distribution')}

+ {#each distributionStats as key} + {@const rows = distributionRows(key)} + {@const distTotal = rows.reduce((sum, row) => sum + row.value, 0)} + {#if rows.length > 0} +
+

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

+ {#each rows as row} +
+ {row.label} +
+
+
+ {formatNumber(row.value)} +
+ {/each} +
+ {/if} + {/each} +
+
+ {/if} + + {#if trendStats.length > 0} +
+
+

{$_('publicProfile.statGroups.trends')}

+ {#each trendStats as key} + {@const points = trendPoints(key)} + {@const max = points.reduce((m, p) => Math.max(m, p.value), 0)} + {#if points.length > 0} +
+

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

+
+ {#each points as point} +
+
+
+ {/each} +
+
+ {#each points as point} +
{point.label}
+ {/each} +
+
+ {/if} + {/each} +
+
+ {/if} + + {#if ratingStats.length > 0} +
+
+

{$_('publicProfile.statGroups.ratings')}

+ {#each ratingStats as key} + {@const i18nKey = PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey} + {#if key === 'books_with_rating' || key === 'books_without_rating' || key === 'average_rating'} +
+ {$_(i18nKey)} + {key === 'average_rating' ? formatNumber(statValue(key) as number) : formatNumber(statValue(key) as number, 0)} +
+ {:else if key === 'top_authors'} + {@const authors = statValue(key) as { author: string; book_count: number }[]} + {#if authors.length > 0} +
+

{$_(i18nKey)}

+ {#each authors as author, idx} +

{author.author} ({$_('statistics.booksCount', { values: { count: author.book_count } })})

+ {/each} +
+ {/if} + {:else if key === 'top_rated_books' || key === 'worst_rated_books'} + {@const books = statValue(key) as { title: string; author: string | null; rating: number; cover_url: string | null }[]} + {#if books.length > 0} +
+

{$_(i18nKey)}

+ {#each books.slice(0, 5) as book} +

+ {book.rating ?? '-'} + {book.title} + {#if book.author}{book.author}{/if} +

+ {/each} +
+ {/if} + {/if} + {/each} +
+
+ {/if} +
+ {/if} + {/if} +
+ + +
\ No newline at end of file diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index ebcce548..59c71f95 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -4,7 +4,7 @@ import { api } from '$lib/api'; import PasswordRequirements from '$lib/components/PasswordRequirements.svelte'; import { currentUser } from '$lib/stores/auth'; - import { Calendar, Info } from '@lucide/svelte'; + import { Calendar, Info, Pencil, Trash2 } from '@lucide/svelte'; import { _, SUPPORTED_LOCALES, setLocale } from '$lib/i18n'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '$lib/password'; import { getTimezone, setTimezone, detectTimezone } from '$lib/stores/timezone'; @@ -12,10 +12,20 @@ import Alert from '$lib/components/Alert.svelte'; import SearchableSelect from '$lib/components/SearchableSelect.svelte'; import AdaptiveDateInput from '$lib/components/AdaptiveDateInput.svelte'; +import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte'; import { toasts } from '$lib/toasts'; import { localizeError } from '$lib/errors'; import { toDateInputValue, today } from '$lib/date'; - import type { ApiKeyMeta, AppConfig, EmbedTokenMeta, OidcConfig, OidcLinkStatus } from '$lib/types'; + import type { + ApiKeyMeta, + AppConfig, + EmbedTokenMeta, + OidcConfig, + OidcLinkStatus, + PublicProfileAudience, + PublicProfileLink, + PublicProfileVisibilityConfig +} from '$lib/types'; let firstname = $state(''); let lastname = $state(''); @@ -151,6 +161,7 @@ saveRestorePoint(); keys = await api.profile.listApiKeys(); embedTokens = await api.profile.listEmbedTokens(); + await loadShareLinks(); appConfig = await api.app.config(); oidcConfig = await api.oidc.config(); if (oidcConfig.enabled) { @@ -430,7 +441,82 @@ embedTokens = await api.profile.listEmbedTokens(); } - async function startOidcLink() { + let shareLinks = $state([]); + let shareLinkDialogOpen = $state(false); + let editingShareLink = $state(null); + let createdShareToken = $state(null); + let shareTokenCopied = $state(false); + let pendingDeleteShareLinkId = $state(null); + let shareLinkMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + + async function loadShareLinks() { + shareLinks = await api.profile.listShareLinks(); + } + + function openCreateShareLink() { + editingShareLink = null; + shareLinkDialogOpen = true; + } + + function openEditShareLink(link: PublicProfileLink) { + editingShareLink = link; + shareLinkDialogOpen = true; + } + + function publicShareUrl(token: string): string { + return `${window.location.origin}${base}/p/${token}`; + } + + async function saveShareLink(payload: { + name: string; + audience: PublicProfileAudience; + visibility_config: PublicProfileVisibilityConfig; + expires_at: string | null; + }) { + shareLinkMessage = null; + try { + if (editingShareLink) { + await api.profile.updateShareLink(editingShareLink.id, payload); + } else { + const result = await api.profile.createShareLink(payload); + createdShareToken = result.token; + shareTokenCopied = false; + } + shareLinkDialogOpen = false; + editingShareLink = null; + await loadShareLinks(); + } catch (e: unknown) { + shareLinkMessage = { type: 'error', text: e instanceof Error ? e.message : $_('publicProfile.saveFailed') }; + } + } + + async function copyShareToken() { + if (!createdShareToken) return; + await navigator.clipboard.writeText(publicShareUrl(createdShareToken)); + shareTokenCopied = true; + } + + function requestDeleteShareLink(id: number) { + pendingDeleteShareLinkId = id; + } + + function cancelDeleteShareLink() { + pendingDeleteShareLinkId = null; + } + + async function confirmDeleteShareLink() { + if (pendingDeleteShareLinkId === null) return; + const id = pendingDeleteShareLinkId; + pendingDeleteShareLinkId = null; + try { + await api.profile.deleteShareLink(id); + await loadShareLinks(); + } catch (e: unknown) { + shareLinkMessage = { type: 'error', text: e instanceof Error ? e.message : $_('publicProfile.saveFailed') }; + } + } + + async function startOidcLink() { oidcMessage = null; try { const response = await api.oidc.startLink(); @@ -900,6 +986,101 @@ {/if} +
+
+

{$_('profile.shareProfile.title')}

+

{$_('profile.shareProfile.subtitle')}

+ {#if shareLinkMessage} + (shareLinkMessage = null)}> + {shareLinkMessage.text} + + {/if} +
+ +
+ {#if createdShareToken} + (createdShareToken = null)} duration={0}> +
+ {$_('publicProfile.tokenShownOnce')} +
+ {publicShareUrl(createdShareToken)} +
+
+ + + {$_('publicProfile.openLink')} + +
+
+
+ {/if} + {#if shareLinks.length === 0} +

{$_('profile.shareProfile.empty')}

+ {:else} +
    + {#each shareLinks as link} +
  • +
    +

    + {link.name} + + {link.audience === 'public' ? $_('publicProfile.audiencePublic') : $_('publicProfile.audienceAuthenticated')} + + {#if link.expires_at && new Date(link.expires_at) < new Date()} + {$_('publicProfile.expired')} + {:else} + {$_('publicProfile.active')} + {/if} +

    +

    {link.token_prefix}...

    +

    + {#if link.expires_at} + {$_('publicProfile.expiresAt')}: {new Date(link.expires_at).toLocaleDateString()} + {:else} + {$_('publicProfile.unlimited')} + {/if} +

    +
    +
    + + +
    +
  • + {/each} +
+ {/if} +
+
+ + (shareLinkDialogOpen = false)} + /> + + {#if pendingDeleteShareLinkId !== null} + { if (e.target === e.currentTarget) cancelDeleteShareLink(); }}> + + + {/if} +

{$_('profile.dataManagement.title')}

@@ -1034,6 +1215,7 @@
  • {$_('profile.goals.title')}
  • {$_('user.apiKeys')}
  • {$_('user.embedTokens')}
  • +
  • {$_('profile.shareProfile.title')}
  • {$_('profile.dataManagement.title')}
  • {#if oidcConfig.enabled}
  • {$_('oidc.profileTitle')}
  • diff --git a/frontend/src/routes/statistics/page.test.ts b/frontend/src/routes/statistics/page.test.ts index 0503d774..0153e192 100644 --- a/frontend/src/routes/statistics/page.test.ts +++ b/frontend/src/routes/statistics/page.test.ts @@ -27,6 +27,7 @@ function createMockStats(overrides?: Partial): StatisticsRes language_distribution: [{ language: 'EN', count: 3 }], status_distribution: { want_to_read: 1, currently_reading: 0, read: 2, did_not_finish: 0 }, acquisition_status_distribution: { owned: 2, borrowed: 1, digital_access: 0, to_acquire: 1 }, + medium_distribution: [], page_buckets: { pages_to_read: 100, pages_read: 200, pages_wasted: 0 }, pages_read_per_month: [], books_finished_per_month: [], From 0dc71dc38a07008458e3acbe3dcdb11bb08a9b5b Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 12:26:00 +0200 Subject: [PATCH 02/10] Add copy and open actions for share links --- ...a7_add_raw_token_to_public_profile_link.py | 29 ++++++++++ backend/app/models.py | 1 + backend/app/routers/share_links.py | 16 ++++++ backend/app/schemas.py | 6 ++ backend/librislog.db | 0 backend/tests/test_public_profile.py | 57 ++++++++++++++++++- frontend/src/lib/api.ts | 7 +++ frontend/src/lib/types.ts | 4 ++ frontend/src/routes/profile/+page.svelte | 45 ++++++++++++++- 9 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py create mode 100644 backend/librislog.db 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/app/models.py b/backend/app/models.py index b346f65b..419db6b0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -329,6 +329,7 @@ class PublicProfileLink(SQLModel, table=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) visibility_config_json: str = Field( diff --git a/backend/app/routers/share_links.py b/backend/app/routers/share_links.py index e4f26fee..27d62c83 100644 --- a/backend/app/routers/share_links.py +++ b/backend/app/routers/share_links.py @@ -19,6 +19,7 @@ PublicProfileLinkRead, PublicProfileLinkUpdate, PublicProfileVisibilityConfig, + ShareLinkRevealResponse, ) from app.services.public_profile import ( parse_visibility_config, @@ -93,6 +94,7 @@ def create_share_link( 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, visibility_config_json=serialize_visibility_config(body.visibility_config), @@ -107,6 +109,20 @@ def create_share_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, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 133b2a25..08631ee4 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -848,6 +848,12 @@ class PublicProfileLinkCreateResponse(SQLModel): 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. diff --git a/backend/librislog.db b/backend/librislog.db new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/test_public_profile.py b/backend/tests/test_public_profile.py index ef7ea42a..52c85c79 100644 --- a/backend/tests/test_public_profile.py +++ b/backend/tests/test_public_profile.py @@ -404,4 +404,59 @@ def test_public_profile_rejects_past_expiry_on_update(client: Any) -> None: json={"expires_at": None}, ) assert resp.status_code == 200 - assert resp.json()["expires_at"] is None \ No newline at end of file + 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/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 13f621c7..5f383f35 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -46,6 +46,7 @@ import type { PublicProfileLinkCreateResponse, PublicProfileResponse, PublicProfileVisibilityConfig, + ShareLinkRevealResponse, User, UserCreateResponse, UserAdminUpdate, @@ -283,6 +284,12 @@ export const api = { 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', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ce13f92f..bb9f9fe5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -514,6 +514,10 @@ export interface PublicProfileLinkCreateResponse { link: PublicProfileLink; } +export interface ShareLinkRevealResponse { + token: string; +} + export interface PublicProfileUserInfo { firstname: string; lastname: string; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 59c71f95..5a6b045a 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -4,7 +4,7 @@ import { api } from '$lib/api'; import PasswordRequirements from '$lib/components/PasswordRequirements.svelte'; import { currentUser } from '$lib/stores/auth'; - import { Calendar, Info, Pencil, Trash2 } from '@lucide/svelte'; + import { Calendar, Check, Copy, ExternalLink, Info, Pencil, Trash2 } from '@lucide/svelte'; import { _, SUPPORTED_LOCALES, setLocale } from '$lib/i18n'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '$lib/password'; import { getTimezone, setTimezone, detectTimezone } from '$lib/stores/timezone'; @@ -448,6 +448,8 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte'; let shareTokenCopied = $state(false); let pendingDeleteShareLinkId = $state(null); let shareLinkMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let revealedTokens = $state>({}); + let copiedShareLinkId = $state(null); async function loadShareLinks() { shareLinks = await api.profile.listShareLinks(); @@ -516,6 +518,31 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte'; } } + async function ensureRevealedToken(link: PublicProfileLink): Promise { + if (revealedTokens[link.id]) return revealedTokens[link.id]; + try { + const result = await api.profile.revealShareLink(link.id); + revealedTokens = { ...revealedTokens, [link.id]: result.token }; + return result.token; + } catch { + return null; + } + } + + async function copyShareLinkUrl(link: PublicProfileLink) { + const token = await ensureRevealedToken(link); + if (!token) return; + await navigator.clipboard.writeText(publicShareUrl(token)); + copiedShareLinkId = link.id; + setTimeout(() => { copiedShareLinkId = null; }, 1500); + } + + async function openShareLinkUrl(link: PublicProfileLink) { + const token = await ensureRevealedToken(link); + if (!token) return; + window.open(publicShareUrl(token), '_blank', 'noopener'); + } + async function startOidcLink() { oidcMessage = null; try { @@ -1034,7 +1061,11 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte'; {$_('publicProfile.active')} {/if}

    -

    {link.token_prefix}...

    + {#if revealedTokens[link.id]} +

    {publicShareUrl(revealedTokens[link.id])}

    + {:else} +

    {link.token_prefix}...

    + {/if}

    {#if link.expires_at} {$_('publicProfile.expiresAt')}: {new Date(link.expires_at).toLocaleDateString()} @@ -1044,6 +1075,16 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte';

    + + From a1594621cc4be86334d4e4aceeaaea17b1ad6633 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 12:27:59 +0200 Subject: [PATCH 03/10] Add backend database to .gitignore --- .gitignore | 3 ++- backend/librislog.db | 0 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 backend/librislog.db 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/backend/librislog.db b/backend/librislog.db deleted file mode 100644 index e69de29b..00000000 From 1ec7f12fe71f25680907c4fa21713049d814d8ac Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 12:43:14 +0200 Subject: [PATCH 04/10] Add validation and scrolling to share link dialog --- .../src/lib/components/ShareLinkDialog.svelte | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/ShareLinkDialog.svelte b/frontend/src/lib/components/ShareLinkDialog.svelte index 244eb894..8e0cdc28 100644 --- a/frontend/src/lib/components/ShareLinkDialog.svelte +++ b/frontend/src/lib/components/ShareLinkDialog.svelte @@ -46,6 +46,7 @@ let expiresHasInput = $state(false); let tz = $state('UTC'); let dialogEl = $state(null); + let nameTouched = $state(false); function resetFromLink(value: PublicProfileLink | null) { const defaults = defaultPublicProfileVisibilityConfig(); @@ -66,6 +67,7 @@ } expiresInvalid = false; expiresHasInput = false; + nameTouched = false; } $effect(() => { @@ -125,7 +127,10 @@ function setAllSections(checked: boolean) { sections = checked ? PUBLIC_PROFILE_SECTIONS.map((s) => s.key) - : sections.filter((s) => s === 'statistics'); + : []; + if (!checked) { + statistics = []; + } } function statsForGroup(group: PublicProfileStatisticsGroup) { @@ -168,7 +173,7 @@ aria-modal="true" aria-label={link ? $_('publicProfile.dialogTitleEdit') : $_('publicProfile.dialogTitleCreate')} > -