diora-web/tui/diora_tui/progress.py
marwin f63bd1f879 TUI: lokaler EPUB-Reader als erste Stufe eines diora-TUI-Clients
Python + Textual, unter tui/. Liest EPUBs aus einem Bibliotheksordner,
zeigt Kapitel als Fließtext, j/k/Pfeiltasten zum Scrollen, n/p für
Kapitelwechsel. Fortschritt wird lokal (progress.json via platformdirs)
nach demselben "furthest wins"-Prinzip wie diora's Web-Reader gespeichert,
damit ein Sync-Layer gegen die künftige diora-API später ohne Rewrite
andocken kann. Vorerst rein lokal, kein Server-Kontakt, keine
Verschlüsselung.
2026-08-15 16:04:18 +02:00

63 lines
1.9 KiB
Python

"""Local reading-progress storage.
Mirrors diora's web-reader "furthest wins" semantics (see books/models.py,
EBookProgress.save_progress): a saved position only ever advances, so an
older or offline run can't regress a further-along read position. The
position is kept anchor-shaped (chapter index + fraction within it) so a
future sync layer against diora's API can adopt it without a rewrite.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from platformdirs import user_data_dir
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_PROGRESS_FILE = _DATA_DIR / "progress.json"
@dataclass
class Position:
chapter_index: int
scroll_fraction: float # 0..1 within the chapter's paragraph list
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 entry is None:
return None
return Position(chapter_index=entry["chapter_index"], scroll_fraction=entry["scroll_fraction"])
def _is_further(new: Position, old: Position) -> bool:
if new.chapter_index != old.chapter_index:
return new.chapter_index > old.chapter_index
return new.scroll_fraction > old.scroll_fraction
def save(book_id: str, position: Position, *, force: bool = False) -> None:
data = _load_all()
existing = data.get(book_id)
if existing is not None and not force:
old = Position(chapter_index=existing["chapter_index"], scroll_fraction=existing["scroll_fraction"])
if not _is_further(position, old):
return
data[book_id] = asdict(position)
_save_all(data)