diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7bc3f..4ce0aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile index cdbdec8..c00cc49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/backend/api/admin_backup.py b/backend/api/admin_backup.py new file mode 100644 index 0000000..44a9263 --- /dev/null +++ b/backend/api/admin_backup.py @@ -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} diff --git a/backend/api/admin_covers.py b/backend/api/admin_covers.py new file mode 100644 index 0000000..2cd81f4 --- /dev/null +++ b/backend/api/admin_covers.py @@ -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} diff --git a/backend/api/books.py b/backend/api/books.py index 70e7329..2ee61c5 100644 --- a/backend/api/books.py +++ b/backend/api/books.py @@ -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") diff --git a/backend/api/meta.py b/backend/api/meta.py new file mode 100644 index 0000000..25aa8f4 --- /dev/null +++ b/backend/api/meta.py @@ -0,0 +1,144 @@ +"""App metadata: What's-New changelog excerpts and the update check. + +Both are deliberately small and self-contained: +- /meta/whats-new parses CHANGELOG.md for the section matching the running + version (falling back to [Unreleased] on dev builds) so the frontend can show + a one-time "what changed" panel after an upgrade. +- /meta/update-check compares the running version against the latest GitHub + release, cached in memory for a day. Admin-only, and TOME_UPDATE_CHECK=false + turns it off entirely — nothing else phones home. +""" +import json +import logging +import re +import threading +import time +import urllib.request +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException + +from backend import __version__ +from backend.core.config import settings +from backend.core.security import get_current_user +from backend.models.user import User + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/meta", tags=["meta"]) + +GITHUB_RELEASES_API = "https://api.github.com/repos/bndct-devops/tome/releases/latest" +GITHUB_RELEASES_URL = "https://github.com/bndct-devops/tome/releases" + +_CHANGELOG = Path(__file__).resolve().parents[2] / "CHANGELOG.md" + + +def parse_changelog_section(text: str, version: str) -> list[dict]: + """Entries of the ``## [version]`` section (or ``## [Unreleased]`` if that + version has no section — dev builds). Each ``- **Title.** body`` bullet + becomes {kind, title, body}; continuation lines fold into the body.""" + lines = text.splitlines() + section: list[str] = [] + in_section = False + for line in lines: + m = re.match(r"^## \[([^\]]+)\]", line) + if m: + if in_section: + break + in_section = m.group(1) == version + continue + if in_section: + section.append(line) + if not section and version != "Unreleased": + return parse_changelog_section(text, "Unreleased") + + entries: list[dict] = [] + kind = "" + for line in section: + km = re.match(r"^### (.+)$", line) + if km: + kind = km.group(1).strip() + continue + bm = re.match(r"^- (.+)$", line) + if bm: + entries.append({"kind": kind, "text": bm.group(1).strip()}) + elif line.startswith(" ") and entries: + entries[-1]["text"] += " " + line.strip() + # Split the conventional "**Title.** body" lead into title/body. + for e in entries: + raw = e.pop("text") + tm = re.match(r"^\*\*(.+?)\*\*\s*(.*)$", raw) + if tm: + e["title"], e["body"] = tm.group(1).strip(), tm.group(2).strip() + else: + e["title"], e["body"] = "", raw + return entries + + +@router.get("/whats-new") +def whats_new(current_user: User = Depends(get_current_user)) -> dict: + try: + text = _CHANGELOG.read_text(encoding="utf-8") + except OSError: + return {"version": __version__, "entries": []} + return {"version": __version__, "entries": parse_changelog_section(text, __version__)} + + +# ── Update check ────────────────────────────────────────────────────────────── + +_cache_lock = threading.Lock() +_cache: dict = {"at": 0.0, "result": None, "ttl": 0.0} +_TTL_OK = 24 * 3600.0 # successful lookups: once a day is plenty +_TTL_FAIL = 3600.0 # failures: retry hourly, don't hammer while offline + + +def _fetch_latest_release() -> str | None: + """Latest release tag from GitHub ('1.9.0', no leading v), or None.""" + req = urllib.request.Request( + GITHUB_RELEASES_API, + headers={"Accept": "application/vnd.github+json", "User-Agent": "tome-update-check"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + tag = json.loads(resp.read().decode()).get("tag_name") or "" + return tag.lstrip("v") or None + + +def _ver_tuple(v: str) -> tuple: + parts = [] + for p in re.split(r"[.\-+]", v): + parts.append(int(p) if p.isdigit() else 0) + return tuple(parts or [0]) + + +@router.get("/update-check") +def update_check(current_user: User = Depends(get_current_user)) -> dict: + from backend.core.permissions import is_admin + + if not is_admin(current_user): + raise HTTPException(status_code=403, detail="Admin only") + if not settings.update_check: + return {"enabled": False, "current": __version__, + "latest": None, "update_available": False, "url": GITHUB_RELEASES_URL} + + now = time.time() + with _cache_lock: + if _cache["result"] is not None and now - _cache["at"] < _cache["ttl"]: + return _cache["result"] + + latest: str | None = None + try: + latest = _fetch_latest_release() + ttl = _TTL_OK + except Exception as exc: # noqa: BLE001 — network failures are expected offline + log.info("update-check: GitHub lookup failed: %s", exc) + ttl = _TTL_FAIL + + result = { + "enabled": True, + "current": __version__, + "latest": latest, + "update_available": bool(latest) and _ver_tuple(latest) > _ver_tuple(__version__), + "url": GITHUB_RELEASES_URL, + } + with _cache_lock: + _cache.update(at=now, result=result, ttl=ttl) + return result diff --git a/backend/api/notifications.py b/backend/api/notifications.py index 4acada3..607c22e 100644 --- a/backend/api/notifications.py +++ b/backend/api/notifications.py @@ -13,7 +13,7 @@ from backend.core.database import get_db from backend.core.security import get_current_user -from backend.models.notification import Notification +from backend.models.notification import Notification, NotificationChannel from backend.models.user import User router = APIRouter(tags=["notifications"]) @@ -82,3 +82,114 @@ def mark_all_notifications_read( ).update({"read": True}) db.commit() return {"ok": True} + + +# ── Outbound channels (ntfy / Gotify / webhook) ─────────────────────────────── + +CHANNEL_KINDS = ("ntfy", "gotify", "webhook") + + +class ChannelIn(BaseModel): + kind: str + url: str + token: Optional[str] = None + + +class ChannelOut(BaseModel): + id: int + kind: str + url: str + has_token: bool + enabled: bool + + +def _channel_out(c: NotificationChannel) -> ChannelOut: + return ChannelOut(id=c.id, kind=c.kind, url=c.url, + has_token=bool(c.token), enabled=c.enabled) + + +@router.get("/notification-channels", response_model=list[ChannelOut]) +def list_channels( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + rows = ( + db.query(NotificationChannel) + .filter(NotificationChannel.user_id == current_user.id) + .order_by(NotificationChannel.id) + .all() + ) + return [_channel_out(c) for c in rows] + + +@router.post("/notification-channels", response_model=ChannelOut, status_code=201) +def create_channel( + body: ChannelIn, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if body.kind not in CHANNEL_KINDS: + raise HTTPException(status_code=422, detail=f"kind must be one of {CHANNEL_KINDS}") + url = body.url.strip() + if not (url.startswith("http://") or url.startswith("https://")): + raise HTTPException(status_code=422, detail="url must be http(s)") + c = NotificationChannel(user_id=current_user.id, kind=body.kind, + url=url, token=(body.token or None)) + db.add(c) + db.commit() + db.refresh(c) + return _channel_out(c) + + +@router.delete("/notification-channels/{channel_id}") +def delete_channel( + channel_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + c = db.get(NotificationChannel, channel_id) + if not c or c.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Channel not found") + db.delete(c) + db.commit() + return {"ok": True} + + +@router.post("/notification-channels/{channel_id}/toggle", response_model=ChannelOut) +def toggle_channel( + channel_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + c = db.get(NotificationChannel, channel_id) + if not c or c.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Channel not found") + c.enabled = not c.enabled + db.commit() + db.refresh(c) + return _channel_out(c) + + +@router.post("/notification-channels/{channel_id}/test") +def test_channel( + channel_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Send a test notification synchronously and report the outcome — the + only way to debug a typo'd topic URL at setup time.""" + from backend.services.outbound_notifications import deliver + + c = db.get(NotificationChannel, channel_id) + if not c or c.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Channel not found") + try: + deliver(c.kind, c.url, c.token, { + "kind": "test", + "title": "Tome test notification", + "body": "Your channel is wired up correctly.", + "link": "/", + }) + except Exception as exc: # noqa: BLE001 — surfaced to the user, not hidden + return {"ok": False, "error": str(exc)[:300]} + return {"ok": True} diff --git a/backend/api/reading_import.py b/backend/api/reading_import.py new file mode 100644 index 0000000..54c15e6 --- /dev/null +++ b/backend/api/reading_import.py @@ -0,0 +1,136 @@ +"""Reading-history CSV import (Goodreads / StoryGraph) — preview + apply. + +Stateless two-step: POST the file for a matched preview, then POST the +selected proposals to apply. Applying is FILL-GAPS-ONLY: it never overwrites +an existing status, rating, review, or finish date — an import can't clobber +live sync state or curation (same philosophy as the TomeSync library sweep). +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.core.database import get_db +from backend.core.permissions import book_visibility_filter +from backend.core.security import get_current_user +from backend.models.book import Book +from backend.models.user import User +from backend.models.user_book_status import UserBookStatus +from backend.services.reading_import import match_rows, parse_csv + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/import", tags=["import"]) + + +@router.post("/reading-csv") +def preview_reading_csv( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + data = file.file.read(20 * 1024 * 1024) + try: + dialect, rows, skipped_unread = parse_csv(data) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + books = ( + db.query(Book) + .filter(Book.status == "active", book_visibility_filter(db, current_user)) + .all() + ) + matched, unmatched = match_rows(rows, books) + + # Annotate what applying would actually do (fill-gaps rule) so the + # preview is honest about no-ops. + existing = { + s.book_id: s + for s in db.query(UserBookStatus).filter( + UserBookStatus.user_id == current_user.id, + UserBookStatus.book_id.in_([m["book_id"] for m in matched] or [0]), + ) + } + for m in matched: + s = existing.get(m["book_id"]) + m["will_apply"] = { + "status": s is None or (s.status or "unread") == "unread", + "rating": m["rating"] is not None and (s is None or s.rating is None), + "finished_on": m["finished_on"] is not None and (s is None or s.finished_at is None), + "review": m["review"] is not None and (s is None or not s.review), + } + + return { + "dialect": dialect, + "matched": matched, + "unmatched": unmatched, + "skipped_unread": skipped_unread, + } + + +class ApplyItem(BaseModel): + book_id: int + status: str # read | reading + rating: Optional[float] = None + finished_on: Optional[str] = None # ISO date + review: Optional[str] = None + + +class ApplyRequest(BaseModel): + items: list[ApplyItem] + + +@router.post("/reading-csv/apply") +def apply_reading_csv( + body: ApplyRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + if len(body.items) > 5000: + raise HTTPException(status_code=422, detail="Too many items") + visible = { + bid for (bid,) in db.query(Book.id).filter( + Book.status == "active", + Book.id.in_([i.book_id for i in body.items] or [0]), + book_visibility_filter(db, current_user), + ) + } + applied = {"status": 0, "rating": 0, "finished_on": 0, "review": 0} + skipped = 0 + for item in body.items: + if item.book_id not in visible or item.status not in ("read", "reading"): + skipped += 1 + continue + row = ( + db.query(UserBookStatus) + .filter(UserBookStatus.user_id == current_user.id, + UserBookStatus.book_id == item.book_id) + .first() + ) + if row is None: + row = UserBookStatus(user_id=current_user.id, book_id=item.book_id) + db.add(row) + if (row.status or "unread") == "unread": + row.status = item.status + if item.status == "read" and row.progress_pct is None: + row.progress_pct = 1.0 + applied["status"] += 1 + if item.rating is not None and row.rating is None: + row.rating = max(0.5, min(5.0, float(item.rating))) + row.rated_at = datetime.utcnow() + applied["rating"] += 1 + if item.finished_on and row.finished_at is None: + try: + row.finished_at = datetime.fromisoformat(item.finished_on) + applied["finished_on"] += 1 + except ValueError: + pass + if item.review and not row.review: + row.review = item.review[:10000] + applied["review"] += 1 + db.commit() + return {"ok": True, "applied": applied, "skipped": skipped} diff --git a/backend/core/config.py b/backend/core/config.py index 7666d46..11cfd68 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -40,6 +40,16 @@ class Settings(BaseSettings): smtp_use_ssl: bool = False smtp_daily_limit: int = 50 + # Outbound notifications (env TOME_OUTBOUND_NOTIFY): fan in-app + # notifications out to each user's configured ntfy/Gotify/webhook + # channels. Off = the kill-switch; per-user channels also toggle. + outbound_notify: bool = True + + # Update awareness (env TOME_UPDATE_CHECK): lets admins see "new version + # available" in Settings via a daily-cached GitHub releases lookup. Set + # false to never call out (air-gapped installs); nothing else phones home. + update_check: bool = True + # Auto-import settings auto_import: bool = False auto_import_interval: int = 300 # seconds diff --git a/backend/core/database.py b/backend/core/database.py index 3ccc276..015eb0c 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -27,6 +27,11 @@ def _set_wal_mode(dbapi_connection, connection_record): def create_db_engine(): settings.ensure_dirs() + # A staged instance-restore swaps the DB in here — before any engine or + # connection exists — so a live pool never sees the file change underneath + # it. No staging file = no-op. + from backend.services.instance_backup import apply_staged_restore_if_present + apply_staged_restore_if_present(settings.data_dir, settings.db_path, settings.covers_dir) # NullPool: a fresh SQLite connection per checkout, closed on release. # SQLite connections are essentially free (no network handshake) and WAL # mode lets many readers run concurrently — so pooling buys nothing and a diff --git a/backend/main.py b/backend/main.py index 8dd0113..d068373 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,11 @@ from backend.api import kosync from backend.api import tome_sync from backend.api import stats +from backend.api import meta as meta_api +from backend.api import admin_covers +from backend.api import reading_import +from backend.api import admin_backup +from backend.services import outbound_notifications # noqa: F401 (registers ORM fanout listeners) from backend.api import quick_connect from backend.api import admin_duplicates from backend.api import word_count as word_count_api @@ -733,6 +738,10 @@ def create_app() -> FastAPI: app.include_router(notifications_api.router, prefix="/api") app.include_router(oidc_api.router, prefix="/api") app.include_router(goals_api.router, prefix="/api") + app.include_router(meta_api.router, prefix="/api") + app.include_router(admin_covers.router, prefix="/api") + app.include_router(reading_import.router, prefix="/api") + app.include_router(admin_backup.router, prefix="/api") app.include_router(annotations_api.router, prefix="/api") # Serve frontend static files in production (SPA fallback) diff --git a/backend/models/notification.py b/backend/models/notification.py index 3c34ba6..6a0231f 100644 --- a/backend/models/notification.py +++ b/backend/models/notification.py @@ -37,3 +37,27 @@ class Notification(Base): ) user: Mapped["User"] = relationship("User", back_populates="notifications") + + +class NotificationChannel(Base): + """A per-user outbound delivery target for notifications. + + The in-app bell only fires when the user visits; a channel pushes the same + events to ntfy, Gotify, or a plain webhook the moment they happen. Purely + additive: rows here never affect in-app behaviour.""" + __tablename__ = "notification_channels" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + kind: Mapped[str] = mapped_column(String(16), nullable=False) # ntfy | gotify | webhook + url: Mapped[str] = mapped_column(String(1024), nullable=False) + # ntfy: optional bearer token; gotify: the app token; webhook: unused. + token: Mapped[Optional[str]] = mapped_column(String(512)) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False + ) + + user: Mapped["User"] = relationship("User") diff --git a/backend/models/tome_sync.py b/backend/models/tome_sync.py index b403081..992b976 100644 --- a/backend/models/tome_sync.py +++ b/backend/models/tome_sync.py @@ -90,6 +90,35 @@ class TomeSyncPosition(Base): book: Mapped["Book"] = relationship("Book") # type: ignore[name-defined] +class PositionHistory(Base): + """Append-only log of meaningful position changes per user+book. + + The safety net under position sync: one bad write (a device jumping a book + to 100%, a mis-tapped chapter skip that propagated) is otherwise + unrecoverable because TomeSyncPosition is a single overwritten row. Written + by upsert_position only when the position moved meaningfully (so the + ten-page heartbeat doesn't spam near-duplicates), pruned to the newest + HISTORY_KEEP entries per (user, book).""" + __tablename__ = "position_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + book_id: Mapped[int] = mapped_column( + Integer, ForeignKey("books.id", ondelete="CASCADE"), nullable=False, index=True + ) + progress: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # CFI or page ref + percentage: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + device: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False, index=True + ) + + user: Mapped["User"] = relationship("User") # type: ignore[name-defined] + book: Mapped["Book"] = relationship("Book") # type: ignore[name-defined] + + class Annotation(Base): """A highlight (and optional note) synced from KOReader, per user+book. diff --git a/backend/services/book_progress.py b/backend/services/book_progress.py index d3cedab..befc1bd 100644 --- a/backend/services/book_progress.py +++ b/backend/services/book_progress.py @@ -22,9 +22,38 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from backend.models.tome_sync import TomeSyncPosition +from backend.models.tome_sync import PositionHistory, TomeSyncPosition from backend.models.user_book_status import UserBookStatus +# Position history: record a new entry only when the position moved at least +# this much (or the locator string changed) — the device heartbeat re-PUTs +# every few pages and would otherwise fill the log with near-duplicates. +HISTORY_MIN_DELTA = 0.002 +# Newest entries kept per (user, book); pruned on insert. +HISTORY_KEEP = 40 + + +def _record_history( + db: Session, *, user_id: int, book_id: int, + percentage: float, progress: Optional[str], device: Optional[str], +) -> None: + db.add(PositionHistory( + user_id=user_id, book_id=book_id, + percentage=percentage, progress=progress, device=device, + )) + # Prune beyond the cap — ids beat created_at for same-second inserts. + stale = ( + db.query(PositionHistory.id) + .filter(PositionHistory.user_id == user_id, PositionHistory.book_id == book_id) + .order_by(PositionHistory.id.desc()) + .offset(HISTORY_KEEP) + .all() + ) + if stale: + db.query(PositionHistory).filter( + PositionHistory.id.in_([s.id for s in stale]) + ).delete(synchronize_session=False) + def upsert_position( db: Session, @@ -58,6 +87,8 @@ def upsert_position( with db.begin_nested(): db.add(row) db.flush() + _record_history(db, user_id=user_id, book_id=book_id, + percentage=percentage, progress=progress, device=device) return row except IntegrityError: # Another writer created the row between our SELECT and INSERT. @@ -70,10 +101,17 @@ def upsert_position( if row is None: raise + moved = ( + abs(percentage - (row.percentage or 0.0)) >= HISTORY_MIN_DELTA + or (progress or "") != (row.progress or "") + ) row.percentage = percentage row.progress = progress row.device = device row.updated_at = datetime.utcnow() + if moved: + _record_history(db, user_id=user_id, book_id=book_id, + percentage=percentage, progress=progress, device=device) return row diff --git a/backend/services/instance_backup.py b/backend/services/instance_backup.py new file mode 100644 index 0000000..d11d648 --- /dev/null +++ b/backend/services/instance_backup.py @@ -0,0 +1,143 @@ +"""Instance backup & restore — the whole curation in one tarball. + +Backup: a consistent SQLite snapshot (sqlite3 backup API — safe under WAL, +never a raw copy of a live DB), the cover cache, and a manifest. Library +files are NOT included: Tome references them on disk and they are the +operator's own data to back up. + +Restore is two-phase by design: the upload is validated (tar shape, +manifest, the DB actually opens and passes integrity_check) and STAGED next +to the data dir; the swap happens at the next server start, before the +engine exists — never under a live connection pool. The current DB is kept +as tome.db.pre-restore- so even a restored-the-wrong-file mistake is +recoverable. Any failure during apply leaves the current DB untouched and +parks the staging file as .failed. +""" +from __future__ import annotations + +import json +import logging +import shutil +import sqlite3 +import tarfile +import tempfile +import time +from pathlib import Path + +log = logging.getLogger(__name__) + +STAGED_NAME = "restore_staged.tar.gz" +_MEMBER_DB = "tome.db" +_MEMBER_MANIFEST = "manifest.json" +_MEMBER_COVERS = "covers" + + +def create_backup_tarball(db_path: Path, covers_dir: Path, version: str) -> Path: + """Build the tarball in a temp file and return its path (caller deletes).""" + tmp_dir = Path(tempfile.mkdtemp(prefix="tome-backup-")) + snap = tmp_dir / _MEMBER_DB + # Consistent snapshot even mid-write: the sqlite backup API copies a + # transactionally coherent image, which a plain file copy of a WAL db is not. + src = sqlite3.connect(str(db_path)) + try: + dst = sqlite3.connect(str(snap)) + try: + src.backup(dst) + finally: + dst.close() + finally: + src.close() + + manifest = { + "app": "tome", + "version": version, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + out = Path(tempfile.mkstemp(prefix="tome-backup-", suffix=".tar.gz")[1]) + with tarfile.open(out, "w:gz") as tar: + tar.add(snap, arcname=_MEMBER_DB) + mpath = tmp_dir / _MEMBER_MANIFEST + mpath.write_text(json.dumps(manifest, indent=2)) + tar.add(mpath, arcname=_MEMBER_MANIFEST) + if covers_dir.is_dir(): + tar.add(covers_dir, arcname=_MEMBER_COVERS) + shutil.rmtree(tmp_dir, ignore_errors=True) + return out + + +def validate_backup(path: Path) -> dict: + """Raise ValueError if the archive isn't a healthy Tome backup; else a + summary dict {version, created_at, users, books}.""" + try: + with tarfile.open(path, "r:gz") as tar: + names = set(tar.getnames()) + if _MEMBER_DB not in names or _MEMBER_MANIFEST not in names: + raise ValueError("Not a Tome backup (missing tome.db or manifest.json)") + manifest = json.loads(tar.extractfile(_MEMBER_MANIFEST).read().decode()) + if manifest.get("app") != "tome": + raise ValueError("Not a Tome backup (manifest mismatch)") + with tempfile.TemporaryDirectory(prefix="tome-restore-check-") as td: + tar.extract(_MEMBER_DB, td) + db_file = Path(td) / _MEMBER_DB + con = sqlite3.connect(str(db_file)) + try: + ok = con.execute("PRAGMA integrity_check").fetchone()[0] + if ok != "ok": + raise ValueError(f"Database failed integrity check: {ok}") + users = con.execute("SELECT COUNT(*) FROM users").fetchone()[0] + books = con.execute("SELECT COUNT(*) FROM books").fetchone()[0] + finally: + con.close() + except (tarfile.TarError, OSError, json.JSONDecodeError, sqlite3.DatabaseError) as exc: + raise ValueError(f"Unreadable backup archive: {exc}") from exc + return { + "version": manifest.get("version"), + "created_at": manifest.get("created_at"), + "users": users, + "books": books, + } + + +def staged_path(data_dir: Path) -> Path: + return data_dir / STAGED_NAME + + +def apply_staged_restore_if_present(data_dir: Path, db_path: Path, covers_dir: Path) -> bool: + """Called at startup BEFORE the engine exists. Returns True if a restore + was applied. Failure never touches the current DB.""" + staged = staged_path(data_dir) + if not staged.is_file(): + return False + log.warning("Staged restore found — applying %s", staged) + try: + summary = validate_backup(staged) + ts = time.strftime("%Y%m%d-%H%M%S") + if db_path.exists(): + shutil.copy2(db_path, db_path.with_name(f"{db_path.name}.pre-restore-{ts}")) + with tarfile.open(staged, "r:gz") as tar: + with tempfile.TemporaryDirectory(prefix="tome-restore-") as td: + tar.extract(_MEMBER_DB, td) + # Replace the DB and drop stale WAL/SHM sidecars from the old one. + shutil.move(str(Path(td) / _MEMBER_DB), str(db_path)) + for suffix in ("-wal", "-shm"): + side = db_path.with_name(db_path.name + suffix) + side.unlink(missing_ok=True) + cover_members = [m for m in tar.getmembers() + if m.name.startswith(_MEMBER_COVERS + "/") and m.isfile() + and ".." not in m.name] + if cover_members: + covers_dir.mkdir(parents=True, exist_ok=True) + for m in cover_members: + m.name = m.name[len(_MEMBER_COVERS) + 1:] + tar.extract(m, covers_dir) + staged.unlink(missing_ok=True) + log.warning("Restore applied: %s users, %s books (backup from %s)", + summary["users"], summary["books"], summary["created_at"]) + return True + except Exception: # noqa: BLE001 — park it, never brick startup + log.exception("Staged restore FAILED — keeping current database") + try: + staged.rename(staged.with_suffix(".failed")) + except OSError: + pass + return False diff --git a/backend/services/outbound_notifications.py b/backend/services/outbound_notifications.py new file mode 100644 index 0000000..6912e89 --- /dev/null +++ b/backend/services/outbound_notifications.py @@ -0,0 +1,137 @@ +"""Outbound notification fanout — ntfy / Gotify / plain webhook. + +The in-app bell only fires when the user visits the web UI; users who run +ntfy/Gotify want the same events pushed the moment they happen. Zero changes +at the six Notification creation sites: an ORM event collects freshly +inserted notifications on the session, and after the transaction COMMITS the +payloads are handed to a daemon thread that loads the owners' enabled +channels and POSTs to each (5s timeout, best-effort, no retries — a missed +push is not worth blocking or crashing anything). + +TOME_OUTBOUND_NOTIFY=false is the operator kill-switch; per-user channels +also have their own enabled flag. +""" +from __future__ import annotations + +import json +import logging +import threading +import urllib.parse +import urllib.request +from typing import Any + +from sqlalchemy import event +from sqlalchemy.orm import Session, object_session + +from backend.core.config import settings +from backend.models.notification import Notification, NotificationChannel + +log = logging.getLogger(__name__) + +_PENDING_KEY = "pending_outbound_notifications" + + +@event.listens_for(Notification, "after_insert") +def _collect_inserted(mapper, connection, target: Notification) -> None: # noqa: ARG001 + sess = object_session(target) + if sess is None: + return + sess.info.setdefault(_PENDING_KEY, []).append({ + "user_id": target.user_id, + "kind": target.kind, + "title": target.title, + "body": target.body, + "link": target.link, + }) + + +@event.listens_for(Session, "after_commit") +def _dispatch_after_commit(session: Session) -> None: + pending = session.info.pop(_PENDING_KEY, None) + if not pending or not settings.outbound_notify: + return + _spawn(pending) + + +def _spawn(payloads: list[dict]) -> None: + """Hand off to a daemon thread. Split out so tests can run it inline.""" + threading.Thread(target=_send_all, args=(payloads,), daemon=True).start() + + +def _session_factory() -> Session: + """Fresh session for the worker thread (injectable for tests, which run + on an in-memory engine the app-level SessionLocal knows nothing about).""" + from backend.core.database import SessionLocal + + return SessionLocal() + + +def _send_all(payloads: list[dict]) -> None: + db = _session_factory() + try: + user_ids = {p["user_id"] for p in payloads} + channels = ( + db.query(NotificationChannel) + .filter(NotificationChannel.user_id.in_(user_ids), + NotificationChannel.enabled == True) # noqa: E712 + .all() + ) + by_user: dict[int, list[NotificationChannel]] = {} + for c in channels: + by_user.setdefault(c.user_id, []).append(c) + for p in payloads: + for c in by_user.get(p["user_id"], []): + try: + deliver(c.kind, c.url, c.token, p) + except Exception as exc: # noqa: BLE001 — best-effort by design + log.info("outbound notify: %s -> %s failed: %s", p["kind"], c.kind, exc) + finally: + db.close() + + +def deliver(kind: str, url: str, token: str | None, payload: dict[str, Any]) -> None: + """Send one notification to one channel. Raises on failure (caller logs; + the Settings test button surfaces the error to the user).""" + title = payload.get("title") or "Tome" + body = payload.get("body") or "" + link = payload.get("link") + if kind == "ntfy": + headers = {"Title": title.encode("ascii", "replace").decode()} + if link: + headers["Click"] = _absolute(link) + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST") + elif kind == "gotify": + msg_url = url.rstrip("/") + "/message?token=" + urllib.parse.quote(token or "") + req = urllib.request.Request( + msg_url, + data=json.dumps({"title": title, "message": body or title, "priority": 5}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + elif kind == "webhook": + req = urllib.request.Request( + url, + data=json.dumps({ + "event": payload.get("kind"), + "title": title, + "body": body, + "link": _absolute(link) if link else None, + }).encode(), + headers={"Content-Type": "application/json", "User-Agent": "tome-notify"}, + method="POST", + ) + else: + raise ValueError(f"unknown channel kind: {kind}") + with urllib.request.urlopen(req, timeout=5): + pass + + +def _absolute(link: str) -> str: + """In-app links are site-relative; prefix the public origin when known.""" + if link.startswith("http://") or link.startswith("https://"): + return link + base = (settings.public_url or "").rstrip("/") + return f"{base}{link}" if base else link + diff --git a/backend/services/reading_import.py b/backend/services/reading_import.py new file mode 100644 index 0000000..3eee144 --- /dev/null +++ b/backend/services/reading_import.py @@ -0,0 +1,165 @@ +"""Goodreads / StoryGraph CSV import — reading history for books Tome holds. + +Two-step, stateless flow: + parse_csv() -> dialect-detected rows (read / currently-reading only) + match_rows() -> proposals against the caller's visible library + (ISBN first, then normalized title+author, then fuzzy title) +The API layer previews the proposals; applying is fill-gaps-only (see the +endpoint) so an import can never overwrite live sync state or curation. + +Only read/currently-reading rows are considered: "to read" piles belong to +the wishlist, not reading history — deliberately out of scope here. +""" +from __future__ import annotations + +import csv +import io +import re +from datetime import datetime +from difflib import SequenceMatcher +from typing import Optional + +MAX_ROWS = 5000 + +# Column names that identify each dialect (case-sensitive as exported). +_GOODREADS_MARKERS = {"Exclusive Shelf", "My Rating"} +_STORYGRAPH_MARKERS = {"Read Status", "Star Rating"} + + +class ImportRow(dict): + """title, author, isbn, status ('read'|'reading'), rating (float|None), + finished_on (date-iso|None), review (str|None), line (int)""" + + +def _clean_isbn(raw: str | None) -> Optional[str]: + if not raw: + return None + digits = re.sub(r"[^0-9Xx]", "", raw) + return digits.upper() or None + + +def _parse_date(raw: str | None) -> Optional[str]: + if not raw or not raw.strip(): + return None + for fmt in ("%Y/%m/%d", "%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"): + try: + return datetime.strptime(raw.strip(), fmt).date().isoformat() + except ValueError: + continue + return None + + +def _norm(s: str | None) -> str: + s = (s or "").lower() + # Drop subtitle/series decorations Goodreads loves: "Title (Series, #3)" + s = re.sub(r"\(.*?\)", " ", s) + s = re.sub(r"[^a-z0-9]+", " ", s) + return " ".join(s.split()) + + +def parse_csv(data: bytes) -> tuple[str, list[ImportRow], int]: + """-> (dialect, rows, skipped_unread). Raises ValueError on unknown shape.""" + text = data.decode("utf-8-sig", errors="replace") + reader = csv.DictReader(io.StringIO(text)) + headers = set(reader.fieldnames or []) + if _GOODREADS_MARKERS <= headers: + dialect = "goodreads" + elif _STORYGRAPH_MARKERS <= headers: + dialect = "storygraph" + else: + raise ValueError( + "Unrecognized CSV — expected a Goodreads library export or a StoryGraph export" + ) + + rows: list[ImportRow] = [] + skipped = 0 + for i, rec in enumerate(reader): + if i >= MAX_ROWS: + break + if dialect == "goodreads": + shelf = (rec.get("Exclusive Shelf") or "").strip().lower() + status = {"read": "read", "currently-reading": "reading"}.get(shelf) + if status is None: + skipped += 1 + continue + rating_raw = (rec.get("My Rating") or "0").strip() + rating = float(rating_raw) if rating_raw not in ("", "0") else None + rows.append(ImportRow( + title=(rec.get("Title") or "").strip(), + author=(rec.get("Author") or "").strip(), + isbn=_clean_isbn(rec.get("ISBN13") or rec.get("ISBN")), + status=status, + rating=rating, + finished_on=_parse_date(rec.get("Date Read")) if status == "read" else None, + review=(rec.get("My Review") or "").strip() or None, + line=i + 2, + )) + else: + rs = (rec.get("Read Status") or "").strip().lower() + status = {"read": "read", "currently-reading": "reading"}.get(rs) + if status is None: + skipped += 1 + continue + rating_raw = (rec.get("Star Rating") or "").strip() + rating = float(rating_raw) if rating_raw else None + rows.append(ImportRow( + title=(rec.get("Title") or "").strip(), + author=(rec.get("Authors") or rec.get("Author") or "").strip(), + isbn=_clean_isbn(rec.get("ISBN/UID") or rec.get("ISBN")), + status=status, + rating=rating, + finished_on=_parse_date(rec.get("Last Date Read")) if status == "read" else None, + review=(rec.get("Review") or "").strip() or None, + line=i + 2, + )) + return dialect, rows, skipped + + +def match_rows(rows: list[ImportRow], books: list) -> tuple[list[dict], list[dict]]: + """Match rows against Book ORM objects (already visibility-filtered). + + -> (matched, unmatched). matched: {row fields..., book_id, matched_title, + match_via}. First ISBN, then exact normalized title+author-overlap, then + fuzzy title (ratio >= 0.88) with author overlap.""" + by_isbn: dict[str, object] = {} + for b in books: + isbn = _clean_isbn(getattr(b, "isbn", None)) + if isbn: + by_isbn.setdefault(isbn, b) + normed = [(b, _norm(b.title), _norm(b.author)) for b in books] + + matched: list[dict] = [] + unmatched: list[dict] = [] + for row in rows: + book = None + via = None + if row["isbn"] and row["isbn"] in by_isbn: + book, via = by_isbn[row["isbn"]], "isbn" + if book is None: + nt, na = _norm(row["title"]), _norm(row["author"]) + na_tokens = set(na.split()) + for b, bt, ba in normed: + if bt == nt and (not na_tokens or na_tokens & set(ba.split())): + book, via = b, "title" + break + if book is None and nt: + best, best_ratio = None, 0.0 + for b, bt, ba in normed: + if na_tokens and not (na_tokens & set(ba.split())): + continue + ratio = SequenceMatcher(None, nt, bt).ratio() + if ratio > best_ratio: + best, best_ratio = b, ratio + if best is not None and best_ratio >= 0.88: + book, via = best, "fuzzy" + if book is None: + unmatched.append(dict(row)) + else: + matched.append({ + **row, + "book_id": book.id, + "matched_title": book.title, + "matched_author": book.author, + "match_via": via, + }) + return matched, unmatched diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx index 26b908d..4a3fbea 100644 --- a/frontend/src/components/AppHeader.tsx +++ b/frontend/src/components/AppHeader.tsx @@ -4,6 +4,8 @@ import { useAuth, isMember } from '@/contexts/AuthContext' import { TomeMark } from '@/components/TomeMark' import { SyncStatusBadge } from '@/components/SyncStatusBadge' import { NotificationBell } from '@/components/NotificationBell' +import { WhatsNewPanel } from '@/components/WhatsNewPanel' +import { CommandPalette } from '@/components/CommandPalette' /** * The one top navbar — menu button, wordmark, a search slot, and the right-side @@ -53,6 +55,11 @@ export function AppHeader({ onMenuClick, search, actions, onUploadClick }: { )} + {/* Post-upgrade release-notes panel — mounted here because both shell + variants (AppShell and DashboardPage) render this header. */} + + {/* Cmd+K palette — same reasoning: one mount that exists on every page. */} + ) } diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..0802cdf --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,260 @@ +// Cmd+K command palette: fuzzy jump to books, series, authors, and pages from +// anywhere. Books query the existing full-text search; series/authors filter +// the facets list client-side; actions are plain navigation (no side effects). +// Portals to (the header that mounts it has backdrop-blur, which would +// contain a fixed overlay — same lesson as WhatsNewPanel). +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useNavigate } from 'react-router-dom' +import { + BookOpen, ChartColumn, Compass, Layers, Library as LibraryIcon, + Quote, Search, Settings, Shield, Sparkles, User as UserIcon, +} from 'lucide-react' +import { api } from '@/lib/api' +import { useAuth, isAdmin } from '@/contexts/AuthContext' +import { cn } from '@/lib/utils' + +interface BookHit { + id: number + title: string + author: string | null + series: string | null + series_index: number | null +} + +interface Facets { + series: string[] + authors: string[] +} + +interface Item { + key: string + group: 'Actions' | 'Books' | 'Series' | 'Authors' + label: string + sub?: string + icon: React.ReactNode + to: string + bookId?: number +} + +const NAV_ACTIONS = (admin: boolean): { label: string; to: string; icon: React.ReactNode }[] => [ + { label: 'Home', to: '/', icon: }, + { label: 'All Books', to: '/?tab=books', icon: }, + { label: 'Series', to: '/?tab=series', icon: }, + { label: 'Reading Stats', to: '/stats', icon: }, + { label: 'Highlights', to: '/highlights', icon: }, + { label: 'Wishlist', to: '/wishlist', icon: }, + { label: 'Settings', to: '/settings', icon: }, + ...(admin ? [{ label: 'Admin', to: '/admin', icon: }] : []), +] + +/** startsWith beats includes; shorter names beat longer at equal rank. */ +function rankFilter(pool: string[], q: string, cap: number): string[] { + const lq = q.toLowerCase() + return pool + .map((s) => { + const ls = s.toLowerCase() + const rank = ls.startsWith(lq) ? 0 : ls.includes(lq) ? 1 : -1 + return { s, rank } + }) + .filter((x) => x.rank >= 0) + .sort((a, z) => a.rank - z.rank || a.s.length - z.s.length) + .slice(0, cap) + .map((x) => x.s) +} + +export function CommandPalette() { + const { user } = useAuth() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [books, setBooks] = useState([]) + const [facets, setFacets] = useState(null) + const [selected, setSelected] = useState(0) + const inputRef = useRef(null) + const debounceRef = useRef | null>(null) + + // Global hotkey — Cmd+K / Ctrl+K toggles, Esc closes. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + setOpen((o) => !o) + } else if (e.key === 'Escape') { + setOpen(false) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + // Focus + lazy facets on open; reset on close. + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 30) + if (!facets) { + api + .get<{ series: string[]; authors: string[] }>('/books/facets') + .then((f) => setFacets({ series: f.series, authors: f.authors })) + .catch(() => setFacets({ series: [], authors: [] })) + } + } else { + setQuery('') + setBooks([]) + setSelected(0) + } + }, [open, facets]) + + // Debounced book search. + useEffect(() => { + if (!open) return + if (debounceRef.current) clearTimeout(debounceRef.current) + if (!query.trim()) { + setBooks([]) + return + } + debounceRef.current = setTimeout(() => { + api + .get(`/books?q=${encodeURIComponent(query.trim())}&limit=6`) + .then((r) => setBooks(Array.isArray(r) ? r : [])) + .catch(() => setBooks([])) + }, 150) + }, [query, open]) + + const items = useMemo(() => { + const q = query.trim() + const out: Item[] = [] + const actions = NAV_ACTIONS(isAdmin(user)).filter( + (a) => !q || a.label.toLowerCase().includes(q.toLowerCase()), + ) + if (q) { + for (const b of books) { + out.push({ + key: `b${b.id}`, + group: 'Books', + label: b.series && b.series_index != null ? `${b.title}` : b.title, + sub: [b.series && `${b.series} #${b.series_index ?? '?'}`, b.author].filter(Boolean).join(' · '), + icon: , + to: `/books/${b.id}`, + bookId: b.id, + }) + } + for (const s of rankFilter(facets?.series ?? [], q, 4)) { + out.push({ + key: `s${s}`, + group: 'Series', + label: s, + icon: , + to: `/?tab=series&series_detail=${encodeURIComponent(s)}`, + }) + } + for (const a of rankFilter(facets?.authors ?? [], q, 4)) { + out.push({ + key: `a${a}`, + group: 'Authors', + label: a, + icon: , + to: `/?tab=books&author=${encodeURIComponent(a)}`, + }) + } + } + for (const a of actions) { + out.push({ key: `n${a.to}`, group: 'Actions', label: a.label, icon: a.icon, to: a.to }) + } + return out + }, [query, books, facets, user]) + + useEffect(() => setSelected(0), [items.length, query]) + + const go = useCallback( + (item: Item) => { + setOpen(false) + navigate(item.to) + }, + [navigate], + ) + + if (!open) return null + + const groups: Item['group'][] = ['Books', 'Series', 'Authors', 'Actions'] + + return createPortal( + <> +
setOpen(false)} /> +
+
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelected((s) => Math.min(s + 1, items.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelected((s) => Math.max(s - 1, 0)) + } else if (e.key === 'Enter' && items[selected]) { + e.preventDefault() + go(items[selected]) + } + }} + placeholder="Jump to a book, series, author, or page…" + className="h-12 w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + + esc + +
+
+ {items.length === 0 && ( +

No matches.

+ )} + {groups.map((g) => { + const groupItems = items.filter((i) => i.group === g) + if (!groupItems.length) return null + return ( +
+

+ {g} +

+ {groupItems.map((item) => { + const idx = items.indexOf(item) + return ( + + ) + })} +
+ ) + })} +
+
+
+ , + document.body, + ) +} diff --git a/frontend/src/components/CoverAudit.tsx b/frontend/src/components/CoverAudit.tsx new file mode 100644 index 0000000..ac4b00b --- /dev/null +++ b/frontend/src/components/CoverAudit.tsx @@ -0,0 +1,200 @@ +// Admin → Covers: which books have missing, unreadable, or low-resolution +// covers (GET /admin/covers/audit). Missing covers offer a one-click auto-fix +// that applies the first candidate from the existing cover search — safe +// because there is nothing to downgrade. Low-res covers deep-link to the book +// page instead, where the cover picker shows candidates to judge by eye. +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ExternalLink, ImageOff, Loader2, RefreshCw, Wand2 } from 'lucide-react' +import { api } from '@/lib/api' +import { cn } from '@/lib/utils' + +interface FlaggedBook { + book_id: number + title: string + author: string | null + series: string | null + series_index: number | null + reason: 'missing' | 'low_res' | 'unreadable' + width: number | null + height: number | null +} + +interface AuditResponse { + scanned: number + min_width: number + books: FlaggedBook[] +} + +type FixState = 'fixing' | 'fixed' | 'nocandidate' | 'failed' + +const REASON_LABEL: Record = { + missing: 'No cover', + low_res: 'Low resolution', + unreadable: 'Unreadable file', +} + +export function CoverAudit() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [fix, setFix] = useState>({}) + const [bulkRunning, setBulkRunning] = useState(false) + + const load = () => { + setLoading(true) + api + .get('/admin/covers/audit') + .then(setData) + .finally(() => setLoading(false)) + } + useEffect(load, []) + + async function autoFix(b: FlaggedBook): Promise { + setFix(prev => ({ ...prev, [b.book_id]: 'fixing' })) + try { + const candidates = await api.get<{ cover_url: string }[]>(`/books/${b.book_id}/cover-candidates`) + const first = candidates[0] + if (!first) { + setFix(prev => ({ ...prev, [b.book_id]: 'nocandidate' })) + return false + } + const form = new FormData() + form.append('url', first.cover_url) + await api.upload(`/books/${b.book_id}/cover`, form) + setFix(prev => ({ ...prev, [b.book_id]: 'fixed' })) + return true + } catch { + setFix(prev => ({ ...prev, [b.book_id]: 'failed' })) + return false + } + } + + async function autoFixAllMissing() { + if (!data || bulkRunning) return + setBulkRunning(true) + for (const b of data.books) { + if (b.reason !== 'missing') continue + if (fix[b.book_id] === 'fixed') continue + await autoFix(b) + } + setBulkRunning(false) + } + + if (!data) { + return ( +
+ Scanning covers… +
+ ) + } + + const missingCount = data.books.filter(b => b.reason === 'missing' && fix[b.book_id] !== 'fixed').length + + return ( +
+
+

+ {data.books.length === 0 + ? `All ${data.scanned} covers look healthy.` + : `${data.books.length} of ${data.scanned} books flagged (low-res = narrower than ${data.min_width}px).`} +

+
+ {missingCount > 0 && ( + + )} + +
+
+ + {data.books.length > 0 && ( +
+
+ + Book + Problem + Size + +
+ {data.books.map(b => { + const state = fix[b.book_id] + return ( +
+ + {b.reason === 'missing' || state === 'fixed' ? ( + state === 'fixed' ? ( + + ) : ( + + ) + ) : ( + + )} + + + + {b.series ? `${b.series} ${b.series_index != null ? `#${b.series_index}` : ''} — ` : ''} + {b.title} + + {b.author && {b.author}} + + + {state === 'fixed' ? 'Fixed' : REASON_LABEL[b.reason]} + + + {b.width != null ? `${b.width}×${b.height}px` : '—'} + + + {b.reason === 'missing' && state !== 'fixed' && ( + + )} + + + + +
+ ) + })} +
+ )} + {Object.values(fix).some(s => s === 'nocandidate' || s === 'failed') && ( +

+ Some books had no usable candidate or failed to apply — open them and use the cover picker. +

+ )} +
+ ) +} diff --git a/frontend/src/components/InstanceBackup.tsx b/frontend/src/components/InstanceBackup.tsx new file mode 100644 index 0000000..3532a5f --- /dev/null +++ b/frontend/src/components/InstanceBackup.tsx @@ -0,0 +1,164 @@ +// Admin → Server → Instance backup: download a consistent snapshot (DB + +// covers + manifest; library files stay on disk), and stage a restore that +// applies at the next server start — never under a live database. +import { useEffect, useRef, useState } from 'react' +import { Archive, Download, Loader2, RotateCcw, X } from 'lucide-react' +import { api } from '@/lib/api' + +interface RestoreStatus { + staged: boolean + summary?: { version: string; created_at: string; users: number; books: number } | null +} + +export function InstanceBackup() { + const [downloading, setDownloading] = useState(false) + const [status, setStatus] = useState(null) + const [confirmText, setConfirmText] = useState('') + const [pendingFile, setPendingFile] = useState(null) + const [staging, setStaging] = useState(false) + const [error, setError] = useState(null) + const fileRef = useRef(null) + + const loadStatus = () => { + api.get('/admin/backup/restore').then(setStatus).catch(() => {}) + } + useEffect(loadStatus, []) + + async function download() { + setDownloading(true) + setError(null) + try { + const token = localStorage.getItem('tome_token') + const resp = await fetch('/api/admin/backup/download', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!resp.ok) throw new Error('Backup failed') + const blob = await resp.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = resp.headers.get('Content-Disposition')?.match(/filename="?([^";]+)/)?.[1] + ?? 'tome-backup.tar.gz' + a.click() + URL.revokeObjectURL(url) + } catch (e) { + setError(e instanceof Error ? e.message : 'Backup failed') + } finally { + setDownloading(false) + } + } + + async function stage() { + if (!pendingFile || confirmText !== 'RESTORE' || staging) return + setStaging(true) + setError(null) + const form = new FormData() + form.append('file', pendingFile) + form.append('confirm', confirmText) + try { + await api.upload('/admin/backup/restore', form) + setPendingFile(null) + setConfirmText('') + loadStatus() + } catch (e) { + setError(e instanceof Error ? e.message : 'That file was rejected') + } finally { + setStaging(false) + } + } + + return ( +
+
+ +

