diora-web/tui/diora_tui/progress.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

68 lines
2.2 KiB
Python

"""Local reading-progress storage.
Positions are stored as the same "blockIndex:innerFraction" anchor string the
server uses (books/models.py, EBookProgress.save_progress) — see
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
exactly, and anchor_is_further() for the identical "furthest wins" comparison
used server-side (_progress_is_further in books/views.py). A saved position
only ever advances (unless forced), so an older/offline run can't regress a
further-along read position — matching that same rule locally.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from pathlib import Path
from platformdirs import user_data_dir
from .blocks import anchor_is_further
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_PROGRESS_FILE = _DATA_DIR / "progress.json"
@dataclass
class Position:
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
updated_at: float = 0.0
def _load_all() -> dict[str, dict]:
if not _PROGRESS_FILE.exists():
return {}
try:
return json.loads(_PROGRESS_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
def load(book_id: str) -> Position | None:
entry = _load_all().get(book_id)
if not isinstance(entry, dict) or not entry.get("anchor"):
return None
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
"""Returns True if the position was actually written (i.e. it was further
along, or forced) — callers that push to the server only need to do so
when this returns True."""
data = _load_all()
existing = data.get(book_id)
if existing is not None and not force:
old_anchor = existing.get("anchor", "")
if not anchor_is_further(position.anchor, old_anchor):
return False
if not position.updated_at:
position.updated_at = time.time()
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
_save_all(data)
return True