progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.
Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
64 lines
2.6 KiB
Python
64 lines
2.6 KiB
Python
"""HTTP client for the diora server, authenticated by the account's API token
|
||
(Authorization: Bearer; the server sets request.user for every endpoint).
|
||
|
||
Only what the reader needs: the book list with the reading state (`GET /books/` – the same
|
||
rows the apps pull, without the notes' blobs that `/api/sync/` carries), a book's encrypted
|
||
file, and a position write (`POST /books/<id>/progress/`, docs/progress-sync.md).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
|
||
import requests
|
||
|
||
USER_AGENT = "diora-tui"
|
||
|
||
|
||
class ApiError(Exception):
|
||
pass
|
||
|
||
|
||
def _headers(token: str) -> dict[str, str]:
|
||
return {"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT}
|
||
|
||
|
||
def _check(resp: requests.Response, what: str) -> None:
|
||
if resp.status_code == 401:
|
||
raise ApiError("Anmeldung abgelehnt — Token ungültig oder neu erzeugt? `diora-tui login` ausführen.")
|
||
if resp.status_code == 404:
|
||
raise ApiError(f"{what} nicht gefunden (gelöscht oder anderes Konto?)")
|
||
resp.raise_for_status()
|
||
|
||
|
||
def fetch_books(server_url: str, token: str) -> tuple[list[dict], int | None, int]:
|
||
"""(books, the server's clock in ms from X-Server-Now or None, this machine's clock in ms when
|
||
the answer came). Each book: id, meta_ct, meta_iv, uploaded_at, is_read, has_cover and the
|
||
reading state (scroll_fraction, position_anchor, read_at, device, furthest_*)."""
|
||
resp = requests.get(f"{server_url}/books/", headers=_headers(token), timeout=60)
|
||
device_now = int(time.time() * 1000)
|
||
_check(resp, "Bücherliste")
|
||
try:
|
||
server_now = int(resp.headers.get("X-Server-Now", ""))
|
||
except ValueError:
|
||
server_now = None
|
||
return resp.json(), server_now, device_now
|
||
|
||
|
||
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
|
||
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=300)
|
||
_check(resp, f"Buch {book_id}")
|
||
return resp.json()
|
||
|
||
|
||
def post_progress(server_url: str, token: str, book_id: int, *, anchor: str, fraction: float, read_at: int,
|
||
device: str, force: bool = False) -> dict:
|
||
"""Send a queued position. `sent_at` is the moment it leaves: the server corrects this
|
||
machine's clock error with it. The answer is the state after the write."""
|
||
body = {"scroll_fraction": fraction, "position_anchor": anchor, "read_at": read_at,
|
||
"sent_at": int(time.time() * 1000), "device": device}
|
||
if force:
|
||
body["force"] = True
|
||
resp = requests.post(f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30)
|
||
_check(resp, f"Buch {book_id}")
|
||
return resp.json()
|