Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs (Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet (marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert (Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen, a öffnet die Liste (springen, Notiz ändern, löschen). Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt. Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf (tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der Server-Smoke schreibt und mischt zwei Geräte. 75 Tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
193 lines
7.8 KiB
Python
193 lines
7.8 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 annotations, 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 pull_annotations(cfg: RemoteConfig, server_id: int, key: str) -> bool:
|
|
"""The server's highlights and bookmarks of the book, laid under our queued edits. False when
|
|
offline or the key doesn't open them (what is local stays)."""
|
|
store = annotations.default()
|
|
try:
|
|
for kind in annotations.KINDS:
|
|
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, server_id, kind))
|
|
store.take_remote(key, kind, theirs)
|
|
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
|
|
return False
|
|
return True
|
|
|
|
|
|
def push_annotations(cfg: RemoteConfig) -> int:
|
|
"""Write every queued list: merged with the server's current one first (annotations.py).
|
|
Stops quietly at network trouble; the rest stays queued."""
|
|
store = annotations.default()
|
|
written = 0
|
|
for key in store.dirty_keys():
|
|
if not (key.startswith("s") and key[1:].isdigit()):
|
|
continue
|
|
book_id = int(key[1:])
|
|
for kind in annotations.KINDS:
|
|
if not store.get(key).dirty[kind]:
|
|
continue
|
|
try:
|
|
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, book_id, kind))
|
|
merged, snapshot = store.for_push(key, kind, theirs)
|
|
blob = annotations.encode(cfg.enc_key_b64, merged)
|
|
if len(blob["ct"]) * 3 // 4 > annotations.MAX_BYTES[kind]:
|
|
raise SyncError("Zu viele Markierungen für dieses Buch (Grenze des Servers erreicht).")
|
|
api.post_annotations(cfg.server_url, cfg.api_token, book_id, kind, blob)
|
|
except api.ApiError as e:
|
|
if "nicht gefunden" in str(e):
|
|
continue # the book is gone
|
|
raise SyncError(str(e)) from e
|
|
except (crypto.DecryptError, ValueError) as e:
|
|
raise SyncError("Die Markierungen auf dem Server lassen sich nicht entschlüsseln.") from e
|
|
except requests.RequestException:
|
|
return written
|
|
store.pushed(key, kind, merged, snapshot)
|
|
written += 1
|
|
return written
|
|
|
|
|
|
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)
|
|
try:
|
|
push_annotations(cfg)
|
|
except SyncError:
|
|
pass # the notes stay queued; the book list is what this call is for
|
|
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
|