diora-web/tui/diora_tui/remote.py
marwin db3f520632 TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync
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.
2026-08-15 17:59:46 +02:00

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