diora-web/tui/diora_tui/remote.py
mrwnslz 695037fb9c TUI: Bibliothek wie im Web (Weiterlesen-Karte, Status- und Ordner-Chips, Suche, Sortierung, Länge)
library.py hält die Regeln der Web-Bibliothek (53-library.js) als reine Funktionen:
Status Angefangen/Ungelesen/Gelesen, Ordner (mehrere je Buch, Reihenfolge aus den
verschlüsselten Bibliothekseinstellungen, "Ohne Ordner"), Suche ohne Akzente, sechs
Sortierungen mit natürlicher Zahlenordnung, Weiterlesen-Karte aus den zuletzt gelesenen,
Bücher/Zeitschriften. library_screen.py zeigt sie an (Tasten 1-4, o, /, s, m, c; Chips und
Karte klickbar). Metadaten (Ordner, Seitenzahl, Zeitschrift) und die Ordnerreihenfolge
kommen beim Abgleich; fehlt die Länge, rechnet die TUI sie für geladene Bücher aus dem Text
(1800 Zeichen je Seite). crypto.encrypt für Stufe 3. 54 Tests, Smoke prüft Ordner/Länge/Prefs
gegen den lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:18:49 +01:00

144 lines
5.5 KiB
Python

"""Talk to the diora server: pull the book list and reading state, push queued positions,
download a book when it is opened.
Reading state follows docs/progress-sync.md (progress.py holds the rules). Books are listed
from the server's list and fetched only when opened (the file is encrypted: decrypted here
with the account's key and stored as a plain .epub in the library folder).
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
import requests
from . import api, crypto, library, library_meta, progress
from .config import RemoteConfig
from .epub import server_key
class SyncError(Exception):
pass
@dataclass
class SyncResult:
books: int = 0
skipped_pdf: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
pushed: int = 0
new_titles: 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 local_path(library_dir: Path, server_id: int) -> Path | None:
return next(library_dir.glob(f"{server_id:04d} - *.epub"), None)
def flush_pending(cfg: RemoteConfig) -> int:
"""Send every queued position. Stops quietly at the first network trouble (the rest stays
queued); a book the server no longer has drops its queue."""
store = progress.default()
pushed = 0
for key in store.pending_keys():
st = store.get(key)
if not (key.startswith("s") and key[1:].isdigit() and st and st.pending):
continue
body = dict(st.pending)
try:
answer = api.post_progress(
cfg.server_url, cfg.api_token, int(key[1:]), anchor=body["anchor"], fraction=body["fraction"],
read_at=body["read_at"], device=progress.DEVICE_NAME, force=bool(body.get("force")),
)
except api.ApiError as e:
if "nicht gefunden" in str(e):
store.sent(key, body, None)
continue
raise SyncError(str(e)) from e
except requests.RequestException:
break
store.sent(key, body, answer)
pushed += 1
return pushed
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
"""Push what is queued, then pull the book list and every book's reading state."""
result = SyncResult()
try:
result.pushed = flush_pending(cfg)
books, server_now, device_now = api.fetch_books(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
store = progress.default()
store.set_server_now(server_now, device_now)
known = library_meta.remote_entries()
entries: dict[str, dict] = {}
for b in books:
try:
meta = json.loads(crypto.decrypt(cfg.enc_key_b64, b["meta_iv"], b["meta_ct"]).decode("utf-8"))
except (crypto.DecryptError, ValueError):
result.failed.append(f"#{b['id']}")
continue
title = meta.get("title") or f"Buch {b['id']}"
if meta.get("type") == "pdf":
result.skipped_pdf.append(title)
continue
key = server_key(b["id"])
entries[key] = {"server_id": b["id"], "title": title, "author": meta.get("author") or "",
"is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or "",
"folders": library.folders_of_meta(meta), "pages": int(meta.get("pages") or 0),
"magazine": meta.get("magazine") if isinstance(meta.get("magazine"), bool) else None}
if key not in known:
result.new_titles.append(title)
store.apply_server(key, b)
library_meta.set_remote(entries)
result.books = len(entries)
_pull_folder_order(cfg)
return result
def _pull_folder_order(cfg: RemoteConfig) -> None:
"""The folders' order: encrypted library prefs {folder_order: [...]}. Best effort."""
try:
prefs = api.fetch_prefs(cfg.server_url, cfg.api_token)
if not prefs.get("prefs_ct"):
return
plain = json.loads(crypto.decrypt(cfg.enc_key_b64, prefs["prefs_iv"], prefs["prefs_ct"]).decode("utf-8"))
order = plain.get("folder_order")
if isinstance(order, list):
library_meta.set_extra("folder_order", [f for f in order if isinstance(f, str)])
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
pass
def download_book(library_dir: Path, cfg: RemoteConfig, server_id: int, title: str) -> Path:
existing = local_path(library_dir, server_id)
if existing is not None:
return existing
try:
data = api.fetch_book_data(cfg.server_url, cfg.api_token, server_id)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
except api.ApiError as e:
raise SyncError(str(e)) from e
except crypto.DecryptError as e:
raise SyncError("Das Buch lässt sich mit dem gespeicherten Schlüssel nicht entschlüsseln.") 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)
dest = library_dir / f"{server_id:04d} - {_sanitize_filename(title)}.epub"
tmp = dest.with_suffix(".part")
tmp.write_bytes(raw)
os.replace(tmp, dest)
return dest