"""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, library_meta 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 read_status = { _book_id(path): bool(book.get("is_read")) for book in snapshot.get("books", []) if (path := local_path_by_server_id.get(book["id"])) is not None } library_meta.set_many(read_status) return result