diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7bc3f..d70bd91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,57 @@ 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 +- **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_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/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/main.py b/backend/main.py index 8dd0113..ba56f7b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,9 @@ 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.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 +736,8 @@ 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(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/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/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/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/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..1c018ad 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -11,6 +11,7 @@ import { import { DOCS, docsLink } from '@/lib/docs' import { MetadataManager } from '@/components/MetadataManager' import { LibraryHealthTab } from '@/components/LibraryHealth' +import { CoverAudit } from '@/components/CoverAudit' import { WordCountTab } from '@/components/WordCount' import { SeriesCoverageStrip } from '@/components/SeriesCoverageStrip' import { useAuth, isAdmin } from '@/contexts/AuthContext' @@ -2004,7 +2005,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 +2032,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 +2077,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..0043cb1 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,12 +1,13 @@ 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 { HardcoverSync } from '@/components/HardcoverSync' import { api } from '@/lib/api' import { cn } from '@/lib/utils' @@ -45,6 +46,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 +1494,12 @@ export function SettingsPage() { {/* ── Export ───────────────────────────────────────────────────── */} + {/* ── Outbound notifications ────────────────────────────────────── */} +
+ + +
+
@@ -1528,6 +1548,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_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