"""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)