Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3ae6cf8
Backend: reading sessions/re-reads (issue #114)
codebude Oct 21, 2026
1cecfc7
Frontend: reading sessions/re-reads UI (issue #114)
codebude Oct 21, 2026
24fff03
Fix migration: omit FK constraint (SQLite does not support ALTER ADD …
codebude Oct 21, 2026
25eee57
Address code review findings (go-1)
codebude Oct 22, 2026
05f29a4
Address code review findings (go-2 cross-check)
codebude Oct 22, 2026
91aa9e4
Round-trip reading sessions through JSON export/import
codebude Oct 22, 2026
3b60a12
Docs: clarify CSV cannot round-trip re-read history
codebude Oct 22, 2026
b07b254
Show read count badge on book detail page (from first reread on)
codebude Oct 22, 2026
1e8db90
Restore timeline pagination (max 40 + load more) and SQL window funct…
codebude Oct 22, 2026
7e4b924
Fix restore of older backups (create_all vs migration collision)
codebude Oct 22, 2026
c7c1703
Docs: backward compatibility for older exports/backups
codebude Oct 22, 2026
965509b
Clear progress log when last reading session is deleted
codebude Oct 22, 2026
a3d4295
Docs: add regular unreleased v1.11.0 release entry
codebude Oct 22, 2026
b39d40c
Docs: remove unreleased bug-fix entries; add regression test for prog…
codebude Oct 22, 2026
cd9abdf
Harden route ordering: move timeline endpoint into books router (no c…
codebude Oct 22, 2026
bda014a
Fix naive/aware datetime comparison and current-session marking in se…
codebude Oct 22, 2026
720b73f
Docs: note progress history is reference-only on export (full fidelit…
codebude Oct 22, 2026
566c88c
Fix timeline load-more off-by-one (return total count)
codebude Oct 22, 2026
2283729
Support uploading LibrisLog export ZIP directly to import wizard
codebude Oct 22, 2026
aaab107
Revert "Support uploading LibrisLog export ZIP directly to import wiz…
codebude Oct 22, 2026
49145d9
Revert "Fix naive/aware datetime comparison and current-session marki…
codebude Oct 22, 2026
8ead95f
Revert "Round-trip reading sessions through JSON export/import"
codebude Oct 22, 2026
3065422
Revert "Docs: note progress history is reference-only on export (full…
codebude Oct 22, 2026
ef7e6c9
Revert "Docs: clarify CSV cannot round-trip re-read history"
codebude Oct 22, 2026
14353b6
Rollback import/export round-trip changes (sessions/progress)
codebude Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions backend/alembic/versions/a3b4c5d6e7f8_add_reading_session_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""add reading_session table and reading_progress.session_id

Revision ID: a3b4c5d6e7f8
Revises: d7e8f9a0b1c2
Create Date: 2026-10-22 00:00:00.000000

"""
from datetime import datetime, timezone
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'a3b4c5d6e7f8'
down_revision: Union[str, Sequence[str], None] = 'd7e8f9a0b1c2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _session_status(status: str, date_started, date_finished) -> str:
"""Map a book's reading status to the equivalent session status."""
if status == 'currently_reading':
return 'currently_reading'
if status == 'did_not_finish':
return 'did_not_finish'
if status == 'read':
return 'read'
# want_to_read book that still carries reading evidence
return 'read' if date_finished is not None else 'currently_reading'


def upgrade() -> None:
op.create_table('reading_session',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('book_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('status', sa.VARCHAR(), nullable=False),
sa.Column('date_started', sa.DateTime(), nullable=True),
sa.Column('date_finished', sa.DateTime(), nullable=True),
sa.Column('is_current', sa.Boolean(), nullable=False, server_default=sa.text('0')),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_reading_session_book_id'), 'reading_session', ['book_id'], unique=False)
op.create_index(op.f('ix_reading_session_user_id'), 'reading_session', ['user_id'], unique=False)
op.create_index(op.f('ix_reading_session_status'), 'reading_session', ['status'], unique=False)
op.create_index(op.f('ix_reading_session_date_started'), 'reading_session', ['date_started'], unique=False)
op.create_index(op.f('ix_reading_session_date_finished'), 'reading_session', ['date_finished'], unique=False)
op.create_index(op.f('ix_reading_session_is_current'), 'reading_session', ['is_current'], unique=False)
op.create_index(
'uq_reading_session_book_current', 'reading_session', ['book_id'],
unique=True, sqlite_where=sa.text('is_current = 1'),
)

op.add_column('reading_progress', sa.Column('session_id', sa.Integer(), nullable=True))
op.create_index(op.f('ix_reading_progress_session_id'), 'reading_progress', ['session_id'], unique=False)
# SQLite does not support adding a FOREIGN KEY constraint via ALTER TABLE,
# and this project does not enforce FKs at the SQLite level anyway. The
# relationship is maintained in the application layer.

bind = op.get_bind()
now = datetime.now(timezone.utc).replace(tzinfo=None)

# Backfill: one session per book with reading evidence, mirroring the book
# dates verbatim so a single-session book behaves identically to before.
books = bind.execute(
sa.text("SELECT id, user_id, reading_status, date_started, date_finished FROM book")
).fetchall()
for book_id, user_id, status, date_started, date_finished in books:
# want_to_read books never get a session: their (legacy) progress stays
# unassigned and continues through the book-level statistics path, so
# their numbers do not change on upgrade.
if status == 'want_to_read':
continue

session_status = _session_status(status, date_started, date_finished)
is_current = 1 if session_status == 'currently_reading' else 0
bind.execute(
sa.text(
"INSERT INTO reading_session "
"(book_id, user_id, status, date_started, date_finished, is_current, created_at, updated_at) "
"VALUES (:book_id, :user_id, :status, :date_started, :date_finished, :is_current, :now, :now)"
),
{
"book_id": book_id,
"user_id": user_id,
"status": session_status,
"date_started": date_started,
"date_finished": date_finished,
"is_current": is_current,
"now": now,
},
)
session_id = bind.execute(
sa.text("SELECT id FROM reading_session WHERE book_id = :bid ORDER BY id DESC LIMIT 1"),
{"bid": book_id},
).scalar()
if session_id is not None:
bind.execute(
sa.text("UPDATE reading_progress SET session_id = :sid WHERE book_id = :bid"),
{"sid": session_id, "bid": book_id},
)


def downgrade() -> None:
op.drop_index(op.f('ix_reading_progress_session_id'), table_name='reading_progress')
op.drop_column('reading_progress', 'session_id')
op.drop_index('uq_reading_session_book_current', table_name='reading_session')
op.drop_index(op.f('ix_reading_session_is_current'), table_name='reading_session')
op.drop_index(op.f('ix_reading_session_date_finished'), table_name='reading_session')
op.drop_index(op.f('ix_reading_session_date_started'), table_name='reading_session')
op.drop_index(op.f('ix_reading_session_status'), table_name='reading_session')
op.drop_index(op.f('ix_reading_session_user_id'), table_name='reading_session')
op.drop_index(op.f('ix_reading_session_book_id'), table_name='reading_session')
op.drop_table('reading_session')
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, public_profile, share_links, statistics, users
from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, sessions, 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
Expand Down Expand Up @@ -220,6 +220,7 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response:


app.include_router(books.router)
app.include_router(sessions.router)
app.include_router(import_.router)
app.include_router(covers.router)
app.include_router(cover_candidates.router)
Expand Down
49 changes: 49 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,51 @@ class ApiKey(SQLModel, table=True):
)


class ReadingSession(SQLModel, table=True):
"""A single reading period (first read, re-read, etc.) of a book.

``Book.date_started`` / ``Book.date_finished`` remain a denormalized mirror
of the *current* session (``is_current=True``), or of the latest session when
none is current, so every existing consumer keeps working unchanged. A book
with a single session therefore behaves identically to the pre-session model.

At most one current session exists per book (enforced by a partial unique
index on ``is_current`` and by the service layer).
"""

__tablename__: str = "reading_session"
__table_args__ = (
sa.Index(
"uq_reading_session_book_current",
"book_id",
unique=True,
sqlite_where=sa.text("is_current = 1"),
),
)

id: Optional[int] = Field(default=None, primary_key=True)
book_id: int = Field(foreign_key="book.id", index=True)
user_id: int = Field(foreign_key="user.id", index=True)
status: ReadingStatus = Field(index=True)
date_started: Optional[datetime] = Field(
default=None,
sa_column=Column(UtcDateTime, index=True)
)
date_finished: Optional[datetime] = Field(
default=None,
sa_column=Column(UtcDateTime, index=True)
)
is_current: bool = Field(default=False, index=True)
created_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow)
)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow)
)


class ReadingProgress(SQLModel, table=True):
"""A page-number reading progress entry for a book."""

Expand All @@ -259,6 +304,10 @@ class ReadingProgress(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
book_id: int = Field(foreign_key="book.id", index=True)
user_id: int = Field(foreign_key="user.id", index=True)
# Note: no FK to reading_session here. SQLite cannot add a FOREIGN KEY via
# ALTER TABLE, and this project does not enforce FKs at the SQLite level, so
# the relationship is maintained in the application layer only.
session_id: Optional[int] = Field(default=None, index=True)
page: int = Field(ge=0)
created_at: datetime = Field(
default_factory=utcnow,
Expand Down
102 changes: 99 additions & 3 deletions backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from app.auth import require_user
from app.config import settings
from app.database import get_session
from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, ReadingProgress, ReadingStatus, Tag, User, UserSettings
from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, ReadingProgress, ReadingSession, ReadingStatus, Tag, User, UserSettings
from app.schemas import (
BookCreate,
BookListResponse,
Expand All @@ -25,6 +25,8 @@
StatusTransitionResponse,
SuggestionList,
TagCloudEntry,
TimelineSessionEntry,
TimelineSessionListResponse,
)
from app.services.authors import (
cleanup_orphan_authors,
Expand All @@ -39,6 +41,7 @@
)
from app.services.cover_import import import_cover_from_url, is_external_cover_url
from app.services.quote_cache import get_or_fetch_dashboard_quote
from app.services.reading_sessions import assign_read_numbers, create_initial_session, reconcile_book_sessions, session_counts
from app.services.search import _escape_like, apply_search_filter
from app.services.tags import build_book_read, cleanup_orphan_tags, load_tags_batch, sync_book_tags
from app.time_utils import utcnow
Expand Down Expand Up @@ -260,12 +263,17 @@ def list_books(
book_ids = [b.id for b in books if b.id is not None]
book_tags_map = load_tags_batch(session, book_ids) if book_ids else {}
book_authors_map = load_authors_batch(session, book_ids) if book_ids else {}
session_counts_map = session_counts(session, book_ids) if book_ids else {}
book_reads: list[BookRead] = []
for book in books:
assert book.id is not None
book_reads.append(
_build_book_read_with_tags(book, book_tags_map.get(book.id), book_authors_map.get(book.id))
counts = session_counts_map.get(book.id, {})
book_read = _build_book_read_with_tags(
book, book_tags_map.get(book.id), book_authors_map.get(book.id)
)
book_read.session_count = counts.get("session_count", 0)
book_read.read_count = counts.get("read_count", 0)
book_reads.append(book_read)
return BookListResponse(
books=book_reads,
total=total,
Expand Down Expand Up @@ -488,10 +496,91 @@ async def create_book(
session.rollback()
_raise_integrity_conflict(exc)
session.refresh(book)
create_initial_session(session, book)
session.commit()
logger.info("Created book: %r (id=%s)", book.title, book.id)
return build_book_read(session, book)


@router.get("/sessions", response_model=TimelineSessionListResponse)
def list_completed_sessions(
limit: int = Query(default=40, ge=1, le=200),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_user),
session: Session = Depends(get_session),
) -> TimelineSessionListResponse:
"""Return completed reads (sessions) across the user's books, newest first.

Mounted here (in the books router, before ``/{book_id}``) so that the
account-level path ``/api/books/sessions`` always resolves to this handler
instead of being captured by the ``/{book_id}`` route, which would try to
parse ``"sessions"`` as an int and return 422. Do not move this route
below ``get_book``.
"""
total = session.exec(
select(func.count()).select_from(ReadingSession).where(
ReadingSession.user_id == current_user.id,
ReadingSession.status == ReadingStatus.read,
col(ReadingSession.date_finished).is_not(None),
)
).one()

page_rows = session.exec(
select(ReadingSession, Book)
.join(Book, col(Book.id) == col(ReadingSession.book_id))
.where(
ReadingSession.user_id == current_user.id,
ReadingSession.status == ReadingStatus.read,
col(ReadingSession.date_finished).is_not(None),
)
.order_by(col(ReadingSession.date_finished).desc())
.offset(offset)
.limit(limit)
).all()

page_book_ids = list({rs.book_id for rs, _ in page_rows})
all_rows = (
session.exec(
select(ReadingSession).where(
ReadingSession.user_id == current_user.id,
ReadingSession.status == ReadingStatus.read,
col(ReadingSession.date_finished).is_not(None),
col(ReadingSession.book_id).in_(page_book_ids),
)
).all()
if page_book_ids
else []
)
per_book: dict[int, list[ReadingSession]] = {}
for rs in all_rows:
per_book.setdefault(rs.book_id, []).append(rs)
authors_map = load_authors_batch(session, page_book_ids)
read_numbers_by_book = {
book_id: assign_read_numbers(s_list) for book_id, s_list in per_book.items()
}

result: list[TimelineSessionEntry] = []
for rs, book in page_rows:
read_number = read_numbers_by_book.get(rs.book_id, {}).get(rs.id)
authors = authors_map.get(book.id, [])
result.append(
TimelineSessionEntry(
session_id=rs.id or 0,
book_id=book.id or 0,
title=book.title,
author=join_authors(authors),
authors=authors,
cover_url=book.cover_url,
rating=book.rating,
page_count=book.page_count,
date_finished=rs.date_finished,
read_number=read_number,
is_reread=read_number is not None and read_number >= 2,
)
)
return TimelineSessionListResponse(entries=result, total=total)


@router.get("/{book_id}", response_model=BookRead)
def get_book(
book_id: int,
Expand Down Expand Up @@ -586,6 +675,7 @@ async def update_book(
except IntegrityError as exc:
session.rollback()
_raise_integrity_conflict(exc)
reconcile_book_sessions(session, book)
if tags_provided:
assert book.id is not None
sync_book_tags(session, current_user.id, book.id, tags_raw)
Expand Down Expand Up @@ -738,6 +828,8 @@ def transition_status(
)
book.sqlmodel_update(update_data)
session.add(book)
session.flush()
reconcile_book_sessions(session, book)
session.commit()
session.refresh(book)
return StatusTransitionResponse(book=build_book_read(session, book), date_conflict=None)
Expand Down Expand Up @@ -773,6 +865,10 @@ def delete_book(
select(ReadingProgress).where(ReadingProgress.book_id == book.id)
).all():
session.delete(entry)
for reading_session in session.exec(
select(ReadingSession).where(ReadingSession.book_id == book.id)
).all():
session.delete(reading_session)
session.delete(book)
cleanup_orphan_tags(session, current_user.id)
cleanup_orphan_authors(session, current_user.id)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/routers/import_.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from app.services.authors import resolve_authors_payload, sync_book_authors
from app.services.cover_import import is_safe_cover_import_url
from app.services.cover_storage import download_cover
from app.services.reading_sessions import create_initial_session
from app.services.tags import build_book_read, sync_book_tags

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -169,6 +170,7 @@ async def import_book(
except IntegrityError as exc:
session.rollback()
_raise_integrity_conflict(exc)
create_initial_session(session, book)
sync_book_tags(session, current_user.id, book.id or 0, c.tags)
names = resolve_authors_payload(c.author, c.authors) or []
sync_book_authors(session, current_user.id, book.id or 0, names)
Expand Down
Loading
Loading