Skip to content
Merged
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,70 @@ 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
- **One-click instance backup and staged restore.** Admin → Server gains an
Instance backup card: download a consistent snapshot of everything Tome
knows (database + covers + manifest; book files stay on disk), and restore
one by uploading it — the restore is validated, requires typing RESTORE,
and applies at the next server restart so it never happens under a live
database. The previous database is kept alongside as a safety copy.
- **Import your Goodreads or StoryGraph history.** Settings → Import reading
history takes either service's CSV export, matches it against your library
(ISBN first, then title/author), and shows a full preview before anything
is written. Importing only fills gaps — statuses, ratings, reviews, and
finish dates you already have are never overwritten — and "to read"
shelves are skipped. Pre-Tome reading finally lands on the lifetime
timeline.
- **Push notifications to ntfy, Gotify, or any webhook.** Settings →
Notifications lets each user add outbound channels; every in-app
notification (wish fulfilled, new volume detected, reading goals) is
pushed the moment it happens instead of waiting for the next visit. Each
channel has a test button, can be paused, and tokens are stored
server-side only. `TOME_OUTBOUND_NOTIFY=false` switches the whole feature
off; nothing is sent unless a user configures a channel.
- **"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
99 changes: 99 additions & 0 deletions backend/api/admin_backup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Admin instance backup/restore endpoints.

Download is a live consistent snapshot; restore is validate-and-stage — the
actual swap happens at the next server start (see services/instance_backup).
"""
from __future__ import annotations

import logging
import os
import shutil
import tempfile
import time
from pathlib import Path

from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse

from backend import __version__
from backend.core.config import settings
from backend.core.permissions import is_admin
from backend.core.security import get_current_user
from backend.models.user import User
from backend.services.instance_backup import (
create_backup_tarball,
staged_path,
validate_backup,
)

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


def _require_admin(user: User) -> None:
if not is_admin(user):
raise HTTPException(status_code=403, detail="Admin only")


@router.get("/download")
def download_backup(
background: BackgroundTasks,
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
path = create_backup_tarball(settings.db_path, settings.covers_dir, __version__)
background.add_task(lambda: path.unlink(missing_ok=True))
stamp = time.strftime("%Y%m%d-%H%M")
return FileResponse(
path,
media_type="application/gzip",
filename=f"tome-backup-{stamp}.tar.gz",
)


@router.get("/restore")
def restore_status(current_user: User = Depends(get_current_user)) -> dict:
_require_admin(current_user)
staged = staged_path(settings.data_dir)
if not staged.is_file():
return {"staged": False}
try:
summary = validate_backup(staged)
except ValueError:
return {"staged": True, "summary": None}
return {"staged": True, "summary": summary}


@router.post("/restore")
def stage_restore(
file: UploadFile = File(...),
confirm: str = Form(""),
current_user: User = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
if confirm != "RESTORE":
raise HTTPException(status_code=422, detail='Type RESTORE to confirm')

tmp = Path(tempfile.mkstemp(prefix="tome-restore-upload-", suffix=".tar.gz")[1])
try:
with tmp.open("wb") as out:
shutil.copyfileobj(file.file, out, length=1024 * 1024)
try:
summary = validate_backup(tmp)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
target = staged_path(settings.data_dir)
shutil.move(str(tmp), str(target))
finally:
tmp.unlink(missing_ok=True)
log.warning("Restore staged by %s: %s", current_user.username, summary)
return {"staged": True, "requires_restart": True, "summary": summary}


@router.delete("/restore")
def unstage_restore(current_user: User = Depends(get_current_user)) -> dict:
_require_admin(current_user)
staged = staged_path(settings.data_dir)
existed = staged.is_file()
staged.unlink(missing_ok=True)
return {"ok": True, "was_staged": existed}
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}
Loading