Instance backup

+
+

+ Everything Tome knows in one archive: the database (all users, statuses, + sessions, highlights, settings) plus the cover cache. Book files are not + included — they live on disk and belong to your own backups. Restoring + stages the archive and applies it at the next server restart; the + current database is kept alongside as a safety copy. +

+ +
+ + + {!status?.staged && ( + <> + { setPendingFile(e.target.files?.[0] ?? null); e.target.value = '' }} + /> + + + )} +
+ + {pendingFile && !status?.staged && ( +
+

+ This replaces the entire database at + the next restart. Type RESTORE to arm it. +

+ setConfirmText(e.target.value)} + placeholder="RESTORE" + className="w-32 rounded-md border border-border bg-background px-2.5 py-1.5 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring" + /> + + +
+ )} + + {status?.staged && ( +
+

+ Restore staged + {status.summary + ? ` — backup from ${status.summary.created_at} (v${status.summary.version}, ${status.summary.users} users, ${status.summary.books} books).` + : '.'}{' '} + It applies at the next server restart. +

+ +
+ )} + + {error &&

{error}

} +
+ ) +} diff --git a/frontend/src/components/KeyboardShortcutsModal.tsx b/frontend/src/components/KeyboardShortcutsModal.tsx index 9c61df5..67e75c2 100644 --- a/frontend/src/components/KeyboardShortcutsModal.tsx +++ b/frontend/src/components/KeyboardShortcutsModal.tsx @@ -17,6 +17,12 @@ interface ShortcutSection { } const SECTIONS: ShortcutSection[] = [ + { + title: 'Everywhere', + rows: [ + { keys: ['⌘', 'K'], description: 'Command palette — jump to a book, series, author, or page' }, + ], + }, { title: 'Dashboard', rows: [ diff --git a/frontend/src/components/NotificationChannels.tsx b/frontend/src/components/NotificationChannels.tsx new file mode 100644 index 0000000..0876b7f --- /dev/null +++ b/frontend/src/components/NotificationChannels.tsx @@ -0,0 +1,152 @@ +// Settings → Notifications: per-user outbound channels (ntfy / Gotify / +// webhook). The in-app bell only fires on visit; a channel pushes the same +// events (wish fulfilled, new volume detected, goals) the moment they happen. +import { useEffect, useState } from 'react' +import { CheckCircle, Loader2, Plus, Send, Trash2, XCircle } from 'lucide-react' +import { api } from '@/lib/api' +import { cn } from '@/lib/utils' + +interface Channel { + id: number + kind: 'ntfy' | 'gotify' | 'webhook' + url: string + has_token: boolean + enabled: boolean +} + +const KIND_LABEL = { ntfy: 'ntfy', gotify: 'Gotify', webhook: 'Webhook' } as const +const URL_HINT: Record = { + ntfy: 'https://ntfy.sh/your-topic', + gotify: 'https://gotify.example.org', + webhook: 'https://example.org/hook', +} +const TOKEN_HINT: Record = { + ntfy: 'Access token (only for protected topics)', + gotify: 'Application token (required)', + webhook: 'Not used', +} + +export function NotificationChannels() { + const [channels, setChannels] = useState([]) + const [kind, setKind] = useState('ntfy') + const [url, setUrl] = useState('') + const [token, setToken] = useState('') + const [adding, setAdding] = useState(false) + const [testState, setTestState] = useState>({}) + + const load = () => { + api.get('/notification-channels').then(setChannels).catch(() => {}) + } + useEffect(load, []) + + async function add() { + if (!url.trim() || adding) return + setAdding(true) + try { + await api.post('/notification-channels', { + kind, url: url.trim(), token: token.trim() || null, + }) + setUrl('') + setToken('') + load() + } finally { + setAdding(false) + } + } + + async function test(c: Channel) { + setTestState(prev => ({ ...prev, [c.id]: 'testing' })) + try { + const r = await api.post<{ ok: boolean; error?: string }>(`/notification-channels/${c.id}/test`) + setTestState(prev => ({ ...prev, [c.id]: r.ok ? 'ok' : (r.error || 'failed') })) + } catch { + setTestState(prev => ({ ...prev, [c.id]: 'request failed' })) + } + } + + return ( +
+

+ Push Tome's notifications (wish fulfilled, new volume detected, reading + goals) to ntfy, Gotify, or any webhook the moment they happen — the bell + above only rings when you visit. Each channel can be tested, paused, or + removed; tokens are stored server-side and never shown again. +

+ + {channels.length > 0 && ( +
    + {channels.map(c => { + const t = testState[c.id] + return ( +
  • + {KIND_LABEL[c.kind]} + + {c.url} + + {t === 'ok' && } + {t && t !== 'ok' && t !== 'testing' && ( + + )} + + + +
  • + ) + })} +
+ )} + +
+ + setUrl(e.target.value)} + placeholder={URL_HINT[kind]} + className="min-w-[220px] flex-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring" + /> + {kind !== 'webhook' && ( + setToken(e.target.value)} + placeholder={TOKEN_HINT[kind]} + className="min-w-[180px] rounded-md border border-border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring" + /> + )} + +
+
+ ) +} diff --git a/frontend/src/components/PositionHistoryModal.tsx b/frontend/src/components/PositionHistoryModal.tsx new file mode 100644 index 0000000..a0a794a --- /dev/null +++ b/frontend/src/components/PositionHistoryModal.tsx @@ -0,0 +1,134 @@ +// Position history + restore: the recovery UI for a bad sync. Lists the +// recent position log for one book (GET /books/{id}/position-history) and +// restores any entry as the live position — explicitly un-finishing the book +// when recovering from a false 100%. +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { History, Loader2, RotateCcw, X } from 'lucide-react' +import { api } from '@/lib/api' +import { cn } from '@/lib/utils' + +interface HistoryEntry { + id: number + percentage: number + device: string | null + created_at: string +} + +interface HistoryResponse { + current: { percentage: number; device: string | null; updated_at: string } | null + history: HistoryEntry[] +} + +function fmtWhen(iso: string): string { + const d = new Date(iso) + const diff = Date.now() - d.getTime() + if (diff < 3600_000) return `${Math.max(1, Math.floor(diff / 60_000))} min ago` + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago` + return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) +} + +export function PositionHistoryModal({ bookId, onClose, onRestored }: { + bookId: number + onClose: () => void + onRestored: () => void +}) { + const [data, setData] = useState(null) + const [restoring, setRestoring] = useState(null) + + const load = () => { + api.get(`/books/${bookId}/position-history`).then(setData).catch(() => {}) + } + useEffect(load, [bookId]) + + async function restore(entry: HistoryEntry) { + if (restoring != null) return + setRestoring(entry.id) + try { + await api.post(`/books/${bookId}/position-history/${entry.id}/restore`) + load() + onRestored() + } finally { + setRestoring(null) + } + } + + return createPortal( + <> +
+
+
+
+ +

