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/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py b/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py new file mode 100644 index 00000000..5e8c3584 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py @@ -0,0 +1,29 @@ +"""add raw token to public_profile_link + +Revision ID: b2c3d4e5f6a7 +Revises: c9a4b7d8e3f1 +Create Date: 2026-09-10 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, Sequence[str], None] = "c9a4b7d8e3f1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.add_column(sa.Column("token", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.drop_column("token") diff --git a/backend/alembic/versions/c9a4b7d8e3f1_add_public_profile_link_table.py b/backend/alembic/versions/c9a4b7d8e3f1_add_public_profile_link_table.py new file mode 100644 index 00000000..8b2c3a74 --- /dev/null +++ b/backend/alembic/versions/c9a4b7d8e3f1_add_public_profile_link_table.py @@ -0,0 +1,48 @@ +"""add public_profile_link table for shareable public profiles + +Revision ID: c9a4b7d8e3f1 +Revises: 7a8b9c0d1e2f +Create Date: 2026-09-09 23:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c9a4b7d8e3f1" +down_revision: Union[str, Sequence[str], None] = "7a8b9c0d1e2f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create the public_profile_link table.""" + op.create_table( + "public_profile_link", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("token_prefix", sa.String(length=255), nullable=False), + sa.Column("token_hash", sa.String(length=255), nullable=False), + sa.Column("audience", sa.String(length=32), nullable=False), + sa.Column("visibility_config_json", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_public_profile_link_user_id", "public_profile_link", ["user_id"], unique=False) + op.create_index("ix_public_profile_link_token_prefix", "public_profile_link", ["token_prefix"], unique=False) + op.create_index("ix_public_profile_link_token_hash", "public_profile_link", ["token_hash"], unique=True) + + +def downgrade() -> None: + """Drop the public_profile_link table.""" + op.drop_index("ix_public_profile_link_token_hash", table_name="public_profile_link") + op.drop_index("ix_public_profile_link_token_prefix", table_name="public_profile_link") + op.drop_index("ix_public_profile_link_user_id", table_name="public_profile_link") + op.drop_table("public_profile_link") \ No newline at end of file diff --git a/backend/alembic/versions/d3e4f5a6b7c8_add_language_to_public_profile_link.py b/backend/alembic/versions/d3e4f5a6b7c8_add_language_to_public_profile_link.py new file mode 100644 index 00000000..83c54eb0 --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_add_language_to_public_profile_link.py @@ -0,0 +1,29 @@ +"""add language to public_profile_link + +Revision ID: d3e4f5a6b7c8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-09-10 13:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, Sequence[str], None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.add_column(sa.Column("language", sa.String(length=10), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.drop_column("language") diff --git a/backend/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..81a7affc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -313,6 +313,44 @@ class EmbedToken(SQLModel, table=True): ) +class PublicProfileAudience(str, Enum): + """Who may access a public profile share link.""" + + public = "public" # everyone, including anonymous viewers + authenticated = "authenticated" # logged-in users only + + +class PublicProfileLink(SQLModel, table=True): + """A shareable public profile link owned by a user.""" + + __tablename__: str = "public_profile_link" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", index=True) + name: str = Field(max_length=255) + token_prefix: str = Field(index=True) + token: Optional[str] = Field(default=None, nullable=True) + token_hash: str = Field(index=True, unique=True) + audience: PublicProfileAudience = Field(default=PublicProfileAudience.public) + language: Optional[str] = Field(default=None, nullable=True) + visibility_config_json: str = Field( + default="{}", + sa_column=Column(sa.Text, default="{}"), + ) + expires_at: Optional[datetime] = Field( + default=None, + sa_column=Column(UtcDateTime, default=None), + ) + created_at: datetime = Field( + default_factory=utcnow, + sa_column=Column(UtcDateTime, default=utcnow), + ) + revoked_at: Optional[datetime] = Field( + default=None, + sa_column=Column(UtcDateTime, default=None), + ) + + class ImportMapping(SQLModel, table=True): """A saved column-mapping configuration for data import.""" diff --git a/backend/app/routers/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..86ca5c31 --- /dev/null +++ b/backend/app/routers/public_profile.py @@ -0,0 +1,143 @@ +"""Public (unauthenticated) profile data endpoint. + +Validates a share-link token, checks expiry and audience rules, and returns a +whitelisted view of the owner's profile. Private account data (email, API +keys, settings, notes, blurbs, OIDC info) is never serialized here. +""" + +import logging + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, Security +from fastapi.security import APIKeyHeader +from sqlmodel import Session, col, select + +from app.auth import hash_public_profile_token, require_user +from app.database import get_session +from app.models import PublicProfileAudience, PublicProfileLink, User +from app.schemas import ( + PublicProfileBook, + PublicProfileResponse, + PublicProfileSectionKey, + PublicProfileUserInfo, + StatisticsRange, +) +from app.services.public_profile import ( + BOOK_SECTIONS, + build_public_books, + filter_statistics, + load_owner_books, + parse_visibility_config, +) +from app.services.statistics import compute_statistics +from app.time_utils import utcnow + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/public-profiles", tags=["public-profile"]) + +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +def get_optional_user( + request: Request, + x_api_key: str | None = Security(api_key_header), + x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"), + session: Session = Depends(get_session), +) -> User | None: + """Resolve the viewer if a session or API key is present, else None. + + This is intentionally non-fatal: public links marked ``public`` must be + viewable by anonymous visitors. Invalid credentials degrade to anonymous. + """ + if not x_api_key and request.session.get("user_id") is None: + return None + try: + return require_user( + request=request, + x_api_key=x_api_key, + x_csrf_token=x_csrf_token, + session=session, + ) + except HTTPException: + return None + + +@router.get("/{token}", response_model=PublicProfileResponse) +def get_public_profile( + token: str, + response: Response, + viewer: User | None = Depends(get_optional_user), + session: Session = Depends(get_session), +) -> PublicProfileResponse: + """Return the whitelisted public profile for a share-link token. + + Invalid, expired, or revoked tokens all yield HTTP 404 so that link + existence cannot be probed. Tokens restricted to logged-in users yield + HTTP 401 for anonymous viewers. + """ + _with_security_headers(response) + link = session.exec( + select(PublicProfileLink).where( + PublicProfileLink.token_hash == hash_public_profile_token(token), + col(PublicProfileLink.revoked_at).is_(None), + ) + ).first() + + if not link: + raise HTTPException(status_code=404, detail="Public profile not found") + + now = utcnow() + if link.expires_at is not None and link.expires_at < now: + logger.debug("Public profile link expired: id=%s", link.id) + raise HTTPException(status_code=404, detail="Public profile not found") + + if link.audience == PublicProfileAudience.authenticated and viewer is None: + raise HTTPException(status_code=401, detail="Login required to view this profile") + + owner = session.get(User, link.user_id) + if owner is None: + raise HTTPException(status_code=404, detail="Public profile not found") + assert owner.id is not None + + config = parse_visibility_config(link.visibility_config_json) + visible_sections = set(config.sections) + + books: list[PublicProfileBook] = [] + if visible_sections & set(BOOK_SECTIONS): + books = build_public_books(session, load_owner_books(session, owner.id)) + + statistics = None + if PublicProfileSectionKey.statistics in visible_sections: + full_stats = compute_statistics( + session, owner.id, range_value=StatisticsRange.alltime + ) + statistics = filter_statistics(full_stats, config.statistics) + + # The owner's name is only emitted when a section that renders it + # (username or user_info) is visible, so it cannot leak through the + # page title or share metadata otherwise. + show_name = bool( + visible_sections + & {PublicProfileSectionKey.username, PublicProfileSectionKey.user_info} + ) + + return PublicProfileResponse( + owner=PublicProfileUserInfo( + firstname=owner.firstname if show_name else None, + lastname=owner.lastname if show_name else None, + ), + audience=link.audience, + language=link.language, + expires_at=link.expires_at, + visibility_config=config, + books=books, + statistics=statistics, + ) + + +def _with_security_headers(response: Response) -> Response: + """Apply baseline security headers to the unauthenticated profile response.""" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + return response \ No newline at end of file diff --git a/backend/app/routers/share_links.py b/backend/app/routers/share_links.py new file mode 100644 index 00000000..12cb3924 --- /dev/null +++ b/backend/app/routers/share_links.py @@ -0,0 +1,165 @@ +"""Share-link management endpoints — CRUD for a user's public profile links.""" + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, col, select + +from app.auth import ( + generate_public_profile_token, + get_public_profile_token_prefix, + hash_public_profile_token, + require_user, +) +from app.database import get_session +from app.models import PublicProfileAudience, PublicProfileLink, User +from app.schemas import ( + PublicProfileLinkCreate, + PublicProfileLinkCreateResponse, + PublicProfileLinkRead, + PublicProfileLinkUpdate, + PublicProfileVisibilityConfig, + ShareLinkRevealResponse, +) +from app.services.public_profile import ( + parse_visibility_config, + serialize_visibility_config, +) +from app.time_utils import utcnow + +router = APIRouter(prefix="/api/profile/share-links", tags=["share-links"]) + + +def _to_read_model(link: PublicProfileLink) -> PublicProfileLinkRead: + """Convert a link model to its read schema, parsing the stored config.""" + assert link.id is not None + return PublicProfileLinkRead( + id=link.id, + name=link.name, + token_prefix=link.token_prefix, + audience=link.audience, + language=link.language, + visibility_config=parse_visibility_config(link.visibility_config_json), + expires_at=link.expires_at, + created_at=link.created_at, + ) + + +def _get_owned_link(link_id: int, user_id: int, session: Session) -> PublicProfileLink: + """Fetch a non-revoked link owned by *user_id*, raising 404 otherwise.""" + link = session.get(PublicProfileLink, link_id) + if not link or link.user_id != user_id or link.revoked_at is not None: + raise HTTPException(status_code=404, detail="Share link not found") + return link + + +def _ensure_future_expiry(expires_at: datetime | None) -> None: + """Reject expiry dates in the past so links cannot be created already dead.""" + if expires_at is not None and expires_at <= utcnow(): + raise HTTPException( + status_code=422, + detail="Expiry date must be in the future", + ) + + +@router.get("", response_model=list[PublicProfileLinkRead]) +def list_share_links( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> list[PublicProfileLinkRead]: + """List non-revoked share links for the current user.""" + assert current_user.id is not None + links = session.exec( + select(PublicProfileLink) + .where( + PublicProfileLink.user_id == current_user.id, + col(PublicProfileLink.revoked_at).is_(None), + ) + .order_by(col(PublicProfileLink.created_at).desc()) + ).all() + return [_to_read_model(link) for link in links] + + +@router.post("", response_model=PublicProfileLinkCreateResponse, status_code=201) +def create_share_link( + body: PublicProfileLinkCreate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> PublicProfileLinkCreateResponse: + """Create a new share link. The raw token is returned exactly once.""" + assert current_user.id is not None + _ensure_future_expiry(body.expires_at) + plain_token = generate_public_profile_token() + audience = PublicProfileAudience(body.audience or PublicProfileAudience.public) + link = PublicProfileLink( + user_id=current_user.id, + name=body.name, + token_prefix=get_public_profile_token_prefix(plain_token), + token=plain_token, + token_hash=hash_public_profile_token(plain_token), + audience=audience, + language=body.language, + visibility_config_json=serialize_visibility_config(body.visibility_config), + expires_at=body.expires_at, + ) + session.add(link) + session.commit() + session.refresh(link) + return PublicProfileLinkCreateResponse( + token=plain_token, + link=_to_read_model(link), + ) + + +@router.post("/{link_id}/reveal", response_model=ShareLinkRevealResponse) +def reveal_share_link( + link_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> ShareLinkRevealResponse: + """Return the raw token for a share link owned by the current user.""" + assert current_user.id is not None + link = _get_owned_link(link_id, current_user.id, session) + if not link.token: + raise HTTPException(status_code=404, detail="Token not available for legacy link") + return ShareLinkRevealResponse(token=link.token) + + +@router.patch("/{link_id}", response_model=PublicProfileLinkRead) +def update_share_link( + link_id: int, + body: PublicProfileLinkUpdate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> PublicProfileLinkRead: + """Update name, audience, visibility config, or expiry of a share link.""" + assert current_user.id is not None + link = _get_owned_link(link_id, current_user.id, session) + + update_data = body.model_dump(exclude_unset=True) + if "expires_at" in update_data and update_data["expires_at"] is not None: + _ensure_future_expiry(update_data["expires_at"]) + if "visibility_config" in update_data: + update_data["visibility_config_json"] = serialize_visibility_config( + body.visibility_config or PublicProfileVisibilityConfig() + ) + update_data.pop("visibility_config") + link.sqlmodel_update(update_data) + session.add(link) + session.commit() + session.refresh(link) + return _to_read_model(link) + + +@router.delete("/{link_id}", status_code=204) +def delete_share_link( + link_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> None: + """Revoke a share link. Subsequent public access returns 404.""" + assert current_user.id is not None + link = _get_owned_link(link_id, current_user.id, session) + link.revoked_at = utcnow() + session.add(link) + session.commit() \ No newline at end of file diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 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..e3f600c6 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,145 @@ class EmbedTokenCreateResponse(SQLModel): embed_token: EmbedTokenRead +class PublicProfileSectionKey(str, Enum): + """Stable keys for the selectable sections of a public profile. + + Adding a new section is a small, contained change: add a member here, a + registry entry on the frontend, an i18n label/tooltip, and a render + component on the public page. Saved configs tolerate unknown keys. + """ + + username = "username" + user_info = "user_info" + currently_reading = "currently_reading" + last_read = "last_read" + reading_timeline = "reading_timeline" + full_library = "full_library" + statistics = "statistics" + + +class PublicProfileStatisticsKey(str, Enum): + """Selectable statistics exposed on a public profile.""" + + total_books = "total_books" + total_authors = "total_authors" + avg_books_per_month = "avg_books_per_month" + busiest_month = "busiest_month" + avg_page_count = "avg_page_count" + most_popular_language = "most_popular_language" + language_distribution = "language_distribution" + status_distribution = "status_distribution" + acquisition_status_distribution = "acquisition_status_distribution" + medium_distribution = "medium_distribution" + page_buckets = "page_buckets" + pages_read_per_month = "pages_read_per_month" + books_finished_per_month = "books_finished_per_month" + books_finished_per_year = "books_finished_per_year" + top_authors = "top_authors" + books_with_rating = "books_with_rating" + books_without_rating = "books_without_rating" + average_rating = "average_rating" + top_rated_books = "top_rated_books" + worst_rated_books = "worst_rated_books" + + +class PublicProfileVisibilityConfig(SQLModel): + """Whitelisted sections and, for statistics, the selected sub-keys.""" + + sections: list[PublicProfileSectionKey] = Field(default_factory=list) + statistics: list[PublicProfileStatisticsKey] = Field(default_factory=list) + + +class PublicProfileLinkCreate(SQLModel): + """Request body to create a new public profile share link.""" + + name: str = Field(min_length=1, max_length=255) + audience: Optional[PublicProfileAudience] = None + language: Optional[str] = Field(default=None, max_length=10) + visibility_config: PublicProfileVisibilityConfig = Field(default_factory=PublicProfileVisibilityConfig) + expires_at: Optional[datetime] = None + + +class PublicProfileLinkUpdate(SQLModel): + """Request body to partially update a public profile share link.""" + + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + audience: Optional[PublicProfileAudience] = None + language: Optional[str] = Field(default=None, max_length=10) + visibility_config: Optional[PublicProfileVisibilityConfig] = None + expires_at: Optional[datetime] = None + + +class PublicProfileLinkRead(SQLModel): + """Share-link read response (without the raw token value).""" + + id: int + name: str + token_prefix: str + audience: PublicProfileAudience + language: Optional[str] = None + visibility_config: PublicProfileVisibilityConfig + expires_at: Optional[datetime] = None + created_at: datetime + + +class PublicProfileLinkCreateResponse(SQLModel): + """Share-link creation response containing the raw token (shown once).""" + + token: str + link: PublicProfileLinkRead + + +class ShareLinkRevealResponse(SQLModel): + """Response for the reveal endpoint, returning the raw token.""" + + token: str + + +class PublicProfileUserInfo(SQLModel): + """Public-safe owner identity shown on a public profile. + + Both names are ``None`` when the owner has enabled no section that + displays them (neither ``username`` nor ``user_info``), so the owner's + identity cannot leak through the page title or share metadata. + """ + + firstname: str | None = None + lastname: str | None = None + + +class PublicProfileBook(SQLModel): + """Public-safe book data whitelisted for public profiles.""" + + id: int + title: str + subtitle: Optional[str] = None + authors: list[str] = Field(default_factory=list) + cover_url: Optional[str] = None + reading_status: ReadingStatus + page_count: int + language: Optional[str] = None + rating: Optional[int] = None + date_started: Optional[datetime] = None + date_finished: Optional[datetime] = None + + +class PublicProfileResponse(SQLModel): + """Data returned by the public profile endpoint. + + ``statistics`` is a dict keyed by the selected ``PublicProfileStatisticsKey`` + values so only the configured statistics are ever serialized. + """ + + owner: PublicProfileUserInfo + audience: PublicProfileAudience + language: Optional[str] = None + expires_at: Optional[datetime] = None + visibility_config: PublicProfileVisibilityConfig + books: list[PublicProfileBook] = Field(default_factory=list) + statistics: Optional[dict[str, Any]] = None + + class DataImportExecuteResult(SQLModel): """Import execution result summary.""" imported: int diff --git a/backend/app/services/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..da836fab --- /dev/null +++ b/backend/tests/test_public_profile.py @@ -0,0 +1,480 @@ +"""Tests for public profile share links — management CRUD and the public endpoint.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlmodel import Session, select + +from app.auth import hash_public_profile_token +from app.models import AcquisitionStatus, Book, PublicProfileLink, ReadingStatus +from app.time_utils import utcnow + + +def _create_share_link(client: Any, **overrides: Any) -> dict[str, Any]: + """Create a share link via the API and return the JSON response.""" + payload = { + "name": "My Profile", + "visibility_config": { + "sections": ["username", "currently_reading", "statistics"], + "statistics": ["total_books", "status_distribution"], + }, + **overrides, + } + resp = client.post("/api/profile/share-links", json=payload) + assert resp.status_code == 201 + return resp.json() + + +def _public_profile(client: Any, token: str) -> Any: + """Call the public profile endpoint with the raw token.""" + return client.get(f"/api/public-profiles/{token}") + + +def _create_book(client: Any, title: str = "Book", **overrides: Any) -> dict[str, Any]: + """Create a book via the API and return the JSON response.""" + payload = {"title": title, "authors": ["Test Author"], "page_count": 100, **overrides} + resp = client.post("/api/books", json=payload) + assert resp.status_code == 201 + return resp.json() + + +def test_share_link_language_roundtrip(client: Any) -> None: + """Language is set on create, appears in list, and survives an update.""" + data = _create_share_link(client, language="de") + assert data["link"]["language"] == "de" + + listed = client.get("/api/profile/share-links") + assert listed.json()[0]["language"] == "de" + + link_id = data["link"]["id"] + resp = client.patch(f"/api/profile/share-links/{link_id}", json={"language": "fr"}) + assert resp.status_code == 200 + assert resp.json()["language"] == "fr" + + public = _public_profile(client, data["token"]) + assert public.status_code == 200 + assert public.json()["language"] == "fr" + + +def test_create_share_link_returns_token_once(client: Any) -> None: + data = _create_share_link(client) + assert data["token"].startswith("lp_") + assert data["link"]["name"] == "My Profile" + assert data["link"]["token_prefix"] == data["token"][:12] + assert data["link"]["visibility_config"]["sections"] == [ + "username", + "currently_reading", + "statistics", + ] + + # Listing must not expose the full token. + listed = client.get("/api/profile/share-links") + assert listed.status_code == 200 + items = listed.json() + assert len(items) == 1 + assert "token" not in items[0] + assert items[0]["token_prefix"] == data["token"][:12] + + +def test_list_share_links_isolated(client: Any, create_user_with_key: Any) -> None: + _create_share_link(client) + created = client.get("/api/profile/share-links").json() + assert len(created) == 1 + link_id = created[0]["id"] + + user_b, key_b = create_user_with_key(email="other@example.com") + + # User B's key cannot see A's links. + assert client.get( + "/api/profile/share-links", headers={"X-API-Key": key_b} + ).json() == [] + + # User B's key cannot modify or delete A's links. + assert ( + client.delete( + f"/api/profile/share-links/{link_id}", headers={"X-API-Key": key_b} + ).status_code + == 404 + ) + assert ( + client.patch( + f"/api/profile/share-links/{link_id}", + headers={"X-API-Key": key_b}, + json={"name": "Hijacked"}, + ).status_code + == 404 + ) + assert client.get("/api/profile/share-links").json()[0]["id"] == link_id + + +def _public_profile_with_client(client: Any, token: str, x_api_key: str | None = None) -> Any: + """Call public endpoint with an explicit API key header. + + The client fixture always attaches the owner's key; when *x_api_key* is + None that header is removed so the request is truly anonymous. + """ + if x_api_key is None: + client.headers.pop("X-API-Key", None) + return client.get(f"/api/public-profiles/{token}") + return client.get(f"/api/public-profiles/{token}", headers={"X-API-Key": x_api_key}) + + +def test_public_profile_success(client: Any) -> None: + _create_book(client, title="Currently Reading", reading_status="currently_reading") + _create_book(client, title="Finished", reading_status="read", date_finished=utcnow().isoformat()) + + data = _create_share_link(client) + token = data["token"] + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + body = resp.json() + assert body["owner"] == {"firstname": "Test", "lastname": "User"} + assert body["audience"] == "public" + assert body["visibility_config"]["sections"] == ["username", "currently_reading", "statistics"] + assert len(body["books"]) == 2 + assert {b["title"] for b in body["books"]} == {"Currently Reading", "Finished"} + assert "authors" in body["books"][0] + assert body["books"][0]["authors"] == ["Test Author"] + # Statistics are filtered to selected keys only. + assert set(body["statistics"].keys()) == {"total_books", "status_distribution"} + assert body["statistics"]["total_books"] == 2 + assert body["statistics"]["status_distribution"]["currently_reading"] == 1 + assert body["statistics"]["status_distribution"]["read"] == 1 + + +def test_public_profile_invalid_token_returns_404(client: Any) -> None: + resp = _public_profile_with_client(client, "lp_does-not-exist", x_api_key=None) + assert resp.status_code == 404 + + +def test_public_profile_expired_returns_404(client: Any, session: Session) -> None: + data = _create_share_link( + client, + expires_at=(utcnow() + timedelta(days=1)).isoformat(), + ) + token = data["token"] + link_id = data["link"]["id"] + + # Back-date the expiry in the DB (the API rejects past dates on write). + link = session.get(PublicProfileLink, link_id) + assert link is not None + link.expires_at = utcnow() - timedelta(days=1) + session.add(link) + session.commit() + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 404 + + +def test_public_profile_revoked_returns_404(client: Any) -> None: + data = _create_share_link(client) + token = data["token"] + link_id = data["link"]["id"] + + resp = client.delete(f"/api/profile/share-links/{link_id}") + assert resp.status_code == 204 + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 404 + + +def test_public_profile_authenticated_audience_blocks_anonymous(client: Any) -> None: + data = _create_share_link(client, audience="authenticated") + token = data["token"] + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 401 + + +def test_public_profile_audience_public_allows_anonymous_with_invalid_key(client: Any) -> None: + data = _create_share_link(client, audience="public") + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key="lk_invalid-key") + assert resp.status_code == 200 + + +def test_public_profile_authenticated_audience_allows_logged_in( + client: Any, + create_user_with_key: Any, +) -> None: + data = _create_share_link(client, audience="authenticated") + token = data["token"] + + user_b, key_b = create_user_with_key(email="viewer@example.com") + + assert key_b # viewer is a different logged-in user + resp = _public_profile_with_client(client, token, x_api_key=key_b) + assert resp.status_code == 200 + assert resp.json()["owner"]["firstname"] == "Test" + + +def test_public_profile_update_share_link(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + resp = client.patch( + f"/api/profile/share-links/{link_id}", + json={ + "name": "Renamed", + "audience": "authenticated", + "visibility_config": {"sections": ["full_library"], "statistics": []}, + "expires_at": (utcnow() + timedelta(days=30)).isoformat(), + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "Renamed" + assert body["audience"] == "authenticated" + assert body["visibility_config"]["sections"] == ["full_library"] + + +def test_public_profile_delete_revokes(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + resp = client.delete(f"/api/profile/share-links/{link_id}") + assert resp.status_code == 204 + + listed = client.get("/api/profile/share-links") + assert listed.json() == [] + + +def test_public_profile_cross_user_isolation(client: Any, session: Session, create_user_with_key: Any) -> None: + """A public profile only ever exposes the owner's books.""" + _create_book(client, title="Owner Book") + data = _create_share_link(client) + token = data["token"] + + user_b, _ = create_user_with_key(email="other@example.com") + # Insert a book owned by the other user directly into the DB. + session.add( + Book( + user_id=user_b.id, + title="Other User's Book", + page_count=50, + reading_status=ReadingStatus.want_to_read, + acquisition_status=AcquisitionStatus.owned, + ) + ) + session.commit() + + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + assert [b["title"] for b in resp.json()["books"]] == ["Owner Book"] + + +def test_public_profile_never_returns_sensitive_fields(client: Any) -> None: + _create_book(client, title="Read", reading_status="read") + data = _create_share_link( + client, + visibility_config={ + "sections": ["username", "user_info", "full_library", "currently_reading", "last_read", "reading_timeline", "statistics"], + "statistics": [], + }, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + raw = resp.json() + + body_text = str(raw) + assert "email" not in body_text + assert "@" not in body_text + assert "password" not in body_text + assert "api_key" not in body_text + assert "notes" not in body_text + assert "blurb" not in body_text + assert "settings" not in body_text + + book = raw["books"][0] + assert "notes" not in book + assert "blurb" not in book + + +def test_public_profile_respects_visibility_config_for_books(client: Any) -> None: + """When no book section is enabled, the books payload is empty.""" + _create_book(client, title="A Book") + data = _create_share_link( + client, + visibility_config={"sections": ["username"], "statistics": []}, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + body = resp.json() + assert body["books"] == [] + assert body["statistics"] is None + + +def test_public_profile_did_not_finish_books_still_whitelisted(client: Any) -> None: + _create_book(client, title="DNF", reading_status="did_not_finish") + data = _create_share_link( + client, + visibility_config={"sections": ["full_library"], "statistics": []}, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + assert [b["title"] for b in resp.json()["books"]] == ["DNF"] + assert resp.json()["books"][0]["reading_status"] == "did_not_finish" + + +def test_public_profile_statistics_only_selected_keys(client: Any) -> None: + _create_book(client, title="Read", reading_status="read", rating=5) + data = _create_share_link( + client, + visibility_config={ + "sections": ["statistics"], + "statistics": ["average_rating", "total_authors"], + }, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + stats = resp.json()["statistics"] + assert set(stats.keys()) == {"average_rating", "total_authors"} + assert stats["average_rating"] == 5.0 + + +def test_public_profile_statistics_include_companion_counts(client: Any) -> None: + """Selected summary stats bring along the *_count fields that describe them.""" + _create_book( + client, + title="Read", + reading_status="read", + language="de", + date_finished=utcnow().isoformat(), + ) + data = _create_share_link( + client, + visibility_config={ + "sections": ["statistics"], + "statistics": [ + "busiest_month", + "most_popular_language", + "total_books", + ], + }, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + stats = resp.json()["statistics"] + assert set(stats.keys()) == { + "total_books", + "busiest_month", + "busiest_month_count", + "most_popular_language", + "most_popular_language_count", + } + assert stats["busiest_month_count"] is not None + assert stats["most_popular_language_count"] is not None + + +def test_public_profile_redacts_owner_name_when_hidden(client: Any) -> None: + """Names are never emitted unless a section renders them.""" + data = _create_share_link( + client, + visibility_config={"sections": ["full_library"], "statistics": []}, + ) + token = data["token"] + resp = _public_profile_with_client(client, token, x_api_key=None) + assert resp.status_code == 200 + owner = resp.json()["owner"] + assert owner == {"firstname": None, "lastname": None} + + +def test_public_profile_sets_security_headers(client: Any) -> None: + data = _create_share_link(client) + token = data["token"] + resp = client.get(f"/api/public-profiles/{token}") + assert resp.status_code == 200 + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "DENY" + assert resp.headers["Referrer-Policy"] == "no-referrer" + + +def test_public_profile_rejects_past_expiry_on_create(client: Any) -> None: + resp = client.post( + "/api/profile/share-links", + json={ + "name": "Expired", + "expires_at": (utcnow() - timedelta(days=1)).isoformat(), + }, + ) + assert resp.status_code == 422 + assert "future" in resp.json()["detail"] + + +def test_public_profile_rejects_past_expiry_on_update(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + resp = client.patch( + f"/api/profile/share-links/{link_id}", + json={"expires_at": (utcnow() - timedelta(minutes=5)).isoformat()}, + ) + assert resp.status_code == 422 + assert "future" in resp.json()["detail"] + + # Clearing the expiry is still allowed. + resp = client.patch( + f"/api/profile/share-links/{link_id}", + json={"expires_at": None}, + ) + assert resp.status_code == 200 + assert resp.json()["expires_at"] is None + + +def test_reveal_share_link_returns_raw_token(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + resp = client.post(f"/api/profile/share-links/{link_id}/reveal") + assert resp.status_code == 200 + body = resp.json() + assert body["token"] == data["token"] + assert body["token"].startswith("lp_") + + +def test_reveal_share_link_404_for_other_user( + client: Any, create_user_with_key: Any +) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + user_b, key_b = create_user_with_key(email="other@example.com") + resp = client.post( + f"/api/profile/share-links/{link_id}/reveal", + headers={"X-API-Key": key_b}, + ) + assert resp.status_code == 404 + + +def test_reveal_share_link_404_for_revoked(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + # Revoke + client.delete(f"/api/profile/share-links/{link_id}") + + resp = client.post(f"/api/profile/share-links/{link_id}/reveal") + assert resp.status_code == 404 + + +def test_reveal_share_link_404_for_legacy_without_token(client: Any, session: Session) -> None: + """Legacy links with token=None cannot be revealed.""" + from app.auth import get_public_profile_token_prefix, hash_public_profile_token + from app.models import PublicProfileLink + + token_hash = hash_public_profile_token("lp_fake_legacy_token") + link = PublicProfileLink( + user_id=1, + name="Legacy", + token_prefix=get_public_profile_token_prefix("lp_fake_legacy_token"), + token_hash=token_hash, + ) + session.add(link) + session.commit() + session.refresh(link) + + resp = client.post(f"/api/profile/share-links/{link.id}/reveal") + assert resp.status_code == 404 \ No newline at end of file diff --git a/backend/tests/test_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/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 44411787..0006c039 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -43,6 +43,36 @@ The **"Show reading streaks & goals on dashboard"** switch above the goals disab See [Dashboard → Reading Streaks & Goals](/guide/using-librislog/dashboard#reading-streaks-goals) for details on how streaks and goal progress are calculated. +## URL/Profile Sharing + +Create a read-only public view of your reading profile and share it with a URL. The shared page does not expose editing controls, notes, blurbs, or your email address. + +### Create a Profile URL + +1. Open **Profile** and scroll to **Share Profile**. +2. Click **Create New URL**. +3. Enter a name for the link, such as `Friends & family`. +4. Choose who can access it: + - **Everyone** — anyone with the URL can view the profile, without logging in. + - **Logged-in users only** — viewers must be signed in to LibrisLog. +5. Under **Content**, select the profile sections to share. Statistics can be enabled separately and configured by group. +6. Choose the language for this URL. The public page uses the link's language independently of your viewer's current UI language. +7. Under **Validity**, leave the link unlimited or set an expiration date. +8. Save the link. + +The complete URL is shown once after creation. Copy it immediately or open it in a new tab. For security, the full token is not shown in the link list unless you explicitly reveal it through the link actions. + +### Manage Existing URLs + +Each link appears in the **Share Profile** list with its name, access level, status, token prefix, and expiry information. The available actions are: + +- **Copy link** — copy the URL to the clipboard. +- **Open link** — open the read-only profile page in a new tab. +- **Edit** — change the access level, shared sections, statistics, language, or expiry date. +- **Delete** — revoke the link immediately. Anyone using it will lose access. + +Treat an **Everyone** URL like a public page: anyone who receives it can view the selected information until the link expires or is deleted. Create separate links when you want different audiences or different languages. + ## API Keys Create and manage API keys for headless access to the REST API. Each key can have an optional description. Keys are shown once at creation — copy it immediately, as it cannot be retrieved later. diff --git a/docs/releases.md b/docs/releases.md index 4bfa988b..1aa433e9 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,7 +39,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner -**Summary:** Groups duplicate import-search results into expandable edition groups, makes running searches cancelable, introduces an adaptive date input with a native picker, adds optional book media and medium statistics, supports localized medium and possession searches, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. +**Summary:** Adds shareable read-only public profile pages with configurable access and content, groups duplicate import-search results into expandable edition groups, makes running searches cancelable, introduces an adaptive date input with a native picker, adds optional book media and medium statistics, supports localized medium and possession searches, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. **Features** - 📚 **Edition groups in the import search**: results from different providers that describe the same book (same ISBN, or same title and authors) are now grouped into expandable entries with an "N results" badge. Compare the variants side by side and import the one you want; no result is dropped anymore. See the [Library guide](/guide/using-librislog/library#how-results-are-grouped) for the exact grouping rules @@ -52,6 +52,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner - 📷 **Secure context detection in the barcode scanner**: if LibrisLog is served outside a secure context (plain HTTP on a remote host), the scan button is hidden and the scanner explains why the camera cannot start, instead of showing a black box. See the [Library guide](/guide/using-librislog/library#isbn-barcode-scan) for details - 🎥 **Active camera name in the scanner**: the barcode scanner now shows the name of the active camera in a badge next to the switch button, so you always know which lens is being used - 🔗 **Heimdall dashboard integration**: new documentation for the LibrisLog enhanced app, which shows your reading statistics directly on [Heimdall](https://github.com/linuxserver/Heimdall) tiles +- 🔗 **Shareable public profile pages**: create named, read-only profile URLs from the Profile page. Configure each link independently for public or logged-in-only access, selected profile sections and statistics, language, and an optional expiration date. Shared pages include responsive book cards, a mobile-safe reading timeline with incremental loading and hidden-book hints, full-library search with incremental loading, selectable 12-month/3-year/all-time trend ranges with value tooltips, distribution and rating panels, and the owner's generated avatar. Existing links can be copied, opened, edited, or revoked. The full URL token is only revealed on demand and is shown once after creation. See the [Profile guide](/guide/using-librislog/profile#urlprofile-sharing) for setup and security details **Bug fixes** - 🗓️ **Timezone-correct daily page statistics**: pages read between two progress updates are now attributed to calendar days in the user's timezone instead of fixed 24h slots, so the pages-per-day view matches your local days. Your heatmap may shift slightly after the upgrade diff --git a/frontend/e2e/specs/15-public-profile.spec.ts b/frontend/e2e/specs/15-public-profile.spec.ts new file mode 100644 index 00000000..0a169b58 --- /dev/null +++ b/frontend/e2e/specs/15-public-profile.spec.ts @@ -0,0 +1,190 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; +import { seedBooks } from '../fixtures/seed.api'; + +async function createShareLink( + page: Page, + name: string, + options: { sections?: string[]; language?: string } = {} +) { + const section = page.locator('#section-share-profile'); + await section.scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + + await section.locator('button.btn-primary').click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.locator('input[name="share-link-name"]').fill(name); + + if (options.sections) { + for (const sectionKey of options.sections) { + await dialog.locator(`input[name="share-link-section"][value="${sectionKey}"]`).check(); + } + } + + if (options.language) { + await dialog.locator('select[name="share-link-language"]').selectOption(options.language); + } + + await dialog.locator('button[type="submit"]').click(); + await expect(dialog).not.toBeVisible(); + + const urlText = await page.locator('#section-share-profile div.font-mono.break-all').first().textContent(); + expect(urlText).toMatch(/\/p\/lp_/); + return urlText!.trim(); +} + +async function openIncognito(browser: Browser, url: string): Promise { + const context = await browser.newContext(); + const publicPage = await context.newPage(); + await publicPage.goto(url); + await publicPage.waitForLoadState('networkidle'); + return publicPage; +} + +test.describe('Public Profile', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + + // The shared E2E database may have German persisted from spec 11.2. Force English so + // all assertions below are deterministic regardless of test order. + await page.goto('/profile'); + await page.waitForTimeout(1500); + await page.locator('select[name="language"]').selectOption('en'); + await page.locator('#section-language button[class*="btn-primary"]').click(); + await page.waitForTimeout(1000); + }); + + test('15.1 share link page shows selected sections and hides app chrome', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + // Spec 14 wipes the seed library; seed a couple of books so the configured + // sections (currently reading, full library + statistics) render real content + // on the public page. + await seedBooks(page, [ + { title: '1984', author: 'George Orwell', page_count: 328, reading_status: 'read', date_started: '2024-10-01', date_finished: '2024-10-20' }, + { title: 'The Three-Body Problem', author: 'Liu Cixin', page_count: 400, reading_status: 'currently_reading', date_started: '2025-01-15' } + ]); + + const shareUrl = await createShareLink(page, 'E2E Public Profile', { + sections: ['full_library'] + }); + + // The list entry persists the configured audience badge (default: Everyone) + await expect(page.locator('#section-share-profile')).toContainText('E2E Public Profile'); + + // App chrome (sidebar) is hidden on the public page even for logged-in users + await page.goto(shareUrl); + await page.waitForLoadState('networkidle'); + await expect(page.locator('aside')).toHaveCount(0); + + // Anonymous visitor sees the owner name, enabled sections, and no chrome + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByRole('heading', { name: /E2E Tester/ })).toBeVisible(); + await expect(publicPage.locator('aside')).toHaveCount(0); + + // full_library was selected explicitly and its content renders. Earlier specs + // may leave duplicate copies of the same title in the shared E2E DB, so + // assert presence (first match) rather than uniqueness. + await expect(publicPage.getByText('Full Library')).toBeVisible(); + const librarySection = publicPage.locator('section').filter({ hasText: 'Full Library' }); + await expect(librarySection.locator('.grid > div')).not.toHaveCount(0); + await expect(librarySection.getByText('1984', { exact: true }).first()).toBeVisible(); + + // currently_reading is on by default and renders the started-on date + const readingSection = publicPage.locator('section').filter({ hasText: 'Currently Reading' }); + await expect(readingSection.getByText('The Three-Body Problem', { exact: true }).first()).toBeVisible(); + await expect(readingSection.getByText('Started on', { exact: false }).first()).toBeVisible(); + + // statistics section is on by default and shows computed values + await expect(publicPage.getByText('Total Books')).toBeVisible(); + + // footer links the librislog word to GitHub and nothing else + const footer = publicPage.locator('footer'); + await expect(footer.getByRole('link', { name: 'LibrisLog' })).toBeVisible(); + await expect(footer.getByRole('link')).toHaveCount(1); + await publicPage.close(); + }); + + test('15.2 authenticated audience blocks anonymous viewers', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + const shareUrl = await createShareLink(page, 'Audience Test'); + + // Open the edit dialog and restrict access to logged-in users + const row = page.locator('#section-share-profile li').filter({ hasText: 'Audience Test' }); + await row.locator('button[aria-label="Edit"]').click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.locator('input[name="share-link-audience"][value="authenticated"]').check(); + await dialog.locator('button[type="submit"]').click(); + await expect(dialog).not.toBeVisible(); + + await expect(row).toContainText('Logged-in users only'); + + // A logged-in viewer can still open the page + await page.goto(shareUrl); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toBeVisible(); + + // Anonymous visitors are redirected to login + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByRole('heading', { name: 'Login required' })).toBeVisible(); + await expect(publicPage.getByRole('link', { name: 'Log in' })).toBeVisible(); + await publicPage.close(); + }); + + test('15.3 deleted share link returns not found', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + const shareUrl = await createShareLink(page, 'Delete Me Test'); + + const row = page.locator('#section-share-profile li').filter({ hasText: 'Delete Me Test' }); + await row.locator('button[aria-label="Delete"]').click(); + + const confirmDialog = page.locator('dialog.modal-open'); + await expect(confirmDialog).toBeVisible(); + await confirmDialog.locator('button.btn-error').click(); + await expect(confirmDialog).not.toBeVisible(); + + // The deleted link's row disappears from the list + await expect(page.locator('#section-share-profile li').filter({ hasText: 'Delete Me Test' })).toHaveCount(0); + + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByText('This public profile link is no longer valid.')).toBeVisible(); + await publicPage.close(); + }); + + test('15.4 share link language controls the public page locale', async ({ page, browser }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + await seedBooks(page, [ + { title: '1984', author: 'George Orwell', page_count: 328, reading_status: 'currently_reading', date_started: '2024-10-01' } + ]); + + const shareUrl = await createShareLink(page, 'German Profile Link', { language: 'de' }); + + // Default language of the dialog follows the profile language ('en'); a German + // share link renders the public page entirely in German for anonymous viewers. + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.getByText('Aktuell gelesen')).toBeVisible(); + await expect(publicPage.getByText('Currently Reading')).toHaveCount(0); + await publicPage.close(); + + const dialog = page.getByRole('dialog'); + await page.locator('#section-share-profile').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + const row = page.locator('#section-share-profile li').filter({ hasText: 'German Profile Link' }); + await row.locator('button[aria-label="Edit"]').click(); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('select[name="share-link-language"]')).toHaveValue('de'); + await dialog.locator('button[aria-label="Close"]').click(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5011c216..5a44e05f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -41,6 +41,12 @@ import type { SortOrder, OidcConfig, OidcLinkStatus, + PublicProfileAudience, + PublicProfileLink, + PublicProfileLinkCreateResponse, + PublicProfileResponse, + PublicProfileVisibilityConfig, + ShareLinkRevealResponse, User, UserCreateResponse, UserAdminUpdate, @@ -99,6 +105,35 @@ async function request(path: string, options?: RequestInit): Promise { return res.json() as Promise; } +async function publicRequest(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + ...options + }); + + const contentType = res.headers.get('content-type') ?? ''; + const isJson = contentType.includes('application/json'); + + if (!res.ok) { + const err = new Error(`HTTP ${res.status}`) as Error & { status?: number }; + err.status = res.status; + if (isJson) { + const detail = await res.json().catch(() => ({})); + err.message = detail?.detail ?? `HTTP ${res.status}`; + throw err; + } + const text = await res.text().catch(() => ''); + err.message = text || `HTTP ${res.status}`; + throw err; + } + if (res.status === 204) return undefined as T; + if (!isJson) { + throw new Error(`Unexpected non-JSON response for ${path}`); + } + return res.json() as Promise; +} + export const api = { auth: { setupRequired(): Promise<{ required: boolean }> { @@ -214,6 +249,49 @@ export const api = { return request(`/profile/embed-tokens/${id}`, { method: 'DELETE' }); }, + listShareLinks(): Promise { + return request('/profile/share-links'); + }, + + createShareLink(data: { + name: string; + audience: PublicProfileAudience; + language?: string | null; + visibility_config: PublicProfileVisibilityConfig; + expires_at?: string | null; + }): Promise { + return request('/profile/share-links', { + method: 'POST', + body: JSON.stringify(data) + }); + }, + + updateShareLink( + id: number, + data: Partial<{ + name: string; + audience: PublicProfileAudience; + language: string | null; + visibility_config: PublicProfileVisibilityConfig; + expires_at: string | null; + }> + ): Promise { + return request(`/profile/share-links/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); + }, + + deleteShareLink(id: number): Promise { + return request(`/profile/share-links/${id}`, { method: 'DELETE' }); + }, + + revealShareLink(id: number): Promise { + return request(`/profile/share-links/${id}/reveal`, { + method: 'POST' + }); + }, + resetData(confirmation: string): Promise { return request('/profile/reset-data', { method: 'POST', @@ -259,6 +337,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..5a0e9d45 --- /dev/null +++ b/frontend/src/lib/components/ShareLinkDialog.svelte @@ -0,0 +1,351 @@ + + +{#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..ad51be09 --- /dev/null +++ b/frontend/src/lib/components/ShareLinkDialog.test.ts @@ -0,0 +1,161 @@ +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), SUPPORTED_LOCALES: ['en', 'de', 'zh', 'es', 'fr'] }; +}); + +function createLink(overrides?: Partial): PublicProfileLink { + const visibility_config = defaultPublicProfileVisibilityConfig(); + return { + id: 1, + name: 'Friends & family', + token_prefix: 'lp_9f2c81a4e7d3', + audience: 'public', + language: null, + 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; + language: string | null; + expires_at: string | null; + visibility_config: { sections: string[]; statistics: string[] }; + }; + expect(payload.name).toBe('My profile'); + expect(payload.audience).toBe('public'); + expect(payload.language).toBe('en'); + 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(); + }); + + it('uses the profile language as default and prefills the link language on edit', async () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + const { container, unmount } = render(ShareLinkDialog, { + props: { open: true, link: null, defaultLanguage: 'de', onSave, onClose } + }); + const langSelect = container.querySelector('select[name="share-link-language"]')!; + expect(langSelect).toBeTruthy(); + expect(langSelect.value).toBe('de'); + + await fireEvent.input(container.querySelector('input[name="share-link-name"]')!, { + target: { value: 'German' } + }); + await fireEvent.click(container.querySelector('button[type="submit"]')!); + expect((onSave.mock.calls[0][0] as { language: string | null }).language).toBe('de'); + unmount(); + + const link = createLink({ language: 'fr' }); + const onSave2 = vi.fn(); + const { container: container2, unmount: unmount2 } = render(ShareLinkDialog, { + props: { open: true, link, defaultLanguage: 'de', onSave: onSave2, onClose } + }); + const langSelect2 = container2.querySelector('select[name="share-link-language"]')!; + expect(langSelect2.value).toBe('fr'); + unmount2(); + }); +}); \ 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..b9d018f2 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -36,6 +36,7 @@ "booksFinishedPerMonth": "Beendete Bücher pro Monat", "booksFinishedPerYear": "Beendete Bücher pro Jahr", "rangeLabel": "Zeitraum", + "rangeLast12Months": "Letzte 12 Monate", "refreshing": "Statistiken werden aktualisiert", "rangeAllTime": "Gesamte Zeit", "rangeLast3Years": "Letzte 3 Jahre", @@ -489,6 +490,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 +530,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 +587,85 @@ } } }, + "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 {app}", + "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.", + "timelineMore": "und {count} weitere", + "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..860f29ca 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -38,6 +38,7 @@ "booksFinishedPerMonth": "Books Finished Per Month", "booksFinishedPerYear": "Books Finished Per Year", "rangeLabel": "Time range", + "rangeLast12Months": "Last 12 months", "refreshing": "Updating statistics", "rangeAllTime": "All time", "rangeLast3Years": "Last 3 years", @@ -489,6 +490,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 +530,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 +587,85 @@ } } }, + "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 {app}", + "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.", + "timelineMore": "and {count} more", + "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..18f1057f 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -36,6 +36,7 @@ "booksFinishedPerMonth": "Libros terminados por mes", "booksFinishedPerYear": "Libros terminados por año", "rangeLabel": "Período", + "rangeLast12Months": "Últimos 12 meses", "refreshing": "Actualizando estadísticas", "rangeAllTime": "Todo el tiempo", "rangeLast3Years": "Últimos 3 años", @@ -489,6 +490,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 +530,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 +587,85 @@ } } }, + "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 {app}", + "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.", + "timelineMore": "y {count} más", + "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..0c5b76f4 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -36,6 +36,7 @@ "booksFinishedPerMonth": "Livres terminés par mois", "booksFinishedPerYear": "Livres terminés par an", "rangeLabel": "Période", + "rangeLast12Months": "12 derniers mois", "refreshing": "Mise à jour des statistiques", "rangeAllTime": "Depuis toujours", "rangeLast3Years": "3 dernières années", @@ -489,6 +490,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 +530,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 +587,85 @@ } } }, + "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 {app}", + "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.", + "timelineMore": "et {count} de plus", + "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..136244b8 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -36,6 +36,7 @@ "booksFinishedPerMonth": "每月读完数量", "booksFinishedPerYear": "每年读完数量", "rangeLabel": "时间范围", + "rangeLast12Months": "最近12个月", "refreshing": "正在更新统计数据", "rangeAllTime": "全部时间", "rangeLast3Years": "最近3年", @@ -489,6 +490,8 @@ "invalidConfirmationPhrase": "确认短语不匹配。", "cannotDeleteLastAdmin": "无法删除账户:你是最后一个管理员", "cannotDeleteOwnAccountHere": "你不能在这里删除自己的账户。请使用“个人资料” > “危险区域”。", + "publicProfileNotFound": "此公开资料链接已失效。", + "publicProfileLoginRequired": "此个人资料仅对已登录用户可见。请登录后查看。", "importMalformedEvent": "导入期间收到格式错误的服务器事件。", "importUnsupportedContentType": "不支持的上传内容类型。请使用 CSV 或 JSON 文件。", "emailAlreadyRegistered": "此邮箱地址已注册。", @@ -527,6 +530,12 @@ "profileSaveFailed": "保存个人资料失败", "passwordChangeSuccess": "密码已更改", "passwordChangeFailed": "更改密码失败", + "shareProfile": { + "title": "分享个人资料", + "subtitle": "通过链接发布你的阅读资料的只读视图。", + "createLink": "创建新链接", + "empty": "暂无公开资料链接。" + }, "goals": { "title": "阅读目标", "subtitle": "启用目标,即可在仪表板上趣味追踪进度。", @@ -578,6 +587,85 @@ } } }, + "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": "由 {app} 提供支持", + "githubLink": "LibrisLog on GitHub", + "expiresHint": "此链接将于 {date} 过期。", + "finishedOn": "完成于 {date}", + "emptyLibrary": "此书库为空。", + "noCurrentlyReading": "当前没有正在阅读的图书。", + "noLastRead": "暂无已读完的图书。", + "timelineMore": "还有 {count} 本", + "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..740832d6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -461,6 +461,93 @@ 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; + language: string | null; + visibility_config: PublicProfileVisibilityConfig; + expires_at: string | null; + created_at: string; +} + +export interface PublicProfileLinkCreateResponse { + token: string; + link: PublicProfileLink; +} + +export interface ShareLinkRevealResponse { + token: string; +} + +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; + language: string | null; + 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..81eea902 --- /dev/null +++ b/frontend/src/routes/p/[token]/+page.svelte @@ -0,0 +1,744 @@ + + + + + {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)}

    + {books.length} +
    +
    + {#each books as book} +
    + {#if book.cover_url} + + {:else} +
    + {/if} +
    +

    {book.title}

    +

    ({formatAuthors(book.authors)})

    +
    +
    + {/each} +
    + {#if timelineHiddenCount(monthKey, books.length) > 0} +

    {$_('publicProfile.page.timelineMore', { values: { count: timelineHiddenCount(monthKey, books.length) } })}

    + {/if} +
  2. + {/each} +
+ {#if timelineBooks.length > timelineLimit} + + {/if} + {/if} +
+
+ {/if} + + {#if hasSection('full_library')} +
+
+

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

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

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

+ {:else} + (libraryLimit = 8)} + /> + {#if filteredBooks.length === 0} +

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

+ {:else} +
+ {#each filteredBooks.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 filteredBooks.length > libraryLimit} + + {/if} + {/if} + {/if} +
+
+ {/if} + + {#if hasSection('statistics') && profile.statistics} +
+ {#if summaryStats.length > 0} +
+
+

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

+
+ {#each summaryStats as key} +
+
{$_(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} +
{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)} + {formatNumber(actualPercentage(row.value, distTotal), 0)}% + +
+
+
+
+
+ {/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')}

+ {#if ratingSummaryKeys.length > 0} +
+ {#each ratingSummaryKeys as key} + {@const i18nKey = PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey} +
+

{$_(i18nKey)}

+

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

+
+ {/each} +
+ {/if} + + {#if ratingStats.includes('top_authors')} + {@const authors = statValue('top_authors') as { author: string; book_count: number }[]} + {#if authors.length > 0} +
+

{$_('statistics.topAuthors')}

+
+ {#each authors as author, idx} +
+ {idx + 1} + {author.author} + {$_('statistics.booksCount', { values: { count: author.book_count } })} +
+ {/each} +
+
+ {/if} + {/if} + + {#if ratedBookKeys.length > 0} +
+ {#each ratedBookKeys as key} + {@const i18nKey = PUBLIC_PROFILE_STATISTICS.find((s) => s.key === key)!.i18nKey} + {@const books = statValue(key) as { title: string; author: string | null; rating: number; cover_url: string | null }[]} + {#if books.length > 0} +
+

{$_(i18nKey)}

+
+ {#each books.slice(0, 5) as book} +
+ {#if book.cover_url} + + {:else} +
+ {/if} +
+

{book.title}

+ {#if book.author}

{book.author}

{/if} +
+ + +
+ {/each} +
+
+ {/if} + {/each} +
+ {/if} +
+
+ {/if} +
+ {/if} + {/if} +
+ +
+
+ {@html $_('publicProfile.page.shareHint', { values: { app: GITHUB_ANCHOR } })} +
+
+
+ + diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index ebcce548..7d3c106e 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, 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'; @@ -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,110 @@ 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); + let revealedTokens = $state>({}); + let copiedShareLinkId = $state(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; + language: 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 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 { const response = await api.oidc.startLink(); @@ -900,6 +1014,116 @@ {/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} +

    + {#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()} + {:else} + {$_('publicProfile.unlimited')} + {/if} +

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

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

@@ -1034,6 +1258,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: [],