Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten (Server-URL, Token, Base64-Key) landen auf Wunsch in ~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf. Lesefortschritt wird bewusst noch nicht synced: der Server verankert Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern. Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download, Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
86 lines
2.8 KiB
Python
86 lines
2.8 KiB
Python
"""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
|