Position history

+ +
+
+ {!data ? ( +
+ Loading… +
+ ) : data.history.length === 0 ? ( +

+ No position changes recorded yet — history starts with the next sync. +

+ ) : ( +
    + {data.history.map((h, i) => { + const isCurrent = + i === 0 && data.current != null && + Math.abs(h.percentage - data.current.percentage) < 0.0005 + return ( +
  • + + {Math.round(h.percentage * 1000) / 10}% + + + {h.device || 'unknown device'} · {fmtWhen(h.created_at)} + {isCurrent && ( + + current + + )} + + {!isCurrent && ( + + )} +
  • + ) + })} +
+ )} +

+ Restoring sets the live position (and read status) back to that + point; devices pick it up on their next sync. Restoring below + 100% un-finishes a falsely completed book. +

+
+
+
+ , + document.body, + ) +} diff --git a/frontend/src/components/ReadingImport.tsx b/frontend/src/components/ReadingImport.tsx new file mode 100644 index 0000000..6498d03 --- /dev/null +++ b/frontend/src/components/ReadingImport.tsx @@ -0,0 +1,197 @@ +// Settings → Import reading history: upload a Goodreads or StoryGraph export, +// preview what matches the library, apply. Applying is fill-gaps-only server- +// side — an import can never overwrite an existing status/rating/review. +import { useRef, useState } from 'react' +import { CheckCircle2, FileUp, Loader2, Upload } from 'lucide-react' +import { api } from '@/lib/api' +import { cn } from '@/lib/utils' + +interface MatchedRow { + title: string + author: string + status: string + rating: number | null + finished_on: string | null + review: string | null + book_id: number + matched_title: string + match_via: 'isbn' | 'title' | 'fuzzy' + will_apply: { status: boolean; rating: boolean; finished_on: boolean; review: boolean } +} + +interface Preview { + dialect: string + matched: MatchedRow[] + unmatched: { title: string; author: string }[] + skipped_unread: number +} + +const VIA_LABEL = { isbn: 'ISBN', title: 'title', fuzzy: 'fuzzy' } as const + +export function ReadingImport() { + const [preview, setPreview] = useState(null) + const [checked, setChecked] = useState>(new Set()) + const [phase, setPhase] = useState<'idle' | 'previewing' | 'applying' | 'done'>('idle') + const [error, setError] = useState(null) + const [summary, setSummary] = useState(null) + const fileRef = useRef(null) + + async function onFile(f: File) { + setPhase('previewing') + setError(null) + setSummary(null) + const form = new FormData() + form.append('file', f) + try { + const p = await api.upload('/import/reading-csv', form) + setPreview(p) + setChecked(new Set(p.matched.map((_, i) => i))) + setPhase('idle') + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not read that file') + setPhase('idle') + } + } + + async function apply() { + if (!preview || phase === 'applying') return + setPhase('applying') + try { + const items = preview.matched + .filter((_, i) => checked.has(i)) + .map(m => ({ + book_id: m.book_id, status: m.status, rating: m.rating, + finished_on: m.finished_on, review: m.review, + })) + const r = await api.post<{ applied: Record; skipped: number }>( + '/import/reading-csv/apply', { items }, + ) + const a = r.applied + setSummary( + `Applied: ${a.status} statuses, ${a.rating} ratings, ${a.finished_on} finish dates, ${a.review} reviews.`, + ) + setPreview(null) + setPhase('done') + } catch { + setError('Applying failed — nothing was changed.') + setPhase('idle') + } + } + + return ( +
+

