"""Fetch + decrypt books from a diora server into the local TUI library. Reading progress is intentionally NOT synced yet: the server tracks EPUB position as a "blockIndex:innerFraction" anchor into the *web* reader's flat paragraph numbering (see books/models.py, EBookProgress), which doesn't line up with this TUI's per-chapter scroll_fraction (diora_tui/progress.py) without an actual translation layer between the two block-numbering schemes. Downloaded books simply start fresh in the local progress store; wiring up that translation is future work. """ from __future__ import annotations import json import re from dataclasses import dataclass, field from pathlib import Path import requests from . import api, crypto from .config import RemoteConfig 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) 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() 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 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) return result