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