+ Bring your reading history from Goodreads or StoryGraph: upload their CSV + export, review what matches your library, apply. Importing only fills + gaps — it never overwrites a status, rating, review, or finish date you + already have. "To read" shelves are skipped (that's what the wishlist + is for). +

+ + { if (e.target.files?.[0]) onFile(e.target.files[0]); e.target.value = '' }} + /> + + {!preview && ( +
+ + {summary && ( + + {summary} + + )} + {error && {error}} +
+ )} + + {preview && ( +
+

+ Recognized a {preview.dialect} export:{' '} + {preview.matched.length} matched, {preview.unmatched.length} not in your library,{' '} + {preview.skipped_unread} to-read rows skipped. +

+ + {preview.matched.length > 0 && ( +
+ {preview.matched.map((m, i) => { + const noop = !m.will_apply.status && !m.will_apply.rating && + !m.will_apply.finished_on && !m.will_apply.review + return ( + + ) + })} +
+ )} + + {preview.unmatched.length > 0 && ( +
+ Not in your library ({preview.unmatched.length}) +
    + {preview.unmatched.map((u, i) => ( +
  • {u.title} — {u.author}
  • + ))} +
+
+ )} + +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/UploadModal.tsx b/frontend/src/components/UploadModal.tsx index 1bd2a74..bf6341f 100644 --- a/frontend/src/components/UploadModal.tsx +++ b/frontend/src/components/UploadModal.tsx @@ -9,8 +9,23 @@ interface UploadItem { id: string file: File bookTypeId: string - status: 'pending' | 'uploading' | 'done' | 'error' + status: 'pending' | 'uploading' | 'done' | 'error' | 'duplicate' errorMsg?: string + /** Book already holding these exact bytes (pre-upload hash check). */ + dupBookId?: number +} + +// Hash in the browser and ask the server before uploading — the server would +// detect the duplicate anyway (upload attaches nothing for identical bytes), +// but only after the whole file crossed the wire. +async function sha256Hex(file: File): Promise { + if (file.size > 512 * 1024 * 1024) return null // don't buffer >512MB in RAM + try { + const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()) + return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('') + } catch { + return null + } } function formatIcon(file: File) { @@ -52,6 +67,32 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches })) setItems(prev => [...prev, ...newItems]) setSummary(null) + void flagDuplicates(newItems) + } + + async function flagDuplicates(newItems: UploadItem[]) { + // Sequential hashing keeps peak memory at one file's buffer. + const hashed: { id: string; hash: string }[] = [] + for (const it of newItems) { + const hash = await sha256Hex(it.file) + if (hash) hashed.push({ id: it.id, hash }) + } + if (!hashed.length) return + try { + const resp = await api.post<{ existing: Record }>( + '/books/check-hashes', + { hashes: hashed.map(h => h.hash) }, + ) + setItems(prev => prev.map(p => { + const h = hashed.find(x => x.id === p.id) + const bookId = h ? resp.existing[h.hash] : undefined + return bookId && p.status === 'pending' + ? { ...p, status: 'duplicate', dupBookId: bookId } + : p + })) + } catch { + // Offline / older server: uploads proceed, the server still dedupes. + } } function handleFileInput(e: React.ChangeEvent) { @@ -90,7 +131,12 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches const allMatchedWishIds: number[] = [] const matchedBookIds: number[] = [] + let skipped = 0 for (const item of items) { + if (item.status === 'duplicate') { + skipped++ + continue + } setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'uploading' } : it)) const form = new FormData() form.append('file', item.file) @@ -124,13 +170,16 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches } if (success > 0) { onDone() + const skipNote = skipped > 0 ? `, ${skipped} already in library` : '' if (failed === 0) { - toast.success(`${success} book${success !== 1 ? 's' : ''} uploaded`) + toast.success(`${success} book${success !== 1 ? 's' : ''} uploaded${skipNote}`) } else { - toast.info(`${success} uploaded, ${failed} failed`) + toast.info(`${success} uploaded, ${failed} failed${skipNote}`) } } else if (failed > 0) { toast.error(`Upload failed for ${failed} file${failed !== 1 ? 's' : ''}`) + } else if (skipped > 0) { + toast.info(`Nothing to upload — ${skipped} file${skipped !== 1 ? 's are' : ' is'} already in your library`) } } @@ -258,7 +307,7 @@ export function UploadModal({ isOpen, onClose, onDone, onUploaded, onWishMatches {/* Status */} - {item.status === 'pending' && ( + {(item.status === 'pending' || item.status === 'duplicate') && (
))}
diff --git a/frontend/src/components/WhatsNewPanel.tsx b/frontend/src/components/WhatsNewPanel.tsx new file mode 100644 index 0000000..593358c --- /dev/null +++ b/frontend/src/components/WhatsNewPanel.tsx @@ -0,0 +1,112 @@ +// One-time "What's new" panel after a server upgrade. The server reports its +// version with the release-notes entries for it (GET /meta/whats-new, parsed +// from the changelog); we remember the last version this browser saw and show +// the panel once when it changes. Fresh browsers/installs store silently — a +// first login should not open with a modal. +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { Sparkles, X } from 'lucide-react' +import { api } from '@/lib/api' + +interface WhatsNewEntry { + kind: string + title: string + body: string +} +interface WhatsNewResponse { + version: string + entries: WhatsNewEntry[] +} + +const SEEN_KEY = 'tome_seen_version' +// Once per SPA session is enough — remounts (route changes) must not refetch. +let checkedThisSession = false + +const KIND_ORDER = ['Added', 'Changed', 'Fixed', 'Removed'] + +export function WhatsNewPanel() { + const [data, setData] = useState(null) + + useEffect(() => { + if (checkedThisSession) return + checkedThisSession = true + api + .get('/meta/whats-new') + .then((d) => { + if (!d.version || d.version === 'dev') return + const seen = localStorage.getItem(SEEN_KEY) + if (seen === null) { + // First visit from this browser: nothing to announce, just baseline. + localStorage.setItem(SEEN_KEY, d.version) + return + } + if (seen !== d.version && d.entries.length > 0) setData(d) + }) + .catch(() => {}) + }, []) + + if (!data) return null + + const dismiss = () => { + localStorage.setItem(SEEN_KEY, data.version) + setData(null) + } + + const groups = KIND_ORDER.filter((k) => data.entries.some((e) => e.kind === k)).concat( + [...new Set(data.entries.map((e) => e.kind))].filter((k) => !KIND_ORDER.includes(k)), + ) + + // Portal to : the header that mounts this has backdrop-blur, which + // makes it the containing block for fixed descendants — the overlay would + // center on the header bar instead of the viewport. + return createPortal( + <> +
+
+
+
+ +

