Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py, reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung (Terminal hat keine Pixel-Geometrie), was funktioniert, weil der Furthest-Wins-Vergleich primär nach blockIndex sortiert. Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt eines einzelnen riesigen Static — bei großen Büchern (mehrere reale heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und zentriert. Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js' _looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele häufig auf einem inneren <a> statt dem umschließenden <p> sitzen. Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei offenen server-verknüpften Büchern Updates zurück (force: false, im Hintergrund-Worker). Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback localStorage.getItem(...) falls die Clipboard-API in der Konsole verweigert wird. Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a. 3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll, Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s, warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine Race-Condition beim ersten Post-Load-Scroll, und Progress-Push End-to-End gegen einen echten Dev-Server verifiziert.
103 lines
3.5 KiB
Python
103 lines
3.5 KiB
Python
"""Fetch + decrypt books from a diora server into the local TUI library, and
|
|
pull reading progress for them into the local progress store.
|
|
|
|
Progress is pull-only here (server -> local); the reader pushes local ->
|
|
server itself while a book is open (see reader_screen.py). Both directions
|
|
use the same "blockIndex:innerFraction" anchor format as the web reader
|
|
(diora_tui/blocks.py) and the same furthest-wins merge rule
|
|
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
|
|
only ever advance local progress, never regress it, unless forced.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
from . import api, crypto
|
|
from .config import RemoteConfig
|
|
from .epub import _book_id
|
|
from .progress import Position, save as save_progress
|
|
|
|
|
|
class SyncError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class SyncResult:
|
|
downloaded: list[str] = field(default_factory=list)
|
|
unchanged: int = 0
|
|
skipped_pdf: list[str] = field(default_factory=list)
|
|
failed: list[str] = field(default_factory=list)
|
|
progress_pulled: int = 0
|
|
|
|
|
|
def _sanitize_filename(name: str) -> str:
|
|
name = re.sub(r"[^\w\s.-]", "_", name).strip()
|
|
return (name or "buch")[:80]
|
|
|
|
|
|
def _parse_meta(raw: bytes) -> dict:
|
|
return json.loads(raw.decode("utf-8"))
|
|
|
|
|
|
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
|
try:
|
|
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
|
|
except api.ApiError as e:
|
|
raise SyncError(str(e)) from e
|
|
except requests.RequestException as e:
|
|
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
|
|
|
|
library_dir.mkdir(parents=True, exist_ok=True)
|
|
result = SyncResult()
|
|
local_path_by_server_id: dict[int, Path] = {}
|
|
|
|
for book in snapshot.get("books", []):
|
|
book_id = book["id"]
|
|
try:
|
|
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"]))
|
|
except crypto.DecryptError:
|
|
result.failed.append(f"#{book_id}")
|
|
continue
|
|
|
|
if meta.get("type") == "pdf":
|
|
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
|
|
continue
|
|
|
|
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
|
|
if existing is not None:
|
|
result.unchanged += 1
|
|
local_path_by_server_id[book_id] = existing
|
|
continue
|
|
|
|
try:
|
|
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id)
|
|
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
|
|
except (api.ApiError, crypto.DecryptError, requests.RequestException):
|
|
result.failed.append(meta.get("title") or f"#{book_id}")
|
|
continue
|
|
|
|
title = meta.get("title") or f"book-{book_id}"
|
|
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
|
|
dest.write_bytes(raw)
|
|
result.downloaded.append(dest.name)
|
|
local_path_by_server_id[book_id] = dest
|
|
|
|
for entry in snapshot.get("book_progress", []):
|
|
anchor = entry.get("position_anchor") or ""
|
|
if not anchor:
|
|
continue # PDF-only scroll_fraction progress — this TUI is EPUB-only
|
|
dest = local_path_by_server_id.get(entry.get("book_id"))
|
|
if dest is None:
|
|
continue
|
|
local_id = _book_id(dest)
|
|
if save_progress(local_id, Position(anchor=anchor)):
|
|
result.progress_pulled += 1
|
|
|
|
return result
|