Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,50 @@ All notable changes to Tome are documented here. Format loosely follows
to the book. The tab renders full-bleed — edge to edge, viewport-tall — and
the ribbon is also available as a regular tile in the dashboard gallery.

### Added
- **"Time left in chapter" in the web reader.** The reader footer now shows
"~12 min left" beside the chapter name, computed from the book's chapter
map and your own measured reading pace (a sensible default until you have
reading history). Quietly absent for books without a chapter map or word
count.
- **Position history — undo a bad sync.** Tome now keeps a short log of every
meaningful reading-position change per book (device, web, manual — the
idle heartbeat doesn't spam it). A history button on the book page's
Reading Stats header lists them, and any entry can be restored as the live
position with one click — including explicitly un-finishing a book that a
device falsely jumped to 100%. Devices pick the restored position up on
their next sync. The classic sync horror story is no longer unrecoverable.
- **Command palette.** Press Cmd+K (Ctrl+K) anywhere to jump straight to a
book, series, author, or page — full-text book search with covers, ranked
series/author matches, and quick navigation, all keyboard-driven. Listed
under "?" shortcuts help.
- **Upload knows what you already have.** Files added to the upload dialog are
hashed in the browser and checked against the library before anything is
sent — exact duplicates get an "already in your library" note with a link to
the existing book and are skipped, instead of uploading megabytes just for
the server to silently discard them.
- **Admin → Covers: cover-quality audit.** Lists books whose covers are
missing, unreadable, or genuinely low-resolution (real thumbnails, not
standard-source covers), with sizes shown. Books with no cover at all offer
a one-click auto-fix from the cover search (nothing to downgrade); low-res
ones deep-link to the book page to pick a better candidate by eye.
- **"What's new" after an upgrade.** The first visit after the server moves to
a new release shows a one-time panel with that release's notes, straight
from the changelog — so features stop shipping invisibly. Dismiss it and it
stays gone until the next release; fresh installs never see it.
- **Update indicator for admins.** Settings → About quietly shows "vX.Y.Z
available" (linking to GitHub releases) when a newer release exists, via a
daily-cached server-side check. Set `TOME_UPDATE_CHECK=false` to disable
the lookup entirely — nothing else phones home.
- **Timeline: the tooltip now answers at day level.** Hovering a bar resolves
the exact day under the cursor — "15 May · 22m", or "no reading" on a gap
day — alongside the book's totals. The day data was always drawn as tick
intensity; now it's readable.
- **Timeline on phones.** The series rail narrows so the ribbon keeps most of
the screen, and bars are tap-friendly: the first tap shows the details
tooltip, a second tap opens the book (tapping empty space or scrolling
dismisses it). Previously any tap navigated away immediately.

### Fixed
- **Stats on phones: dead gap and misaligned range picker.** The Stats page
header paid the notch inset a second time inside the app shell (the top bar
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ RUN pip install --no-cache-dir .
COPY backend/ ./backend/
COPY alembic.ini ./
COPY alembic/ ./alembic/
# What's-New panel reads release notes from the changelog at runtime
COPY CHANGELOG.md ./
COPY --from=frontend-build /app/frontend/dist ./frontend/dist

ENV TOME_DATA_DIR=/data \
Expand Down
78 changes: 78 additions & 0 deletions backend/api/admin_covers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Admin cover-quality audit: which books have missing or low-resolution covers.

Read-only listing; fixing goes through the existing per-book cover picker (or
the client-driven auto-fix, which reuses /books/{id}/cover-candidates +
POST /books/{id}/cover). PIL reads only image headers here, so a full-library
sweep stays cheap.
"""
import logging

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from backend.core.config import settings
from backend.core.database import get_db
from backend.core.permissions import is_admin
from backend.core.security import get_current_user
from backend.models.book import Book
from backend.models.user import User

log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/covers", tags=["admin"])

# Below this width a cover renders visibly soft on the dashboard grid. 300 is
# a deliberate floor: the standard Google Books cover is 329px wide and looks
# fine in practice — flagging it would drown the real offenders (the 98–128px
# thumbnails), which is what this audit exists to surface.
MIN_WIDTH = 300


@router.get("/audit")
def cover_audit(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
if not is_admin(current_user):
raise HTTPException(status_code=403, detail="Admin only")

from PIL import Image

flagged: list[dict] = []
scanned = 0
books = (
db.query(Book)
.filter(Book.status == "active")
.order_by(Book.series.isnot(None), Book.series, Book.series_index, Book.title)
.all()
)
for b in books:
scanned += 1
reason: str | None = None
width = height = None
if not b.cover_path:
reason = "missing"
else:
path = settings.covers_dir / b.cover_path
if not path.is_file():
reason = "missing"
else:
try:
with Image.open(path) as im: # lazy: header only, no pixel decode
width, height = im.size
if width < MIN_WIDTH:
reason = "low_res"
except OSError:
reason = "unreadable"
if reason:
flagged.append({
"book_id": b.id,
"title": b.title,
"author": b.author,
"series": b.series,
"series_index": b.series_index,
"reason": reason,
"width": width,
"height": height,
})

return {"scanned": scanned, "min_width": MIN_WIDTH, "books": flagged}
163 changes: 163 additions & 0 deletions backend/api/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,169 @@ def get_book_annotations(
]


# ── Reader pacing ("time left in chapter") ────────────────────────────────────

@router.get("/{book_id}/reader-pacing")
def get_reader_pacing(
book_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Everything the web reader needs for "time left in chapter": the book's
chapter map (fraction-of-book boundaries), its word count, and the user's
true WPM. The WPM mirrors the stats rule exactly — finished word-counted
books with >= 5 min of reconciled read-time — and is null until the user
has such history (the reader falls back to a default pace)."""
from backend.models.book import BookChapter
from backend.services import reconciled_reading as rr
from backend.services.reading_day import date_modifier

book = _visible_book_or_404(db, current_user, book_id)
chapters = (
db.query(BookChapter)
.filter(BookChapter.book_id == book_id)
.order_by(BookChapter.idx)
.all()
)

wpm = None
covered = rr.covered_book_ids(db, current_user.id)
secs_by_book = rr.book_seconds(db, current_user.id, date_modifier(0), covered, None, None)
rows = (
db.query(Book.id, Book.word_count)
.join(UserBookStatus, UserBookStatus.book_id == Book.id)
.filter(
UserBookStatus.user_id == current_user.id,
UserBookStatus.status == "read",
Book.status == "active",
Book.word_count.isnot(None),
)
.all()
)
WPM_MIN_SECONDS = 300
words = secs = 0
for bid, wc in rows:
s = int(secs_by_book.get(bid, (0, 0, 0))[0])
if s >= WPM_MIN_SECONDS:
words += int(wc)
secs += s
if secs > 0:
wpm = round(words * 60 / secs, 1)

return {
"word_count": book.word_count,
"wpm": wpm,
"chapters": [
{"title": c.title, "start_fraction": c.start_fraction, "end_fraction": c.end_fraction}
for c in chapters
],
}


# ── Position history (restore a bad sync) ─────────────────────────────────────

def _visible_book_or_404(db: Session, current_user: User, book_id: int) -> Book:
book = db.get(Book, book_id)
if not book or book.status != "active" or not user_can_see_book(db, current_user, book):
raise HTTPException(status_code=404, detail="Book not found")
return book


@router.get("/{book_id}/position-history")
def get_position_history(
book_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""The user's recent position log for this book, newest first, plus the
live position — the recovery UI for a bad sync (a device jumping a book
to 100% is otherwise unrecoverable)."""
from backend.models.tome_sync import PositionHistory, TomeSyncPosition

_visible_book_or_404(db, current_user, book_id)
current = (
db.query(TomeSyncPosition)
.filter(TomeSyncPosition.user_id == current_user.id,
TomeSyncPosition.book_id == book_id)
.first()
)
rows = (
db.query(PositionHistory)
.filter(PositionHistory.user_id == current_user.id,
PositionHistory.book_id == book_id)
.order_by(PositionHistory.id.desc())
.all()
)
return {
"current": {
"percentage": current.percentage,
"device": current.device,
"updated_at": current.updated_at.isoformat() + "Z",
} if current else None,
"history": [
{
"id": r.id,
"percentage": r.percentage,
"device": r.device,
"created_at": r.created_at.isoformat() + "Z",
}
for r in rows
],
}


@router.post("/{book_id}/position-history/{history_id}/restore")
def restore_position(
book_id: int,
history_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Set the live position back to a logged entry. Devices pick it up on
their next pull (per their pull-conflict strategy); the restore itself is
logged too, so it can be undone the same way.

Unlike normal progress writes, a restore is an explicit override of the
sticky-completion rule: recovering from a device that falsely jumped a
book to 100% must also un-finish it, or the position comes back but the
book stays "read"."""
from datetime import datetime
from backend.models.tome_sync import PositionHistory
from backend.models.user_book_status import UserBookStatus
from backend.services.book_progress import upsert_position

_visible_book_or_404(db, current_user, book_id)
entry = db.get(PositionHistory, history_id)
if not entry or entry.user_id != current_user.id or entry.book_id != book_id:
raise HTTPException(status_code=404, detail="History entry not found")

upsert_position(
db, user_id=current_user.id, book_id=book_id,
percentage=entry.percentage, progress=entry.progress, device="restore",
)
status_row = (
db.query(UserBookStatus)
.filter(UserBookStatus.user_id == current_user.id,
UserBookStatus.book_id == book_id)
.first()
)
if status_row is None:
status_row = UserBookStatus(user_id=current_user.id, book_id=book_id)
db.add(status_row)
status_row.progress_pct = entry.percentage
status_row.cfi = entry.progress
if entry.percentage >= 0.99:
status_row.status = "read"
if status_row.finished_at is None:
status_row.finished_at = datetime.utcnow()
else:
if status_row.status == "read":
status_row.finished_at = None
status_row.status = "reading" if entry.percentage > 0 else "unread"
db.commit()
return {"ok": True, "percentage": entry.percentage, "status": status_row.status}


# ── Per-book reading stats ────────────────────────────────────────────────────

@router.get("/{book_id}/reading-stats")
Expand Down
Loading