+ What's new in Tome {data.version} +

+ +
+
+ {groups.map((kind) => ( +
+

+ {kind} +

+
    + {data.entries + .filter((e) => e.kind === kind) + .map((e, i) => ( +
  • + {e.title && {e.title} } + {e.body} +
  • + ))} +
+
+ ))} +
+
+ +
+
+
+ , + document.body, + ) +} diff --git a/frontend/src/components/stats/TimelineRibbon.tsx b/frontend/src/components/stats/TimelineRibbon.tsx index 18de3b1..4a67cce 100644 --- a/frontend/src/components/stats/TimelineRibbon.tsx +++ b/frontend/src/components/stats/TimelineRibbon.tsx @@ -32,8 +32,11 @@ interface TimelineResponse { const DAY_MS = 86400000 const dayNum = (d: string) => Math.floor(Date.parse(`${d}T00:00:00Z`) / DAY_MS) +const dayStr = (n: number) => new Date(n * DAY_MS).toISOString().slice(0, 10) const RAIL_W = 200 +const RAIL_W_NARROW = 96 // phones: the full rail would eat half the viewport +const NARROW_BELOW = 480 const AXIS_H = 26 const SUB_H = 22 // one sub-lane (bar row) inside a lane const BAR_H = 16 @@ -128,10 +131,19 @@ export function TimelineRibbon({ standalone = false }: { standalone?: boolean } localStorage.setItem(ZOOM_KEY, String(next)) return next }) - const [hover, setHover] = useState<{ b: TimelineBook; x: number; y: number } | null>(null) + const [hover, setHover] = useState<{ + b: TimelineBook + x: number + y: number + day: { date: string; seconds: number } | null + } | null>(null) const scrollRef = useRef(null) const colors = useChartColors() const navigate = useNavigate() + // Touch: first tap on a bar shows the tooltip, second tap navigates — + // tap-navigating instantly made "inspect" impossible on phones. + const coarse = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []) + const lastTapRef = useRef(null) useEffect(() => { api @@ -161,11 +173,14 @@ export function TimelineRibbon({ standalone = false }: { standalone?: boolean } return () => ro.disconnect() }, [data]) + // Phones get a narrow rail — the full 200px rail ate half the viewport. + const railW = containerW > 0 && containerW < NARROW_BELOW ? RAIL_W_NARROW : RAIL_W + const model = useMemo(() => { if (!data || !lanes) return null const minDay = Math.min(...lanes.map((l) => l.firstDay)) - 2 const maxDay = dayNum(data.today) + 2 - const fit = containerW > RAIL_W + 100 ? (containerW - RAIL_W - 1) / (maxDay - minDay) : 0 + const fit = containerW > railW + 100 ? (containerW - railW - 1) / (maxDay - minDay) : 0 const ppd = Math.max(ZOOMS[zoom], fit) const months: { x: number; label: string }[] = [] const first = new Date(minDay * DAY_MS) @@ -190,7 +205,7 @@ export function TimelineRibbon({ standalone = false }: { standalone?: boolean } months, ref, } - }, [data, lanes, zoom, containerW]) + }, [data, lanes, zoom, containerW, railW]) // Land on the recent end — that's where the reading is. useEffect(() => { @@ -205,6 +220,20 @@ export function TimelineRibbon({ standalone = false }: { standalone?: boolean } const ppd = model.ppd const atFitFloor = ppd > ZOOMS[zoom] + 0.001 // preset already below fit — zooming out changes nothing + // Resolve the hovered/tapped position to the day under the cursor, so the + // tooltip answers at day resolution, not just book totals. + const hoverFor = (b: TimelineBook, clientX: number, clientY: number, barEl: HTMLElement) => { + const d0 = dayNum(b.first_day) + const idx = Math.floor((clientX - barEl.getBoundingClientRect().left) / ppd) + const date = dayStr(d0 + Math.max(0, Math.min(idx, dayNum(b.last_day) - d0))) + const day = b.days.find((d) => d.date === date) ?? { date, seconds: 0 } + return { b, x: clientX, y: clientY, day } + } + const clearHover = () => { + setHover(null) + lastTapRef.current = null + } + return ( // Standalone hugs its content (the page shows background below a short // ribbon); as a tile it fills the card body it was given. @@ -234,11 +263,20 @@ export function TimelineRibbon({ standalone = false }: { standalone?: boolean }
-
setHover(null)}> -
+
{ + // Tap on empty ribbon space (not a bar) dismisses the touch tooltip. + if (!(e.target as HTMLElement).closest('button')) clearHover() + }} + > +
{/* axis row — sticky against vertical scroll; corner sticky both ways */}
-
+
{model.months .filter((m) => m.x < model.width - 42) @@ -261,7 +299,7 @@ export function TimelineRibbon({ standalone = false }: { standalone?: boolean }
{lane.label} @@ -284,9 +322,21 @@ export function TimelineRibbon({ standalone = false }: { standalone?: boolean } return (
+ {hover.day && ( +
+ {formatDate(hover.day.date)} ·{' '} + {hover.day.seconds > 0 ? formatDuration(hover.day.seconds) : 'no reading'} +
+ )}
)} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index ecff1a9..0f21db3 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -11,6 +11,8 @@ import { import { DOCS, docsLink } from '@/lib/docs' import { MetadataManager } from '@/components/MetadataManager' import { LibraryHealthTab } from '@/components/LibraryHealth' +import { CoverAudit } from '@/components/CoverAudit' +import { InstanceBackup } from '@/components/InstanceBackup' import { WordCountTab } from '@/components/WordCount' import { SeriesCoverageStrip } from '@/components/SeriesCoverageStrip' import { useAuth, isAdmin } from '@/contexts/AuthContext' @@ -521,6 +523,8 @@ function ServerTab() { return (
+ {/* Instance backup / restore */} + {/* Stats */}
{[ @@ -2004,7 +2008,7 @@ function WishlistTab() { // ── AdminPage ───────────────────────────────────────────────────────────── -type Tab = 'users' | 'scanner' | 'server' | 'types' | 'audit' | 'metadata' | 'library' | 'wordcount' | 'sync' | 'duplicates' | 'email' | 'wishlist' +type Tab = 'users' | 'scanner' | 'server' | 'types' | 'audit' | 'metadata' | 'library' | 'wordcount' | 'sync' | 'duplicates' | 'covers' | 'email' | 'wishlist' export function AdminPage() { const { user } = useAuth() @@ -2031,6 +2035,7 @@ export function AdminPage() { { id: 'wordcount', label: 'Word Counts' }, { id: 'sync', label: 'Sync Status' }, { id: 'duplicates', label: 'Duplicates' }, + { id: 'covers', label: 'Covers' }, { id: 'email', label: 'Email' }, { id: 'wishlist', label: 'Wishlist' }, ] @@ -2075,6 +2080,7 @@ export function AdminPage() { {tab === 'wordcount' && } {tab === 'sync' && } {tab === 'duplicates' && } + {tab === 'covers' && } {tab === 'email' && } {tab === 'wishlist' && } diff --git a/frontend/src/pages/BookDetailPage.tsx b/frontend/src/pages/BookDetailPage.tsx index 500c47b..a4f00c4 100644 --- a/frontend/src/pages/BookDetailPage.tsx +++ b/frontend/src/pages/BookDetailPage.tsx @@ -5,13 +5,14 @@ import { Calendar, Globe, Hash, Building2, FileText, Trash2, Loader2, Sparkles, Library, Check, BookMarked, ChevronLeft, ChevronRight, Home, Tag as TagIcon, StickyNote, ChevronDown, Archive, AlignLeft, - Plus, TrendingUp, TrendingDown, Minus, Info + Plus, TrendingUp, TrendingDown, Minus, Info, History as HistoryIcon } from 'lucide-react' import { useAuth, isMember } from '@/contexts/AuthContext' import { useToast } from '@/contexts/ToastContext' import { ThemeToggle } from '@/components/ThemeToggle' import { MetadataFetchModal } from '@/components/MetadataFetchModal' import { CoverPickerModal } from '@/components/CoverPickerModal' +import { PositionHistoryModal } from '@/components/PositionHistoryModal' import { SendButton } from '@/components/SendButton' import { BookAnimation } from '@/components/BookAnimation' import { StarRating } from '@/components/StarRating' @@ -184,6 +185,7 @@ export function BookDetailPage() { // intensity + time-per-chapter) has grown tall enough that "keep it closed" // is a real preference, not a per-page whim. const [statsOpen, setStatsOpen] = useState(() => localStorage.getItem('tome_book_stats_open') !== '0') + const [showPositionHistory, setShowPositionHistory] = useState(false) const [descExpanded, setDescExpanded] = useState(false) const [facets, setFacets] = useState({ authors: [], series: [], tags: [], formats: [] }) const [draftTags, setDraftTags] = useState([]) @@ -752,6 +754,14 @@ export function BookDetailPage() { Reading Stats +
{statsOpen && ( hasReadingData ? ( @@ -1264,6 +1274,18 @@ export function BookDetailPage() { onApplied={updated => { setBook(updated); setDraft(updated) }} /> )} + {showPositionHistory && ( + setShowPositionHistory(false)} + onRestored={() => { + refreshStats() + api.get(`/books/${id}/status`) + .then(s => { setBookStatus(s.status); setProgressPct(s.progress_pct); setCfi(s.cfi ?? null) }) + .catch(() => {}) + }} + /> + )}
) } diff --git a/frontend/src/pages/ReaderPage.tsx b/frontend/src/pages/ReaderPage.tsx index 5ac9d61..f2c9451 100644 --- a/frontend/src/pages/ReaderPage.tsx +++ b/frontend/src/pages/ReaderPage.tsx @@ -669,6 +669,31 @@ export default function ReaderPage() { // ── EPUB-only state ───────────────────────────────────────────────────────── const [chapterLabel, setChapterLabel] = useState('') + // "Time left in chapter": chapter fraction boundaries × book word count × + // the user's true WPM (measured; 250 default until they have history). + const pacingRef = useRef<{ + word_count: number | null + wpm: number | null + chapters: { title: string; start_fraction: number; end_fraction: number }[] + } | null>(null) + const lastFractionRef = useRef(0) + const [chapterMinsLeft, setChapterMinsLeft] = useState(null) + const computeChapterLeft = useCallback((f: number) => { + lastFractionRef.current = f + const pacing = pacingRef.current + if (!pacing?.word_count || pacing.chapters.length === 0) return + const ch = + pacing.chapters.find(c => f >= c.start_fraction && f < c.end_fraction) ?? + (f >= (pacing.chapters[pacing.chapters.length - 1]?.start_fraction ?? 1) + ? pacing.chapters[pacing.chapters.length - 1] + : null) + if (ch) { + const wordsLeft = pacing.word_count * Math.max(0, ch.end_fraction - f) + setChapterMinsLeft(wordsLeft / (pacing.wpm || 250)) + } else { + setChapterMinsLeft(null) + } + }, []) const [toc, setToc] = useState([]) const [showToc, setShowToc] = useState(false) const [fontSize, setFontSize] = useState( @@ -987,6 +1012,16 @@ export default function ReaderPage() { const annotationsPromise = api .get(`/books/${bookId}/annotations`) .catch(() => [] as ReaderAnnotation[]) + // Pacing data for "time left in chapter" — non-blocking; the footer + // simply stays quiet for books without a chapter map or word count. + // Recompute on arrival: the initial relocate usually fires first. + api + .get>(`/books/${bookId}/reader-pacing`) + .then(p => { + pacingRef.current = p + computeChapterLeft(lastFractionRef.current) + }) + .catch(() => {}) view.addEventListener('load', (e: Event) => { applyStylesRef.current() @@ -1025,6 +1060,9 @@ export default function ReaderPage() { setProgress(pct) if (detail.tocItem?.label) setChapterLabel(detail.tocItem.label.trim()) + // Chapter time-left from fraction boundaries — no pagination involved. + computeChapterLeft(detail.fraction ?? 0) + if (!readyToSave.current) return const fraction = detail.fraction ?? 0 @@ -2015,6 +2053,11 @@ export default function ReaderPage() { {chapterLabel} + {chapterMinsLeft != null && chapterMinsLeft >= 0.5 && ( + + {chapterLabel ? ' · ' : ''}~{Math.max(1, Math.round(chapterMinsLeft))} min left + + )}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index a76b23f..2e76e21 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,12 +1,14 @@ import { useState, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import { - ArrowLeft, Eye, EyeOff, Download, Check, RefreshCw, Loader2, + ArrowLeft, ArrowUpCircle, Eye, EyeOff, Download, Check, RefreshCw, Loader2, Copy, Trash2, Plus, Key, Smartphone, CheckCircle, Info, X, ChevronDown, ChevronUp, AlertTriangle, ExternalLink, Send, } from 'lucide-react' import { DOCS, docsLink } from '@/lib/docs' import { ThemeToggle } from '@/components/ThemeToggle' +import { NotificationChannels } from '@/components/NotificationChannels' +import { ReadingImport } from '@/components/ReadingImport' import { HardcoverSync } from '@/components/HardcoverSync' import { api } from '@/lib/api' import { cn } from '@/lib/utils' @@ -45,6 +47,19 @@ export function SettingsPage() { api.get<{ version: string }>('/health').then(r => setTomeVersion(r.version)).catch(() => {}) }, []) + // Quiet update indicator (admins): daily-cached server-side check against + // GitHub releases; renders a link in About only when something is newer. + const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null) + useEffect(() => { + if (!user?.is_admin) return + api + .get<{ update_available: boolean; latest: string | null; url: string }>('/meta/update-check') + .then(r => { + if (r.update_available && r.latest) setUpdateInfo({ latest: r.latest, url: r.url }) + }) + .catch(() => {}) + }, [user?.is_admin]) + // ── SSO linking ──────────────────────────────────────────────────────────── const [ssoEnabled, setSsoEnabled] = useState(false) const [ssoMsg, setSsoMsg] = useState<{ ok: boolean; text: string } | null>(null) @@ -1480,6 +1495,18 @@ export function SettingsPage() { {/* ── Export ───────────────────────────────────────────────────── */} + {/* ── Outbound notifications ────────────────────────────────────── */} +
+ + +
+ + {/* ── Reading-history import ────────────────────────────────────── */} +
+ + +
+
@@ -1528,6 +1555,20 @@ export function SettingsPage() { > GitHub + {updateInfo && ( + <> + · + + + v{updateInfo.latest} available + + + )}
diff --git a/tests/test_admin_covers.py b/tests/test_admin_covers.py new file mode 100644 index 0000000..d7dbecf --- /dev/null +++ b/tests/test_admin_covers.py @@ -0,0 +1,51 @@ +"""Tests for GET /api/admin/covers/audit.""" +from PIL import Image +from sqlalchemy.orm import Session +from starlette.testclient import TestClient + +from backend.core.config import settings +from backend.core.security import create_access_token, hash_password +from backend.models.user import User, UserPermission + + +def _write_cover(name: str, width: int, height: int) -> str: + settings.covers_dir.mkdir(parents=True, exist_ok=True) + path = settings.covers_dir / name + Image.new("RGB", (width, height), "white").save(path, "JPEG") + return name + + +def test_audit_flags_missing_and_low_res(client: TestClient, db: Session, make_book): + good = make_book(title="Good Cover") + good.cover_path = _write_cover("good.jpg", 600, 900) + small = make_book(title="Tiny Cover") + small.cover_path = _write_cover("small.jpg", 128, 190) + bare = make_book(title="No Cover") + bare.cover_path = None + ghost = make_book(title="Ghost Cover") + ghost.cover_path = "does-not-exist.jpg" + db.flush() + + r = client.get("/api/admin/covers/audit") + assert r.status_code == 200 + data = r.json() + flagged = {b["title"]: b for b in data["books"]} + assert "Good Cover" not in flagged + assert flagged["Tiny Cover"]["reason"] == "low_res" + assert flagged["Tiny Cover"]["width"] == 128 + assert flagged["No Cover"]["reason"] == "missing" + assert flagged["Ghost Cover"]["reason"] == "missing" + assert data["scanned"] >= 4 + + +def test_audit_admin_only(client: TestClient, db: Session): + member = User(username="cov_member", email="cov_member@example.com", + hashed_password=hash_password("pass1234"), is_active=True, + is_admin=False, role="member", must_change_password=False) + db.add(member) + db.flush() + db.add(UserPermission(user_id=member.id)) + db.flush() + token = create_access_token(subject=member.id) + r = client.get("/api/admin/covers/audit", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 diff --git a/tests/test_book_reading_stats.py b/tests/test_book_reading_stats.py index 4ec4f97..1515566 100644 --- a/tests/test_book_reading_stats.py +++ b/tests/test_book_reading_stats.py @@ -358,3 +358,39 @@ def test_progress_fallback_uses_furthest_page_not_distinct(client: TestClient, m own = _get_stats(client, book.id)["own"] assert own["progress"] == pytest.approx(0.5) # 500/1000, not 50/1000 + + +# ── Reader pacing (time left in chapter) ────────────────────────────────────── + +def test_reader_pacing_shape(client: TestClient, make_book, admin_user, db: Session): + from backend.models.book import BookChapter + + user, _ = admin_user + book = make_book(title="Paced Book") + book.word_count = 80_000 + db.add(BookChapter(book_id=book.id, idx=0, title="One", start_fraction=0.0, end_fraction=0.5)) + db.add(BookChapter(book_id=book.id, idx=1, title="Two", start_fraction=0.5, end_fraction=1.0)) + db.flush() + + r = client.get(f"/api/books/{book.id}/reader-pacing") + assert r.status_code == 200 + data = r.json() + assert data["word_count"] == 80_000 + assert [c["title"] for c in data["chapters"]] == ["One", "Two"] + # No finished word-counted books yet -> no personal pace. + assert data["wpm"] is None + + +def test_reader_pacing_wpm_from_history(client: TestClient, make_book, admin_user, db: Session): + user, _ = admin_user + finished = make_book(title="Finished Fast") + finished.word_count = 60_000 + db.add(UserBookStatus(user_id=user.id, book_id=finished.id, status="read")) + # 60k words over 200 minutes -> 300 wpm + _make_session(db, user.id, finished.id, datetime(2026, 5, 1, 9), + duration_seconds=200 * 60, device="web") + db.flush() + + target = make_book(title="Now Reading") + data = client.get(f"/api/books/{target.id}/reader-pacing").json() + assert data["wpm"] == 300.0 diff --git a/tests/test_instance_backup.py b/tests/test_instance_backup.py new file mode 100644 index 0000000..ced0c29 --- /dev/null +++ b/tests/test_instance_backup.py @@ -0,0 +1,117 @@ +"""Instance backup/restore service — snapshot, validate, staged apply.""" +import sqlite3 +import tarfile +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from backend.services.instance_backup import ( + apply_staged_restore_if_present, + create_backup_tarball, + staged_path, + validate_backup, +) + + +def _mini_db(path: Path, users: int = 2, books: int = 3) -> None: + con = sqlite3.connect(str(path)) + con.execute("CREATE TABLE users (id INTEGER PRIMARY KEY)") + con.execute("CREATE TABLE books (id INTEGER PRIMARY KEY)") + con.executemany("INSERT INTO users VALUES (?)", [(i,) for i in range(users)]) + con.executemany("INSERT INTO books VALUES (?)", [(i,) for i in range(books)]) + con.commit() + con.close() + + +def test_backup_roundtrip(tmp_path: Path): + db = tmp_path / "tome.db" + _mini_db(db) + covers = tmp_path / "covers" + covers.mkdir() + (covers / "1.jpg").write_bytes(b"fakejpg") + + tarball = create_backup_tarball(db, covers, "9.9.9") + try: + summary = validate_backup(tarball) + assert summary["version"] == "9.9.9" + assert summary["users"] == 2 + assert summary["books"] == 3 + with tarfile.open(tarball) as tar: + assert "covers/1.jpg" in tar.getnames() + finally: + tarball.unlink(missing_ok=True) + + +def test_validate_rejects_garbage(tmp_path: Path): + bad = tmp_path / "junk.tar.gz" + bad.write_bytes(b"this is not a tarball") + with pytest.raises(ValueError): + validate_backup(bad) + + +def test_staged_apply_swaps_db_and_keeps_safety_copy(tmp_path: Path): + data = tmp_path + db = data / "tome.db" + covers = data / "covers" + covers.mkdir() + _mini_db(db, users=1, books=1) # the "current" instance + + other = tmp_path / "other.db" + _mini_db(other, users=5, books=9) # the backup being restored + other_covers = tmp_path / "other-covers" + other_covers.mkdir() + (other_covers / "9.jpg").write_bytes(b"x") + tarball = create_backup_tarball(other, other_covers, "1.2.3") + tarball.rename(staged_path(data)) + + applied = apply_staged_restore_if_present(data, db, covers) + assert applied is True + con = sqlite3.connect(str(db)) + assert con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 5 + con.close() + assert (covers / "9.jpg").is_file() + assert not staged_path(data).exists() + assert list(data.glob("tome.db.pre-restore-*")), "safety copy missing" + + +def test_staged_apply_failure_keeps_current_db(tmp_path: Path): + data = tmp_path + db = data / "tome.db" + _mini_db(db, users=1) + staged_path(data).write_bytes(b"corrupt garbage") + + applied = apply_staged_restore_if_present(data, db, data / "covers") + assert applied is False + con = sqlite3.connect(str(db)) + assert con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1 # untouched + con.close() + assert staged_path(data).with_suffix(".failed").exists() + + +def test_no_staging_is_noop(tmp_path: Path): + db = tmp_path / "tome.db" + _mini_db(db) + assert apply_staged_restore_if_present(tmp_path, db, tmp_path / "covers") is False + + +def test_restore_endpoints_admin_only_and_confirm(client: TestClient, db, tmp_path): + from backend.core.security import create_access_token, hash_password + from backend.models.user import User, UserPermission + + member = User(username="bk_member", email="bk_member@example.com", + hashed_password=hash_password("pass1234"), is_active=True, + is_admin=False, role="member", must_change_password=False) + db.add(member) + db.flush() + db.add(UserPermission(user_id=member.id)) + db.flush() + token = create_access_token(subject=member.id) + r = client.get("/api/admin/backup/restore", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 + + # Admin without the confirm phrase is rejected before any file handling. + r = client.post("/api/admin/backup/restore", + files={"file": ("b.tar.gz", b"x", "application/gzip")}, + data={"confirm": "yes please"}) + assert r.status_code == 422 diff --git a/tests/test_meta.py b/tests/test_meta.py new file mode 100644 index 0000000..ba8331a --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,130 @@ +"""Tests for /api/meta — What's-New changelog parsing and the update check.""" +from starlette.testclient import TestClient +from sqlalchemy.orm import Session + +import backend.api.meta as meta +from backend.core.config import settings +from backend.core.security import create_access_token, hash_password +from backend.models.user import User, UserPermission + + +SAMPLE = """# Changelog + +## [Unreleased] + +### Added +- **Unreleased thing.** Not shipped yet. + +## [1.8.0] + +### Added +- **Sync closed books.** Walks the device for books the plugin never synced, + adopting status and rating from the sidecar. +- Plain bullet without a bold lead. + +### Fixed +- **Chapter maps extract from EPUB2 books.** NCX fallback. + +## [1.7.0] + +### Added +- **Older thing.** Should never appear for 1.8.0. +""" + + +def test_parse_matches_version_section(): + entries = meta.parse_changelog_section(SAMPLE, "1.8.0") + assert len(entries) == 3 + assert entries[0]["kind"] == "Added" + assert entries[0]["title"] == "Sync closed books." + assert "adopting status and rating" in entries[0]["body"] # continuation folded + assert entries[1]["title"] == "" + assert entries[1]["body"] == "Plain bullet without a bold lead." + assert entries[2]["kind"] == "Fixed" + assert all("Older thing" not in (e["title"] + e["body"]) for e in entries) + + +def test_parse_falls_back_to_unreleased(): + entries = meta.parse_changelog_section(SAMPLE, "9.9.9") + assert len(entries) == 1 + assert entries[0]["title"] == "Unreleased thing." + + +def test_whats_new_endpoint_shape(client: TestClient): + r = client.get("/api/meta/whats-new") + assert r.status_code == 200 + data = r.json() + assert "version" in data + assert isinstance(data["entries"], list) + + +def _reset_cache(): + meta._cache.update(at=0.0, result=None, ttl=0.0) + + +def test_update_check_available(client: TestClient, monkeypatch): + _reset_cache() + monkeypatch.setattr(meta, "_fetch_latest_release", lambda: "99.0.0") + r = client.get("/api/meta/update-check") + assert r.status_code == 200 + data = r.json() + assert data["enabled"] is True + assert data["latest"] == "99.0.0" + assert data["update_available"] is True + assert data["url"].startswith("https://github.com/") + + +def test_update_check_current_is_latest(client: TestClient, monkeypatch): + _reset_cache() + from backend import __version__ + monkeypatch.setattr(meta, "_fetch_latest_release", lambda: __version__) + data = client.get("/api/meta/update-check").json() + assert data["update_available"] is False + + +def test_update_check_cached(client: TestClient, monkeypatch): + _reset_cache() + calls = {"n": 0} + + def fetch(): + calls["n"] += 1 + return "99.0.0" + + monkeypatch.setattr(meta, "_fetch_latest_release", fetch) + client.get("/api/meta/update-check") + client.get("/api/meta/update-check") + assert calls["n"] == 1 + + +def test_update_check_network_failure_is_graceful(client: TestClient, monkeypatch): + _reset_cache() + + def boom(): + raise OSError("offline") + + monkeypatch.setattr(meta, "_fetch_latest_release", boom) + data = client.get("/api/meta/update-check").json() + assert data["update_available"] is False + assert data["latest"] is None + + +def test_update_check_disabled_by_env(client: TestClient, monkeypatch): + _reset_cache() + monkeypatch.setattr(settings, "update_check", False) + data = client.get("/api/meta/update-check").json() + assert data["enabled"] is False + assert data["update_available"] is False + + +def test_update_check_admin_only(client: TestClient, db: Session, monkeypatch): + _reset_cache() + member = User(username="meta_member", email="meta_member@example.com", + hashed_password=hash_password("pass1234"), is_active=True, + is_admin=False, role="member", must_change_password=False) + db.add(member) + db.flush() + db.add(UserPermission(user_id=member.id)) + db.flush() + token = create_access_token(subject=member.id) + r = client.get("/api/meta/update-check", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 diff --git a/tests/test_outbound_notifications.py b/tests/test_outbound_notifications.py new file mode 100644 index 0000000..e9e6074 --- /dev/null +++ b/tests/test_outbound_notifications.py @@ -0,0 +1,104 @@ +"""Outbound notification fanout (ntfy/Gotify/webhook) + channel CRUD.""" +import json + +from sqlalchemy.orm import Session +from starlette.testclient import TestClient + +import backend.services.outbound_notifications as outbound +from backend.models.notification import Notification, NotificationChannel + + +def test_channel_crud_and_token_hidden(client: TestClient): + r = client.post("/api/notification-channels", + json={"kind": "ntfy", "url": "https://ntfy.sh/my-topic", "token": "secret"}) + assert r.status_code == 201 + ch = r.json() + assert ch["has_token"] is True + assert "secret" not in json.dumps(ch) + + rows = client.get("/api/notification-channels").json() + assert len(rows) == 1 + + assert client.post(f"/api/notification-channels/{ch['id']}/toggle").json()["enabled"] is False + assert client.delete(f"/api/notification-channels/{ch['id']}").json()["ok"] is True + assert client.get("/api/notification-channels").json() == [] + + +def test_channel_rejects_bad_input(client: TestClient): + assert client.post("/api/notification-channels", + json={"kind": "carrier-pigeon", "url": "https://x"}).status_code == 422 + assert client.post("/api/notification-channels", + json={"kind": "ntfy", "url": "ftp://x"}).status_code == 422 + + +def test_fanout_on_commit(client: TestClient, db: Session, admin_user, monkeypatch): + user, _ = admin_user + sent: list[tuple] = [] + monkeypatch.setattr(outbound, "_spawn", lambda payloads: outbound._send_all(payloads)) + # The worker normally opens its own app-level session; give it a fresh + # session on the test engine instead (same rule as production: never the + # session that is mid-commit). + from sqlalchemy.orm import sessionmaker + test_factory = sessionmaker(bind=db.get_bind()) + monkeypatch.setattr(outbound, "_session_factory", lambda: test_factory()) + monkeypatch.setattr(outbound, "deliver", + lambda kind, url, token, payload: sent.append((kind, url, payload["title"]))) + + db.add(NotificationChannel(user_id=user.id, kind="ntfy", + url="https://ntfy.sh/topic", enabled=True)) + db.add(NotificationChannel(user_id=user.id, kind="webhook", + url="https://hook.example", enabled=False)) # disabled: skipped + db.commit() + + db.add(Notification(user_id=user.id, kind="wish_fulfilled", + title="Your wish arrived", body="Vol 12 is in the library")) + db.commit() + + assert sent == [("ntfy", "https://ntfy.sh/topic", "Your wish arrived")] + + +def test_fanout_respects_kill_switch(client: TestClient, db: Session, admin_user, monkeypatch): + from backend.core.config import settings + + user, _ = admin_user + called = [] + monkeypatch.setattr(outbound, "_spawn", lambda payloads: called.append(payloads)) + monkeypatch.setattr(settings, "outbound_notify", False) + + db.add(Notification(user_id=user.id, kind="test", title="quiet")) + db.commit() + assert called == [] + + +def test_deliver_builds_correct_requests(monkeypatch): + captured = [] + + class FakeResp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_urlopen(req, timeout=None): + captured.append(req) + return FakeResp() + + monkeypatch.setattr(outbound.urllib.request, "urlopen", fake_urlopen) + + outbound.deliver("ntfy", "https://ntfy.sh/topic", "tok", + {"kind": "k", "title": "Title", "body": "Body", "link": "/books/1"}) + outbound.deliver("gotify", "https://gotify.example", "apptok", + {"kind": "k", "title": "Title", "body": "Body", "link": None}) + outbound.deliver("webhook", "https://hook.example/x", None, + {"kind": "wish_fulfilled", "title": "T", "body": "B", "link": "/wishlist"}) + + ntfy, gotify, hook = captured + assert ntfy.get_header("Title") == "Title" + assert ntfy.get_header("Authorization") == "Bearer tok" + assert ntfy.data == b"Body" + assert "/message?token=apptok" in gotify.full_url + assert json.loads(gotify.data)["title"] == "Title" + body = json.loads(hook.data) + assert body["event"] == "wish_fulfilled" + assert body["link"] == "/wishlist" # no public_url configured -> relative diff --git a/tests/test_position_history.py b/tests/test_position_history.py new file mode 100644 index 0000000..04f161b --- /dev/null +++ b/tests/test_position_history.py @@ -0,0 +1,107 @@ +"""Position history + restore — the safety net under position sync.""" +from datetime import datetime + +from sqlalchemy.orm import Session +from starlette.testclient import TestClient + +from backend.models.tome_sync import PositionHistory +from backend.models.user_book_status import UserBookStatus +from backend.services.book_progress import HISTORY_KEEP, upsert_position + + +def _entries(db: Session, user_id: int, book_id: int) -> list[PositionHistory]: + return ( + db.query(PositionHistory) + .filter(PositionHistory.user_id == user_id, PositionHistory.book_id == book_id) + .order_by(PositionHistory.id) + .all() + ) + + +def test_heartbeat_noise_not_recorded(db: Session, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Heartbeat Book") + upsert_position(db, user_id=user.id, book_id=book.id, + percentage=0.42, progress="cfi(a)", device="Kindle") + # Same position re-PUT (idle heartbeat) — no new entry. + upsert_position(db, user_id=user.id, book_id=book.id, + percentage=0.42, progress="cfi(a)", device="Kindle") + upsert_position(db, user_id=user.id, book_id=book.id, + percentage=0.4205, progress="cfi(a)", device="Kindle") # sub-threshold + db.flush() + assert len(_entries(db, user.id, book.id)) == 1 + + +def test_movement_recorded_and_pruned(db: Session, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Mover Book") + for i in range(HISTORY_KEEP + 15): + upsert_position(db, user_id=user.id, book_id=book.id, + percentage=i / 100.0, progress=f"cfi({i})", device="Kindle") + db.flush() + rows = _entries(db, user.id, book.id) + assert len(rows) == HISTORY_KEEP + # Newest survive the prune. + assert rows[-1].percentage == (HISTORY_KEEP + 14) / 100.0 + + +def test_history_endpoint_lists_newest_first(client: TestClient, db: Session, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Listed Book") + for pct in (0.1, 0.5, 0.9): + upsert_position(db, user_id=user.id, book_id=book.id, + percentage=pct, progress=f"cfi({pct})", device="Kindle") + db.flush() + r = client.get(f"/api/books/{book.id}/position-history") + assert r.status_code == 200 + data = r.json() + assert data["current"]["percentage"] == 0.9 + assert [h["percentage"] for h in data["history"]] == [0.9, 0.5, 0.1] + + +def test_restore_reverts_false_completion(client: TestClient, db: Session, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Jumped Book") + upsert_position(db, user_id=user.id, book_id=book.id, + percentage=0.45, progress="cfi(mid)", device="Kindle") + # The bad sync: device reports 100%, book gets finished. + upsert_position(db, user_id=user.id, book_id=book.id, + percentage=1.0, progress="cfi(end)", device="Kindle") + db.add(UserBookStatus(user_id=user.id, book_id=book.id, status="read", + progress_pct=1.0, finished_at=datetime(2026, 7, 1))) + db.flush() + + hid = next(h["id"] for h in client.get(f"/api/books/{book.id}/position-history").json()["history"] + if h["percentage"] == 0.45) + r = client.post(f"/api/books/{book.id}/position-history/{hid}/restore") + assert r.status_code == 200 + assert r.json()["status"] == "reading" + + data = client.get(f"/api/books/{book.id}/position-history").json() + assert data["current"]["percentage"] == 0.45 + assert data["current"]["device"] == "restore" + status = db.query(UserBookStatus).filter_by(user_id=user.id, book_id=book.id).one() + db.refresh(status) + assert status.status == "reading" + assert status.finished_at is None + assert status.progress_pct == 0.45 + + +def test_restore_rejects_foreign_entry(client: TestClient, db: Session, admin_user, make_book): + from backend.core.security import hash_password + from backend.models.user import User, UserPermission + + other = User(username="ph_other", email="ph_other@example.com", + hashed_password=hash_password("pass1234"), is_active=True, + is_admin=False, role="member", must_change_password=False) + db.add(other) + db.flush() + db.add(UserPermission(user_id=other.id)) + book = make_book(title="Foreign Book") + upsert_position(db, user_id=other.id, book_id=book.id, + percentage=0.3, progress="cfi(x)", device="Kindle") + db.flush() + foreign_id = _entries(db, other.id, book.id)[0].id + # Admin (the client's default auth) must not restore another user's entry. + r = client.post(f"/api/books/{book.id}/position-history/{foreign_id}/restore") + assert r.status_code == 404 diff --git a/tests/test_reading_import.py b/tests/test_reading_import.py new file mode 100644 index 0000000..f7c1f48 --- /dev/null +++ b/tests/test_reading_import.py @@ -0,0 +1,94 @@ +"""Goodreads / StoryGraph CSV import — parse, match, fill-gaps apply.""" +import io + +from sqlalchemy.orm import Session +from starlette.testclient import TestClient + +from backend.models.user_book_status import UserBookStatus +from backend.services.reading_import import parse_csv + +GOODREADS = '''Book Id,Title,Author,ISBN,ISBN13,My Rating,Date Read,Exclusive Shelf,My Review +1,"Mistborn: The Final Empire","Brandon Sanderson","=""0765311780""","=""9780765311788""",5,2023/07/18,read,"Loved it" +2,"The Way of Kings","Brandon Sanderson",,,0,,currently-reading, +3,"Some TBR Book","Nobody",,,0,,to-read, +''' + +STORYGRAPH = """Title,Authors,ISBN/UID,Format,Read Status,Last Date Read,Star Rating,Review +"Mistborn: The Final Empire","Brandon Sanderson",9780765311788,paperback,read,2023-07-18,4.5, +"Unknown Thing","Ghost Writer",,,read,2022-01-01,3.0, +""" + + +def test_parse_goodreads_dialect(): + dialect, rows, skipped = parse_csv(GOODREADS.encode()) + assert dialect == "goodreads" + assert skipped == 1 # to-read dropped + assert rows[0]["isbn"] == "9780765311788" # ="..." unwrapped + assert rows[0]["rating"] == 5.0 + assert rows[0]["finished_on"] == "2023-07-18" + assert rows[1]["status"] == "reading" + assert rows[1]["rating"] is None + + +def test_parse_storygraph_dialect(): + dialect, rows, skipped = parse_csv(STORYGRAPH.encode()) + assert dialect == "storygraph" + assert rows[0]["rating"] == 4.5 + + +def test_preview_matches_and_flags_gaps(client: TestClient, db: Session, admin_user, make_book): + user, _ = admin_user + b1 = make_book(title="Mistborn: The Final Empire", author="Brandon Sanderson") + b1.isbn = "9780765311788" + b2 = make_book(title="The Way of Kings", author="Brandon Sanderson") + # b1 already has a rating -> preview must mark rating as no-op + db.add(UserBookStatus(user_id=user.id, book_id=b1.id, rating=3.0)) + db.flush() + + r = client.post("/api/import/reading-csv", + files={"file": ("goodreads.csv", io.BytesIO(GOODREADS.encode()), "text/csv")}) + assert r.status_code == 200 + data = r.json() + assert data["dialect"] == "goodreads" + assert data["skipped_unread"] == 1 + by_title = {m["title"]: m for m in data["matched"]} + m1 = by_title["Mistborn: The Final Empire"] + assert m1["book_id"] == b1.id + assert m1["match_via"] == "isbn" + assert m1["will_apply"]["rating"] is False # existing rating survives + assert m1["will_apply"]["status"] is True + m2 = by_title["The Way of Kings"] + assert m2["match_via"] == "title" + assert data["unmatched"] == [] + + +def test_apply_fills_gaps_only(client: TestClient, db: Session, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Gapped Book", author="A. Author") + db.add(UserBookStatus(user_id=user.id, book_id=book.id, + status="reading", rating=None)) + db.flush() + + r = client.post("/api/import/reading-csv/apply", json={"items": [{ + "book_id": book.id, "status": "read", "rating": 4.0, + "finished_on": "2023-05-01", "review": "imported review", + }]}) + assert r.status_code == 200 + applied = r.json()["applied"] + # status NOT applied (already 'reading'); rating/finish/review filled. + assert applied["status"] == 0 + assert applied["rating"] == 1 + assert applied["finished_on"] == 1 + assert applied["review"] == 1 + + row = db.query(UserBookStatus).filter_by(user_id=user.id, book_id=book.id).one() + db.refresh(row) + assert row.status == "reading" # untouched + assert row.rating == 4.0 + assert row.finished_at.date().isoformat() == "2023-05-01" + + +def test_bad_csv_rejected(client: TestClient): + r = client.post("/api/import/reading-csv", + files={"file": ("x.csv", io.BytesIO(b"foo,bar\n1,2\n"), "text/csv")}) + assert r.status_code == 422