From 51b7316398447a5b822639b604ec004b0fa7f822 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 26 Oct 2026 13:08:59 +0100 Subject: [PATCH] =?UTF-8?q?TUI:=20Lesestand-Vertrag=20(zuletzt=20gelesen?= =?UTF-8?q?=20gewinnt),=20Login=20wie=20diora=5Fclient,=20B=C3=BCcher=20be?= =?UTF-8?q?im=20=C3=96ffnen=20laden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tui/README.md | 98 ++++++------ tui/diora_tui/api.py | 68 +++++--- tui/diora_tui/app.py | 210 +++++++++++++------------ tui/diora_tui/app.tcss | 17 ++ tui/diora_tui/book_view.py | 23 ++- tui/diora_tui/cache.py | 5 +- tui/diora_tui/config.py | 50 ++++-- tui/diora_tui/epub.py | 24 ++- tui/diora_tui/library_meta.py | 46 +++--- tui/diora_tui/login.py | 109 +++++++++++++ tui/diora_tui/paths.py | 30 ++++ tui/diora_tui/progress.py | 253 ++++++++++++++++++++++++------ tui/diora_tui/reader_screen.py | 232 ++++++++++++++++++++++----- tui/diora_tui/remote.py | 149 ++++++++++-------- tui/diora_tui/secretstore.py | 55 +++++++ tui/tests/conftest.py | 1 + tui/tests/test_app_smoke.py | 38 +++++ tui/tests/test_progress.py | 142 +++++++++++++++++ tui/tests/test_reader_progress.py | 129 +++++++++++++++ tui/tests/test_remote.py | 59 +++++++ tui/tools/smoke_server.py | 88 +++++++++++ 21 files changed, 1454 insertions(+), 372 deletions(-) create mode 100644 tui/diora_tui/login.py create mode 100644 tui/diora_tui/paths.py create mode 100644 tui/diora_tui/secretstore.py create mode 100644 tui/tests/test_progress.py create mode 100644 tui/tests/test_reader_progress.py create mode 100644 tui/tests/test_remote.py create mode 100644 tui/tools/smoke_server.py diff --git a/tui/README.md b/tui/README.md index 40fcd74..85c2afb 100644 --- a/tui/README.md +++ b/tui/README.md @@ -11,10 +11,9 @@ Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in beide Richtungen synchronisieren (siehe unten). -Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben -("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via -`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene -Position zurücksetzt. +Die Lesestelle folgt dem Vertrag aus `docs/progress-sync.md`: **die zuletzt gelesene Stelle +gewinnt** (auf welchem Gerät auch immer), die weiteste bleibt als Hinweis erhalten. Die +Regeln stehen in `progress.py` und sind dieselben wie auf dem Server und in den Apps. ## Setup @@ -33,6 +32,7 @@ Tastenkürzel: - `↑`/`k`, `↓`/`j` — zeilenweise scrollen - `n` — nächstes Kapitel, `p` — vorheriges Kapitel +- `w` — zur weitesten Stelle des Buchs springen (auf Wunsch; beim Öffnen weist ein Hinweis darauf hin) - `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q` zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s `_looksLikeFootnoteLink` (Link in/um ``, Klassenname mit note/footnote/fn, oder @@ -47,63 +47,54 @@ volle Terminalbreite bei breiten Fenstern). Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit, sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht. -Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand -des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher -standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json` -gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung. +Die Bibliotheksansicht ist nach der zuletzt gelesenen Stelle sortiert (neueste zuerst, auch +was auf einem anderen Gerät gelesen wurde), zeigt den Lesestand in % und blendet gelesene +Bücher standardmäßig aus (`r` zeigt sie, für diese Sitzung). Bücher vom Server stehen mit ☁ +in der Liste, bevor sie heruntergeladen sind. -## Bücher + Fortschritt vom Server holen (`diora-tui sync`) - -Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe -unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue -Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von -selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App -tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für -Nicht-interaktive Nutzung (Cron o.ä.): +## Anmelden und synchronisieren ```bash -diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage -diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern +diora-tui login # einmal; fragt Benutzername und Passwort +diora-tui # die Oberfläche, synct beim Start und beim Beenden +diora-tui sync # nur abgleichen, ohne Oberfläche +diora-tui logout # gespeicherte Zugangsdaten löschen ``` -Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books//data/` herunter, -entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als -normale `.epub`-Dateien in `--library` ab (Dateiname ` - .epub`) — von da an -funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden -beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot -wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch). +`login` meldet sich wie die Apps über das Web-Formular an, liest den vorhandenen API-Token +von der Einstellungsseite (es wird nie ein neuer erzeugt: das würde die Apps abmelden) und +leitet den Buchschlüssel aus dem Passwort ab (PBKDF2, wie „Mit Passwort entsperren“ im Web). +Das Passwort wird nicht gespeichert. Passt der abgeleitete Schlüssel nicht (Passwort seit dem +Hochladen geändert, Schlüssel zufällig erzeugt), fragt `login` nach dem Schlüssel +(diora: Einstellungen → Schlüssel kopieren; `--paste-key` fragt gleich danach). -Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am ` - `-Dateinamens- -Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server -(`POST /books//progress/`, `force: false` — überschreibt also nie eine weiter -gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein -lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff. +Token und Schlüssel liegen geschützt in `config.json`: unter Windows per DPAPI (nur dieser +Windows-Benutzer auf diesem Rechner kann sie lesen), sonst als Base64 in einer 0600-Datei. +Eine ältere Konfiguration mit Klartextfeldern wird beim ersten Lesen geschützt neu geschrieben. +Alle Dateien liegen unter `platformdirs`' Verzeichnissen; `DIORA_TUI_HOME=` legt sie +stattdessen gemeinsam in einen Ordner (zweites Konto, Tests). -Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter -`~/.config/diora-tui/config.json`, `chmod 600`): +**Was synchronisiert wird:** die Bücherliste (`GET /books/`, ohne die Blobs der Markierungen) +samt Lesestand, Gelesen-Status und der Uhr des Servers. Die Bücher selbst werden erst beim +Öffnen geladen und entschlüsselt (` - .epub` im Bibliotheksordner). PDFs werden +übersprungen. -- **Server-URL** — z.B. `https://diora.creamfresh.xyz`. -- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token". -- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig - verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege: - 1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's - "Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000 - Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`). - Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt - hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese - Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als - Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis. - 2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der - Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in - `app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in - der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die - lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins - Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem - `NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage` - lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`. +**Lesestand** (`progress.py`, `reader_screen.py`): -Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der -Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei. +- Gespeichert wird nur, was der Leser selbst bewegt hat (Tasten, Mausrad), mit dem Zeitpunkt + des Ankommens (`read_at`). Wiederherstellen, Umbruch bei Größenänderung und Sprünge auf + Angebote ändern nichts. Ein offenes, ruhendes Buch kann deshalb nie eine neuere Stelle von + woanders überschreiben. +- Zeiten sind Serverzeit: die TUI lernt aus jeder Antwort den Versatz ihrer Uhr, und der + Server korrigiert mit `sent_at` nach. Ein falsch gehender Rechner ordnet trotzdem richtig. +- Jede Stelle wird lokal angewendet und vorgemerkt (`pending`, je Buch die später gelesene), + bis der Server sie hat, auch offline; nach einem Abgleich wird sie nach derselben Regel auf + den Stand des Servers gelegt. +- Alle zwei Minuten fragt ein offenes Buch den Server. Liegt dort eine neuere Stelle von einem + anderen Gerät, fragt die TUI „Auf *X* weitergelesen — bei n %. Springen?“ (`y`/`n`), und + bewegt sich nie von allein. +- Der Fortschritt in % ist wie im Web der Anteil am Buchtext in Zeichen + (`blocks.fraction_for_anchor`), nicht mehr 0. ## Performance bei großen Büchern @@ -139,8 +130,7 @@ fügt Leerraum ein). ## Grenzen der aktuellen Version -- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen, - wird gemeldet), da der Reader sie ohnehin nicht darstellen kann. +- Nur EPUB, kein PDF (der Abgleich überspringt PDFs und meldet sie). - Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt. - `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung (zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung diff --git a/tui/diora_tui/api.py b/tui/diora_tui/api.py index 09890c7..0d9d14a 100644 --- a/tui/diora_tui/api.py +++ b/tui/diora_tui/api.py @@ -1,48 +1,64 @@ -"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for -local clients"). Status: that API is provisional — expect endpoint/field -changes as the server-side implementation settles. +"""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//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}"} + return {"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT} -def fetch_sync_snapshot(server_url: str, token: str) -> dict: - resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30) +def _check(resp: requests.Response, what: str) -> None: if resp.status_code == 401: - raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") + 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() - return resp.json() + + +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=120) - if resp.status_code == 401: - raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") - if resp.status_code == 404: - raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)") - resp.raise_for_status() + 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, *, scroll_fraction: float, position_anchor: str, force: bool = False -) -> None: - body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force} - resp = requests.post( - f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30 - ) - if resp.status_code == 401: - raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") - if resp.status_code == 404: - raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)") - resp.raise_for_status() +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() diff --git a/tui/diora_tui/app.py b/tui/diora_tui/app.py index 883df81..cbba688 100644 --- a/tui/diora_tui/app.py +++ b/tui/diora_tui/app.py @@ -1,11 +1,11 @@ -"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress +"""diora-tui: terminal EPUB reader for diora, syncing books + reading position against a diora server (see project README).""" from __future__ import annotations import argparse import asyncio -import getpass +from dataclasses import dataclass from pathlib import Path from textual import work @@ -15,13 +15,27 @@ from textual.screen import Screen from textual.widgets import Footer, Header, Label, ListItem, ListView from . import config as config_mod -from . import crypto, epub, library_meta, progress, remote +from . import epub, library_meta, login as login_mod, progress, remote from .reader_screen import ReaderScreen from .statusbar import StatusBar DEFAULT_LIBRARY = Path.home() / "Books" +@dataclass +class Entry: + """A line of the library: a file in the folder, a server book not downloaded yet, or both.""" + + key: str + title: str + path: Path | None + server_id: int | None + is_read: bool + read_at: int + uploaded_at: str + fraction: float + + class LibraryScreen(Screen): BINDINGS = [ Binding("enter", "open_selected", "Öffnen"), @@ -32,8 +46,8 @@ class LibraryScreen(Screen): def __init__(self, library_dir: Path) -> None: super().__init__() self.library_dir = library_dir - self.paths: list[Path] = [] - self.visible_paths: list[Path] = [] + self.all_entries: list[Entry] = [] + self.shown: list[Entry] = [] self.show_read = False def compose(self) -> ComposeResult: @@ -49,47 +63,63 @@ class LibraryScreen(Screen): if cfg is not None: self._auto_sync(cfg) + def on_screen_resume(self) -> None: + self._refresh_list() # a book was read: the order and the percentages changed + @work(thread=True) def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None: try: result = remote.sync_library(self.library_dir, cfg) - except remote.SyncError: + except remote.SyncError as e: + self.app.call_from_thread(self.notify, str(e), severity="warning", timeout=5) return # best-effort — e.g. offline; the local library still works self.app.call_from_thread(self._on_auto_sync_done, result) def _on_auto_sync_done(self, result: remote.SyncResult) -> None: - if result.downloaded or result.progress_pulled: - self._refresh_list() - if result.downloaded: - self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3) + self._refresh_list() + if result.new_titles: + self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3) + + def _gather(self) -> list[Entry]: + store = progress.default() + remote_books = library_meta.remote_entries() + by_key: dict[str, Entry] = {} + for path in epub.scan_library(self.library_dir): + key = epub._book_id(path) + info = remote_books.get(key) or {} + by_key[key] = Entry(key, info.get("title") or path.stem, path, epub.server_id_of(path), + bool(info.get("is_read")), 0, info.get("uploaded_at") or "", 0.0) + for key, info in remote_books.items(): + if key not in by_key: + by_key[key] = Entry(key, info["title"], None, info["server_id"], bool(info.get("is_read")), + 0, info.get("uploaded_at") or "", 0.0) + for key, e in by_key.items(): + st = store.get(key) + if st is not None: + e.read_at = st.read_at or 0 + e.fraction = st.fraction + # last read first; never opened ones by when they came + return sorted(by_key.values(), key=lambda e: (e.read_at, e.uploaded_at), reverse=True) def _refresh_list(self) -> None: - self.paths = epub.scan_library(self.library_dir) - - entries = [] - for path in self.paths: - book_id = epub._book_id(path) - read = library_meta.is_read(book_id) - if read and not self.show_read: - continue - saved = progress.load(book_id) - last_opened = saved.updated_at if saved else 0.0 - entries.append((last_opened, path, read)) - entries.sort(key=lambda e: e[0], reverse=True) - self.visible_paths = [path for _, path, _ in entries] + self.all_entries = self._gather() + self.shown = [e for e in self.all_entries if self.show_read or not e.is_read] list_view = self.query_one("#library-list", ListView) + index = list_view.index list_view.clear() - if not self.paths: + if not self.all_entries: list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}"))) return - if not entries: + if not self.shown: list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen"))) return - for _, path, read in entries: - label = f"✓ {path.stem}" if read else path.stem - list_view.append(ListItem(Label(label))) - list_view.index = 0 + for e in self.shown: + mark = "✓ " if e.is_read else "" + cloud = "☁ " if e.path is None else "" + pct = f" {round(e.fraction * 100)} %" if e.fraction > 0 else "" + list_view.append(ListItem(Label(f"{mark}{cloud}{e.title}{pct}"))) + list_view.index = min(index or 0, len(self.shown) - 1) list_view.focus() def action_toggle_read_filter(self) -> None: @@ -100,9 +130,27 @@ class LibraryScreen(Screen): def action_open_selected(self) -> None: list_view = self.query_one("#library-list", ListView) - if not self.visible_paths or list_view.index is None: + if not self.shown or list_view.index is None or list_view.index >= len(self.shown): return - self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index])) + entry = self.shown[list_view.index] + if entry.path is not None: + self.app.push_screen(ReaderScreen(entry.path)) + return + cfg = config_mod.load() + if cfg is None or entry.server_id is None: + self.notify("Nicht angemeldet — `diora-tui login`.", severity="error") + return + self.notify(f"Lade „{entry.title}“ …", timeout=4) + self._download_and_open(cfg, entry) + + @work(thread=True, exclusive=True, group="download") + def _download_and_open(self, cfg: config_mod.RemoteConfig, entry: Entry) -> None: + try: + path = remote.download_book(self.library_dir, cfg, entry.server_id, entry.title) + except remote.SyncError as e: + self.app.call_from_thread(self.notify, str(e), severity="error", timeout=6) + return + self.app.call_from_thread(self.app.push_screen, ReaderScreen(path)) def on_list_view_selected(self, event: ListView.Selected) -> None: self.action_open_selected() @@ -123,87 +171,45 @@ class DioraTuiApp(App): cfg = config_mod.load() if cfg is not None: try: + # Sends what is queued, then pulls: nothing read stays only here. await asyncio.to_thread(remote.sync_library, self.library_dir, cfg) except remote.SyncError: pass # best-effort — don't block quitting on a sync failure self.exit() -def _prompt(label: str, *, secret: bool = False) -> str: - value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip() - if not value: - raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.") - return value +def run_login(server: str | None, *, paste_key: bool) -> None: + try: + cfg = login_mod.login(server, paste_key=paste_key) + except login_mod.LoginError as e: + raise SystemExit(f"Fehler: {e}") + config_mod.save(cfg) + print("Gespeichert (Token und Schlüssel geschützt). Weiter mit diora-tui oder diora-tui sync.") -def _ask_yes_no(question: str) -> bool: - return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja") - - -def _prompt_enc_key() -> str: - print() - print("Verschlüsselungs-Key — zwei Wege:") - print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)") - print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)") - choice = input("Wahl [1/2, Standard 1]: ").strip() or "1" - if choice == "2": - return _prompt("Verschlüsselungs-Key (Base64)", secret=True) - - print( - "Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über " - "diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. " - "War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer " - "(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne " - "etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen." - ) - username = _prompt("diora-Benutzername") - password = _prompt("diora-Passwort", secret=True) - return crypto.derive_key_b64(username, password) - - -def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig: - cfg = None if server_override else config_mod.load() - if cfg is not None: - return cfg - - print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.") - server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/") - token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True) - enc_key = _prompt_enc_key() - cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key) - if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"): - config_mod.save(cfg) - print("Gespeichert.") - return cfg - - -def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None: - cfg = _resolve_remote_config(server_override, save=save) +def run_sync(library_dir: Path) -> None: + cfg = config_mod.load() + if cfg is None: + raise SystemExit("Nicht angemeldet: erst diora-tui login") print(f"Verbinde zu {cfg.server_url} …") try: result = remote.sync_library(library_dir, cfg) except remote.SyncError as e: raise SystemExit(f"Fehler: {e}") - print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.") - for name in result.downloaded: - print(f" + {name}") - if result.unchanged: - print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.") + print(f"{result.books} EPUB(s) auf dem Server (geladen wird beim Öffnen)." ) + if result.pushed: + print(f"Lesestand von {result.pushed} Buch/Bücher an den Server geschickt.") + for title in result.new_titles: + print(f" + {title}") if result.skipped_pdf: - titles = ", ".join(result.skipped_pdf) - print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}") + print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest nur EPUB): {', '.join(result.skipped_pdf)}") if result.failed: - titles = ", ".join(result.failed) - print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}") - if result.progress_pulled: - print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.") + print(f"{len(result.failed)} Buch/Bücher nicht entschlüsselbar (falscher Schlüssel?): {', '.join(result.failed)}") def main() -> None: - parser = argparse.ArgumentParser( - description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)" - ) + parser = argparse.ArgumentParser(description="diora-tui — EPUB-Reader im Terminal, synct mit diora") parser.add_argument( "--library", type=Path, @@ -211,17 +217,23 @@ def main() -> None: help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})", ) subparsers = parser.add_subparsers(dest="command") - sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln") - sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf") - sync_parser.add_argument( - "--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen" - ) + login_parser = subparsers.add_parser("login", help="Einmal anmelden (Token + Schlüssel, geschützt gespeichert)") + login_parser.add_argument("--server", help=f"Server-URL (Standard: {login_mod.DEFAULT_SERVER})") + login_parser.add_argument("--paste-key", action="store_true", help="Buchschlüssel einfügen statt aus dem Passwort ableiten") + subparsers.add_parser("logout", help="Gespeicherte Zugangsdaten löschen") + subparsers.add_parser("sync", help="Bücherliste und Lesestand abgleichen (ohne die Oberfläche)") args = parser.parse_args() library_dir = args.library.expanduser() + if args.command == "login": + run_login(args.server, paste_key=args.paste_key) + return + if args.command == "logout": + print("Zugangsdaten gelöscht." if config_mod.forget() else "Keine Zugangsdaten gespeichert.") + return if args.command == "sync": - run_sync(library_dir, args.server, save=args.save) + run_sync(library_dir) return DioraTuiApp(library_dir).run() diff --git a/tui/diora_tui/app.tcss b/tui/diora_tui/app.tcss index 06172cd..a75e1ed 100644 --- a/tui/diora_tui/app.tcss +++ b/tui/diora_tui/app.tcss @@ -47,3 +47,20 @@ FootnoteScreen { color: $text-muted; padding: 0 2; } + +JumpOfferScreen { + align: center middle; +} + +#offer-body { + width: 60; + height: auto; + background: $panel; + border: round $primary; + padding: 1 2; +} + +#offer-hint { + margin-top: 1; + color: $text-muted; +} diff --git a/tui/diora_tui/book_view.py b/tui/diora_tui/book_view.py index 9ccc14e..455a2f2 100644 --- a/tui/diora_tui/book_view.py +++ b/tui/diora_tui/book_view.py @@ -7,6 +7,9 @@ paint time, only with viewport height. from __future__ import annotations +from contextlib import contextmanager +from typing import Callable + from textual.geometry import Size from textual.scroll_view import ScrollView from textual.strip import Strip @@ -27,10 +30,28 @@ class ContinuousBookView(ScrollView): } """ - def __init__(self, book_layout: BookLayout) -> None: + def __init__(self, book_layout: BookLayout, on_read_scroll: Callable[[], None] | None = None) -> None: super().__init__() self.book_layout = book_layout self.virtual_size = Size(book_layout.width, book_layout.total_rows) + # Called when the reader moves the view (keys, wheel, scrollbar), not when the program does + # (restoring a position, re-wrapping after a resize, jumping on an offer): only the first + # is "arriving at a place by reading" for the reading position (docs/progress-sync.md). + self.on_read_scroll = on_read_scroll + self._program_scrolls = 0 + + @contextmanager + def program_scroll(self): + self._program_scrolls += 1 + try: + yield + finally: + self._program_scrolls -= 1 + + def watch_scroll_y(self, old_value: float, new_value: float) -> None: + super().watch_scroll_y(old_value, new_value) + if old_value != new_value and not self._program_scrolls and self.on_read_scroll is not None: + self.on_read_scroll() def render_line(self, y: int) -> Strip: _scroll_x, scroll_y = self.scroll_offset diff --git a/tui/diora_tui/cache.py b/tui/diora_tui/cache.py index e3abc49..04051e1 100644 --- a/tui/diora_tui/cache.py +++ b/tui/diora_tui/cache.py @@ -14,13 +14,12 @@ import hashlib import pickle from pathlib import Path -from platformdirs import user_cache_dir - +from . import paths from .blocks import FlatBook from .layout import BookLayout _CACHE_VERSION = 2 # 2: blocks carry char_start/char_len -_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache" +_CACHE_DIR = paths.cache_dir() / "layout_cache" def _cache_key(path: Path, width: int) -> str: diff --git a/tui/diora_tui/config.py b/tui/diora_tui/config.py index bc82a5f..6fb591a 100644 --- a/tui/diora_tui/config.py +++ b/tui/diora_tui/config.py @@ -1,21 +1,20 @@ -"""Local storage for diora server credentials used by `diora-tui sync`. +"""The account the TUI syncs with: server, API token and the book key. -Mirrors the trust model of the web client's key storage (static/js/app.js, -getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept -in plaintext on disk, scoped to this machine/user (0600), with no additional -at-rest encryption — same exposure as the browser's localStorage already has. +Token and key are stored protected (secretstore.py), the server and user name in the clear. +A config from before 2026-10 (token and key as plain JSON fields) is still read and is +rewritten protected at once. """ from __future__ import annotations import json import stat -from dataclasses import asdict, dataclass +from dataclasses import dataclass from pathlib import Path -from platformdirs import user_config_dir +from . import paths, secretstore -_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora")) +_CONFIG_DIR = paths.config_dir() _CONFIG_FILE = _CONFIG_DIR / "config.json" @@ -24,19 +23,42 @@ class RemoteConfig: server_url: str api_token: str enc_key_b64: str + username: str = "" def load() -> RemoteConfig | None: - if not _CONFIG_FILE.exists(): - return None try: data = json.loads(_CONFIG_FILE.read_text()) - return RemoteConfig(**data) - except (json.JSONDecodeError, OSError, TypeError): + except (OSError, ValueError): return None + if not isinstance(data, dict): + return None + try: + if "secret" in data: + secret = json.loads(secretstore.unprotect(data["secret"])) + return RemoteConfig(data["server_url"], secret["token"], secret["key"], data.get("username", "")) + cfg = RemoteConfig(data["server_url"], data["api_token"], data["enc_key_b64"], data.get("username", "")) + except (KeyError, ValueError, OSError): + return None + save(cfg) # a legacy plaintext config: protect it now + return cfg def save(cfg: RemoteConfig) -> None: _CONFIG_DIR.mkdir(parents=True, exist_ok=True) - _CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2)) - _CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) + secret = json.dumps({"token": cfg.api_token, "key": cfg.enc_key_b64}).encode() + _CONFIG_FILE.write_text(json.dumps({ + "server_url": cfg.server_url, "username": cfg.username, "secret": secretstore.protect(secret), + }, indent=2)) + try: + _CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + + +def forget() -> bool: + try: + _CONFIG_FILE.unlink() + return True + except OSError: + return False diff --git a/tui/diora_tui/epub.py b/tui/diora_tui/epub.py index e5eaaa2..623ef92 100644 --- a/tui/diora_tui/epub.py +++ b/tui/diora_tui/epub.py @@ -1,15 +1,31 @@ -"""Library scanning and the stable per-book id shared across progress.py, -blocks.py, and remote.py.""" +"""Library scanning and the stable per-book key shared across progress.py, blocks.py and +remote.py.""" from __future__ import annotations import hashlib +import re from pathlib import Path +# Books downloaded from the server are named " - .epub". +_SERVER_FILE_RE = re.compile(r"^(\d{4,7}) - ") + + +def server_id_of(path: Path) -> int | None: + m = _SERVER_FILE_RE.match(path.name) + return int(m.group(1)) if m else None + + +def server_key(server_id: int) -> str: + return f"s{server_id}" + def _book_id(path: Path) -> str: - # Identifies a book by its resolved path for now. Once the sync API exists, - # this should switch to whatever stable id diora assigns server-side. + """A book's key for reading state: the server's id for a downloaded book (the same whether + or not, and under whatever name, the file is there), else a hash of its path.""" + sid = server_id_of(path) + if sid is not None: + return server_key(sid) return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16] diff --git a/tui/diora_tui/library_meta.py b/tui/diora_tui/library_meta.py index 5cf9ea2..53181f8 100644 --- a/tui/diora_tui/library_meta.py +++ b/tui/diora_tui/library_meta.py @@ -1,43 +1,51 @@ -"""Local per-book metadata pulled from the server — currently just "read" -status (EBook.is_read from the /api/sync/ snapshot). Kept separate from -progress.py (reading position): different concern, different cadence — this -is only ever set by `diora-tui sync`, never by the reader itself. +"""What the TUI knows about the server's books beyond the files: title, author, read status +and which books exist at all (so a book can be listed before it is downloaded). Set only by +`sync`; the reader's position is progress.py. """ from __future__ import annotations import json +import os from pathlib import Path -from platformdirs import user_data_dir +from . import paths -_DATA_DIR = Path(user_data_dir("diora-tui", "diora")) +_DATA_DIR = paths.data_dir() _META_FILE = _DATA_DIR / "library.json" def _load_all() -> dict[str, dict]: - if not _META_FILE.exists(): - return {} try: - return json.loads(_META_FILE.read_text()) - except (json.JSONDecodeError, OSError): + data = json.loads(_META_FILE.read_text()) + except (OSError, ValueError): return {} + return data if isinstance(data, dict) else {} def _save_all(data: dict[str, dict]) -> None: _DATA_DIR.mkdir(parents=True, exist_ok=True) - _META_FILE.write_text(json.dumps(data, indent=2)) + tmp = _META_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=1)) + os.replace(tmp, _META_FILE) -def is_read(book_id: str) -> bool: - entry = _load_all().get(book_id) +def is_read(book_key: str) -> bool: + entry = _load_all().get(book_key) return bool(entry and entry.get("is_read")) -def set_many(read_status: dict[str, bool]) -> None: - if not read_status: - return - data = _load_all() - for book_id, read in read_status.items(): - data.setdefault(book_id, {})["is_read"] = read +def get(book_key: str) -> dict | None: + return _load_all().get(book_key) + + +def remote_entries() -> dict[str, dict]: + """Every server book: key -> {server_id, title, author, is_read, uploaded_at}.""" + return {k: v for k, v in _load_all().items() if isinstance(v, dict) and v.get("server_id") is not None} + + +def set_remote(entries: dict[str, dict]) -> None: + """Replace the server books with `entries` (a book deleted on the server drops out).""" + data = {k: v for k, v in _load_all().items() if not (isinstance(v, dict) and v.get("server_id") is not None)} + data.update(entries) _save_all(data) diff --git a/tui/diora_tui/login.py b/tui/diora_tui/login.py new file mode 100644 index 0000000..05d0e79 --- /dev/null +++ b/tui/diora_tui/login.py @@ -0,0 +1,109 @@ +"""`diora-tui login`: sign in once like the apps do and keep what a sync needs. + +The password goes through the web login form (CSRF + session) only to read the account's +existing API token off the settings page, and to derive the book key (PBKDF2, as the web +client's "unlock with password"). The password is not stored; the session is logged out again. +A new token is never created here: that would sign out the apps using the current one. +""" + +from __future__ import annotations + +import base64 +import getpass +import html +import re + +import requests + +from . import api, crypto +from .config import RemoteConfig + +DEFAULT_SERVER = "https://diora.creamfresh.xyz" + +_CSRF = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"') +_TOKEN = re.compile(r'<code style="user-select:all[^"]*">\s*([A-Za-z0-9_-]+)\s*</code>') +_USERNAME = re.compile(r'<meta name="username" content="([^"]*)">') + + +class LoginError(Exception): + pass + + +def web_login(server: str, username: str, password: str) -> tuple[str, str]: + """(API token, canonical user name) via the web login.""" + s = requests.Session() + s.headers["User-Agent"] = api.USER_AGENT + + def get(path): + return s.get(server + path, headers={"Referer": server + path}, allow_redirects=False, timeout=30) + + def post(path, form): + return s.post(server + path, data=form, headers={"Referer": server + path, "Origin": server}, + allow_redirects=False, timeout=30) + + try: + page = get("/accounts/login/") + m = _CSRF.search(page.text) + if page.status_code != 200 or not m: + raise LoginError(f"Login-Seite nicht lesbar ({page.status_code}).") + r = post("/accounts/login/", {"csrfmiddlewaretoken": m.group(1), "username": username, "password": password}) + if r.status_code == 200: + raise LoginError("Benutzername oder Passwort falsch.") + if r.status_code != 302: + raise LoginError(f"Anmeldung fehlgeschlagen ({r.status_code}).") + try: + t = _TOKEN.search(get("/accounts/settings/").text) + if not t: + raise LoginError("Kein API-Token gefunden. Melde dich einmal in einer diora-App an, dann noch mal.") + name = _USERNAME.search(get("/").text) + return t.group(1), html.unescape(name.group(1)) if name else username + finally: + c = _CSRF.search(get("/").text) + if c: + post("/accounts/logout/", {"csrfmiddlewaretoken": c.group(1)}) + except requests.RequestException as e: + raise LoginError(f"Verbindung zu {server} fehlgeschlagen: {e}") from e + + +def _books_open(server: str, token: str, key_b64: str) -> tuple[int, int]: + """(books, books the key opens).""" + books, _, _ = api.fetch_books(server, token) + ok = 0 + for b in books: + try: + crypto.decrypt(key_b64, b["meta_iv"], b["meta_ct"]) + ok += 1 + except crypto.DecryptError: + pass + return len(books), ok + + +def login(server: str | None = None, *, paste_key: bool = False) -> RemoteConfig: + server = (server or DEFAULT_SERVER).rstrip("/") + username = input("diora-Benutzername: ").strip() + password = getpass.getpass("Passwort (wird nicht gespeichert, nicht angezeigt): ") + if not username or not password: + raise LoginError("Abgebrochen.") + token, username = web_login(server, username, password) + key = "" if paste_key else crypto.derive_key_b64(username, password) + del password + + if key: + total, ok = _books_open(server, token, key) + if total and not ok: + print("Der Schlüssel aus dem Passwort öffnet die Bücher nicht (Passwort seit dem Hochladen geändert?).") + key = "" + if not key: + key = getpass.getpass("Schlüssel einfügen (diora: Einstellungen → Schlüssel kopieren, Base64; leer = abbrechen): ").strip() + if not key: + raise LoginError("Abgebrochen, nichts gespeichert.") + try: + if len(base64.b64decode(key)) != 32: + raise ValueError + except ValueError: + raise LoginError("Das ist kein gültiger Schlüssel (32 Byte, Base64).") from None + total, ok = _books_open(server, token, key) + if total and not ok: + raise LoginError("Auch dieser Schlüssel passt nicht. Nichts gespeichert.") + print(f"Angemeldet als {username} ({total} Bücher).") + return RemoteConfig(server_url=server, api_token=token, enc_key_b64=key, username=username) diff --git a/tui/diora_tui/paths.py b/tui/diora_tui/paths.py new file mode 100644 index 0000000..0fb0045 --- /dev/null +++ b/tui/diora_tui/paths.py @@ -0,0 +1,30 @@ +"""Where the TUI keeps its files. `DIORA_TUI_HOME` moves all of it into one folder (tests, the +smoke script and anyone who wants a second account side by side use it); platformdirs' own +answer on Windows doesn't follow the LOCALAPPDATA/APPDATA environment variables.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from platformdirs import user_cache_dir, user_config_dir, user_data_dir + + +def _home() -> Path | None: + h = os.environ.get("DIORA_TUI_HOME") + return Path(h) if h else None + + +def data_dir() -> Path: + h = _home() + return h / "data" if h else Path(user_data_dir("diora-tui", "diora")) + + +def config_dir() -> Path: + h = _home() + return h / "config" if h else Path(user_config_dir("diora-tui", "diora")) + + +def cache_dir() -> Path: + h = _home() + return h / "cache" if h else Path(user_cache_dir("diora-tui", "diora")) diff --git a/tui/diora_tui/progress.py b/tui/diora_tui/progress.py index 588e24b..cbfa435 100644 --- a/tui/diora_tui/progress.py +++ b/tui/diora_tui/progress.py @@ -1,68 +1,231 @@ -"""Local reading-progress storage. +"""Reading position per book: the place last read wins. -Positions are stored as the same "blockIndex:innerFraction" anchor string the -server uses (books/models.py, EBookProgress.save_progress) — see -diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js -exactly, and anchor_is_further() for the identical "furthest wins" comparison -used server-side (_progress_is_further in books/views.py). A saved position -only ever advances (unless forced), so an older/offline run can't regress a -further-along read position — matching that same rule locally. +This is the contract in diora-web docs/progress-sync.md, the same rules the server +(books/views.py save_progress) and the other clients apply (diora-android Ops.kt, +diora-desktop src/main/ops.js): + +* A position carries `read_at`, the moment the reader arrived there. Local times are the + server's clock: `offset` (server - this machine, ms, learnt from the server's clock on every + answer) turns this machine's `read_at` into it. Saving an unchanged position again carries the + old time, so an idle reader can never beat a newer position from elsewhere. +* A write is stored when its time is >= the stored one (`force`: always, time = now). The + furthest place ever reported only grows. +* A write is also queued (`pending`, one per book: the later `read_at` wins, `force` sticks) + until the server has it. After a pull the pending write is applied again on top of the + server's state with the same rule. + +State lives in progress.json next to the book list; the file also holds the clock offset. """ from __future__ import annotations import json +import functools +import os +import threading import time -from dataclasses import dataclass +from dataclasses import asdict, dataclass, replace from pathlib import Path -from platformdirs import user_data_dir +from . import paths +from .blocks import parse_anchor -from .blocks import anchor_is_further +DEVICE_NAME = "diora TUI" -_DATA_DIR = Path(user_data_dir("diora-tui", "diora")) +_DATA_DIR = paths.data_dir() _PROGRESS_FILE = _DATA_DIR / "progress.json" +_VERSION = 2 + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def plausible_ms(v) -> int | None: + """A time (ms since epoch) from the server or a body, or None (as Ops.ms).""" + if isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v in (float("inf"), float("-inf")): + return None + t = int(v) + return t if 10**12 <= t < 10**14 else None + + +def cmp_progress(a_anchor: str, a_frac: float, b_anchor: str, b_frac: float) -> float: + """> 0 if A is further into the book than B, < 0 if behind (books/views.py _cmp_progress).""" + a, b = parse_anchor(a_anchor or ""), parse_anchor(b_anchor or "") + if a is not None and b is not None: + return (a[0] - b[0]) if a[0] != b[0] else a[1] - b[1] + return a_frac - b_frac @dataclass -class Position: - anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000" - updated_at: float = 0.0 +class BookState: + anchor: str = "" + fraction: float = 0.0 + read_at: int | None = None # server clock, ms + device: str = "" + furthest_anchor: str = "" + furthest_fraction: float = 0.0 + # The write the server hasn't got yet: {"anchor", "fraction", "read_at" (this machine's + # clock), "force"}. + pending: dict | None = None + # The server's read_at for the last write of ours that it stored: tells our own position + # coming back from a pull from another device's. + echo_read_at: int | None = None -def _load_all() -> dict[str, dict]: - if not _PROGRESS_FILE.exists(): - return {} - try: - return json.loads(_PROGRESS_FILE.read_text()) - except (json.JSONDecodeError, OSError): - return {} +def _locked(fn): + @functools.wraps(fn) + def wrapper(self, *a, **kw): + with self._lock: + return fn(self, *a, **kw) + return wrapper -def _save_all(data: dict[str, dict]) -> None: - _DATA_DIR.mkdir(parents=True, exist_ok=True) - _PROGRESS_FILE.write_text(json.dumps(data, indent=2)) +class ProgressStore: + """Shared by the UI thread (the reader writes) and the sync workers (they pull and push).""" + + def __init__(self, path: Path | None = None) -> None: + self.path = path or _PROGRESS_FILE + self._lock = threading.RLock() + self.offset = 0 + self.books: dict[str, BookState] = {} + self._load() + + # ---- persistence + + def _load(self) -> None: + try: + raw = json.loads(self.path.read_text()) + except (OSError, ValueError): + return + if not isinstance(raw, dict): + return + if raw.get("version") == _VERSION: + self.offset = int(raw.get("offset") or 0) + for key, entry in (raw.get("books") or {}).items(): + try: + self.books[key] = BookState(**entry) + except TypeError: + continue + return + # version 1: {book_id: {anchor, updated_at (s)}}, furthest-wins, nothing queued + for key, entry in raw.items(): + if isinstance(entry, dict) and entry.get("anchor"): + self.books[key] = BookState( + anchor=entry["anchor"], read_at=int(entry.get("updated_at", 0) * 1000) or None, + furthest_anchor=entry["anchor"], + ) + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + data = {"version": _VERSION, "offset": self.offset, + "books": {k: asdict(v) for k, v in self.books.items()}} + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=1)) + os.replace(tmp, self.path) + + # ---- clock + + @_locked + def set_server_now(self, server_now: int | None, device_now: int | None = None) -> None: + t = plausible_ms(server_now) + if t is not None: + self.offset = t - (device_now if device_now is not None else now_ms()) + + # ---- reads + + @_locked + def get(self, key: str) -> BookState | None: + """A copy: the sync workers change the live state.""" + st = self.books.get(key) + return replace(st, pending=dict(st.pending) if st.pending else None) if st else None + + @_locked + def pending_keys(self) -> list[str]: + return [k for k, v in self.books.items() if v.pending] + + # ---- writes + + @_locked + def write(self, key: str, anchor: str, fraction: float, read_at: int, *, force: bool = False, + queue: bool = True, device: str = DEVICE_NAME, now: int | None = None) -> bool: + """Apply a position the reader reached (`read_at`: this machine's clock) locally and + queue it for the server (`queue` False: the book isn't one of the server's). Returns + whether it is the continue point now.""" + st = self.books.setdefault(key, BookState()) + fraction = max(0.0, min(1.0, fraction)) + took = self._apply(st, anchor, fraction, read_at, force, device, now) + if queue: + new = {"anchor": anchor, "fraction": fraction, "read_at": read_at, "force": force} + st.pending = _merge_pending(st.pending, new) + self.save() + return took + + def _apply(self, st: BookState, anchor: str, fraction: float, read_at: int | None, force: bool, + device: str, now: int | None) -> bool: + now_server = (now if now is not None else now_ms()) + self.offset + if force: + when, take = now_server, True + else: + when = min((read_at or 0) + self.offset, now_server) + take = st.read_at is None or when >= st.read_at + if take: + st.anchor, st.fraction, st.read_at, st.device = anchor, fraction, when, device[:40] + if cmp_progress(anchor, fraction, st.furthest_anchor, st.furthest_fraction) > 0: + st.furthest_anchor, st.furthest_fraction = anchor, fraction + return take + + @_locked + def apply_server(self, key: str, entry: dict, *, keep_pending: bool = True) -> None: + """Take the server's state for the book (an entry of /books/ or a progress answer), then + apply the pending write on top by the same rule.""" + st = self.books.setdefault(key, BookState()) + st.anchor = entry.get("position_anchor") or "" + st.fraction = float(entry.get("scroll_fraction") or 0.0) + st.read_at = plausible_ms(entry.get("read_at")) + st.device = entry.get("device") or "" + st.furthest_anchor = entry.get("furthest_anchor") or "" + st.furthest_fraction = float(entry.get("furthest_fraction") or 0.0) + if st.pending and keep_pending: + p = st.pending + self._apply(st, p["anchor"], p["fraction"], p["read_at"], bool(p.get("force")), DEVICE_NAME, None) + self.save() + + @_locked + def sent(self, key: str, body: dict, answer: dict | None) -> None: + """The server took `body` (the pending write as it was sent): forget it unless a newer one + queued meanwhile, and take the server's state from the answer.""" + st = self.books.get(key) + if st is None: + return + if st.pending and st.pending.get("read_at") == body.get("read_at") and bool(st.pending.get("force")) == bool(body.get("force")): + st.pending = None + if answer: + self.set_server_now(answer.get("server_now")) + if answer.get("stored") is True: + st.echo_read_at = plausible_ms(answer.get("read_at")) + self.apply_server(key, answer) + else: + self.save() -def load(book_id: str) -> Position | None: - entry = _load_all().get(book_id) - if not isinstance(entry, dict) or not entry.get("anchor"): - return None - return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0)) +def _merge_pending(old: dict | None, new: dict) -> dict: + """Two queued writes for the book: the place read later is the one to send; a forced place + stays forced (Ops.merge).""" + if old is None: + return new + keep = new if new["read_at"] >= old["read_at"] else old + keep = dict(keep) + if old.get("force") or new.get("force"): + keep["force"] = True + return keep -def save(book_id: str, position: Position, *, force: bool = False) -> bool: - """Returns True if the position was actually written (i.e. it was further - along, or forced) — callers that push to the server only need to do so - when this returns True.""" - data = _load_all() - existing = data.get(book_id) - if existing is not None and not force: - old_anchor = existing.get("anchor", "") - if not anchor_is_further(position.anchor, old_anchor): - return False - if not position.updated_at: - position.updated_at = time.time() - data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at} - _save_all(data) - return True +_default: ProgressStore | None = None + + +def default() -> ProgressStore: + global _default + if _default is None: + _default = ProgressStore() + return _default diff --git a/tui/diora_tui/reader_screen.py b/tui/diora_tui/reader_screen.py index 6d4392b..f6a8048 100644 --- a/tui/diora_tui/reader_screen.py +++ b/tui/diora_tui/reader_screen.py @@ -7,12 +7,18 @@ Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the whole book into one Static: for large books (tens of thousands of blocks) a single giant renderable made Textual's layout/paint pass take upwards of a minute, whereas the Line API only ever renders the rows on screen. + +Reading position (docs/progress-sync.md, rules in progress.py): the place last read wins. +The reader saves a position only when the *reader* moved it (keys, wheel), stamped with the +moment of arriving; restoring, re-wrapping on resize and jumping on an offer change nothing. +While a book is open the server is asked now and then; a newer position from another device +is offered ("Auf X weitergelesen — springen?"), never applied by itself. """ from __future__ import annotations import bisect -import re +import threading from pathlib import Path from rich.console import Console @@ -23,12 +29,17 @@ from textual.geometry import Size from textual.screen import ModalScreen, Screen from textual.widgets import Footer, Header, Label, LoadingIndicator, Static -from . import api, blocks, cache, config as config_mod, layout, progress +from . import blocks, cache, config as config_mod, epub, layout, library_meta, progress, remote from .book_view import ContinuousBookView from .statusbar import StatusBar MAX_LINE_WIDTH = 120 AUTOSAVE_INTERVAL = 5.0 +# How often the server is asked for a newer position while the book is open (the apps pull +# at most every 2 minutes). +REMOTE_CHECK_INTERVAL = 120.0 +# "Weiteste Stelle" is offered when the furthest place is this much of the book ahead. +FURTHEST_OFFER_SHARE = 0.01 # ContinuousBookView forces its vertical scrollbar always-on (see book_view.py) # so this stays constant — measuring the container's width and subtracting # this fixed amount avoids a measure-after-constrain race against book_view's @@ -37,15 +48,6 @@ SCROLLBAR_GUTTER = 2 _EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1) -# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see -# remote.py) — reused here to find the server book id for progress push. -_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ") - - -def _server_book_id(path: Path) -> int | None: - m = _SERVER_ID_RE.match(path.name) - return int(m.group(1)) if m else None - class FootnoteScreen(ModalScreen[None]): BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")] @@ -64,6 +66,27 @@ class FootnoteScreen(ModalScreen[None]): self.dismiss(None) +class JumpOfferScreen(ModalScreen[bool]): + """A question the reader answers with a key; nothing moves unless the answer is yes.""" + + BINDINGS = [ + Binding("y,enter", "answer(True)", "Springen"), + Binding("n,escape,q", "answer(False)", "Hier bleiben"), + ] + + def __init__(self, text: str) -> None: + super().__init__() + self._text = text + + def compose(self): + with Container(id="offer-body"): + yield Static(self._text) + yield Label("y/Enter: springen n/Escape: hier bleiben", id="offer-hint") + + def action_answer(self, yes: bool) -> None: + self.dismiss(yes) + + class ReaderScreen(Screen): BINDINGS = [ Binding("j,down", "scroll_down_line", "Runter", show=False), @@ -71,6 +94,7 @@ class ReaderScreen(Screen): Binding("n", "next_chapter", "Nächstes Kapitel"), Binding("p", "prev_chapter", "Vorheriges Kapitel"), Binding("f", "peek_footnote", "Fußnote"), + Binding("w", "goto_furthest", "Weiteste Stelle"), Binding("q,escape", "back", "Zurück zur Bibliothek"), ] @@ -79,9 +103,19 @@ class ReaderScreen(Screen): self.path = path self.book: blocks.FlatBook | None = None self.book_layout: layout.BookLayout = _EMPTY_LAYOUT - self._server_id = _server_book_id(path) - self._remote_cfg = config_mod.load() if self._server_id is not None else None - self._last_pushed_anchor = "" + self._key = epub._book_id(path) + self._server_id = epub.server_id_of(path) + self._cfg = config_mod.load() if self._server_id is not None else None + self._store = progress.default() + # The position the view is on, as last saved or restored, and when the reader last + # moved it (this machine's clock). A change of the anchor is a reading position only + # when there is such a move. + self._last_anchor: str | None = None + self._last_move_ms: int | None = None + # read_at (server clock) of the state this view is based on: a newer one from the + # server is what gets offered. + self._held_read_at: int | None = None + self._offer_open = False def compose(self): yield Header() @@ -93,7 +127,7 @@ class ReaderScreen(Screen): # to visible hasn't been through a layout pass yet, so its `.size` # is still (0, 0) and an immediate scroll_to() right after has # nothing to clamp against and silently resets to 0. - self._book_view = ContinuousBookView(_EMPTY_LAYOUT) + self._book_view = ContinuousBookView(_EMPTY_LAYOUT, on_read_scroll=self._on_read_scroll) yield self._book_view yield StatusBar() yield Footer() @@ -129,8 +163,12 @@ class ReaderScreen(Screen): self.sub_title = book.title self._apply_layout(book_layout, width) self.query_one("#reader-loading", LoadingIndicator).display = False + st = self._store.get(self._key) + self._held_read_at = st.read_at if st else None self.call_after_refresh(self._restore_position) self.set_interval(AUTOSAVE_INTERVAL, self._autosave) + if self._cfg is not None: + self.set_interval(REMOTE_CHECK_INTERVAL, self._check_remote) def _content_width(self) -> int: # Measuring the *container* rather than book_view's own size avoids a @@ -168,63 +206,175 @@ class ReaderScreen(Screen): anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y) self._build_layout() new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac) - self._book_view.scroll_to(y=new_y, animate=False, immediate=True) + with self._book_view.program_scroll(): + self._book_view.scroll_to(y=new_y, animate=False, immediate=True) + + def _scroll_to_anchor(self, anchor: str) -> bool: + parsed = blocks.parse_anchor(anchor) + if parsed is None: + return False + y = layout.scroll_y_for_anchor(self.book_layout, *parsed) + with self._book_view.program_scroll(): + self._book_view.scroll_to(y=y, animate=False, immediate=True) + return y > 0 def _restore_position(self, attempt: int = 0) -> None: if self.book is None: return - saved = progress.load(self.book.id) - if saved is None: + st = self._store.get(self._key) + if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None: + self._settle() return - parsed = blocks.parse_anchor(saved.anchor) - if parsed is None: - return - block_index, inner_fraction = parsed - y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction) + parsed = blocks.parse_anchor(st.anchor) + y = layout.scroll_y_for_anchor(self.book_layout, *parsed) if y <= 0: + self._settle() return - self._book_view.scroll_to(y=y, animate=False, immediate=True) + with self._book_view.program_scroll(): + self._book_view.scroll_to(y=y, animate=False, immediate=True) # A widget that's only just become part of the layout doesn't always # honor an immediate scroll on the first attempt (its own size/scroll # bounds can still be mid-update) — verify it actually landed and # retry a bounded number of times rather than guessing a fixed delay. if attempt < 20 and abs(self._book_view.scroll_y - y) > 1: self.call_after_refresh(lambda: self._restore_position(attempt + 1)) + return + self._settle() + self._offer_furthest(st) + + def _settle(self) -> None: + """The view is where the saved position put it: from here a changed anchor with a move + by the reader is a reading position.""" + self._last_anchor = self._current_anchor_str() + self._last_move_ms = None def _current_anchor_str(self) -> str: block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) return blocks.format_anchor(block_index, inner_fraction) + # ---- saving + + def _on_read_scroll(self) -> None: + self._last_move_ms = progress.now_ms() + def _autosave(self) -> None: self._save_progress() def _save_progress(self) -> None: - if self.book is None or not self.book_layout.heights: + if self.book is None or not self.book_layout.heights or self._last_anchor is None: return anchor = self._current_anchor_str() - advanced = progress.save(self.book.id, progress.Position(anchor=anchor)) - if advanced and self._remote_cfg is not None and self._server_id is not None: - self._push_remote_progress(anchor) + if anchor == self._last_anchor: + self._last_move_ms = None # moved and came back: nothing new + return + moved_at = self._last_move_ms + self._last_anchor = anchor + self._last_move_ms = None + if moved_at is None: + return # the layout moved it, not the reader + block_index, inner = blocks.parse_anchor(anchor) + fraction = blocks.fraction_for_anchor(self.book, block_index, inner) + self._store.write(self._key, anchor, fraction, moved_at, queue=self._cfg is not None) + st = self._store.get(self._key) + if st is not None: + self._held_read_at = st.read_at + if self._cfg is not None: + self._push() @work(thread=True, exclusive=True, group="progress-push") - def _push_remote_progress(self, anchor: str) -> None: - if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None: - return + def _push(self) -> None: try: - api.post_progress( - self._remote_cfg.server_url, - self._remote_cfg.api_token, - self._server_id, - scroll_fraction=0.0, - position_anchor=anchor, - force=False, - ) - self._last_pushed_anchor = anchor + remote.flush_pending(self._cfg) except Exception: - pass # best-effort — local progress is already saved regardless + return # best-effort — the position is saved locally and stays queued + self.app.call_from_thread(self._after_push) + + def _after_push(self) -> None: + # What the server stored of ours is no news; a newer position of another device in its + # answer (our write was refused) is. + st = self._store.get(self._key) + if st is not None and st.echo_read_at is not None and st.echo_read_at >= (self._held_read_at or 0): + self._held_read_at = st.echo_read_at + self._offer_newer() def on_unmount(self) -> None: self._save_progress() + if self._cfg is not None and self._store.pending_keys(): + # The screen's workers die with it; the queue still has to go out. + threading.Thread(target=self._flush_quietly, args=(self._cfg,), daemon=False).start() + + @staticmethod + def _flush_quietly(cfg) -> None: + try: + remote.flush_pending(cfg) + except Exception: + pass + + # ---- the server's newer positions + + @work(thread=True, exclusive=True, group="progress-check") + def _check_remote(self) -> None: + try: + remote.sync_library(self.path.parent, self._cfg) + except Exception: + return # offline etc.: ask again later + self.app.call_from_thread(self._offer_newer) + + def _offer_newer(self) -> None: + if self.book is None or self._offer_open or self._last_anchor is None: + return + st = self._store.get(self._key) + if st is None or not st.anchor or st.read_at is None: + return + if self._held_read_at is not None and st.read_at <= self._held_read_at: + return + parsed = blocks.parse_anchor(st.anchor) + if parsed is None: + return + shown_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) + self._held_read_at = st.read_at # asked once per newer state + if parsed[0] == shown_block: + return + pct = round(blocks.fraction_for_anchor(self.book, *parsed) * 100) + who = st.device or "einem anderen Gerät" + self._ask(f"Auf {who} weitergelesen — bei {pct} %.\nSpringen?", st.anchor) + + def _offer_furthest(self, st: progress.BookState) -> None: + if self.book is None or not st.furthest_anchor: + return + here, there = blocks.parse_anchor(st.anchor), blocks.parse_anchor(st.furthest_anchor) + if there is None: + return + share = blocks.fraction_for_anchor(self.book, *there) - (blocks.fraction_for_anchor(self.book, *here) if here else 0.0) + if share >= FURTHEST_OFFER_SHARE: + pct = round(blocks.fraction_for_anchor(self.book, *there) * 100) + self.notify(f"Weiteste Stelle: {pct} % — mit w springen.", timeout=6) + + def _ask(self, text: str, anchor: str) -> None: + self._offer_open = True + + def answered(yes: bool | None) -> None: + self._offer_open = False + if yes: + self._scroll_to_anchor(anchor) + self._last_anchor = self._current_anchor_str() + self._last_move_ms = None + + self.app.push_screen(JumpOfferScreen(text), answered) + + def action_goto_furthest(self) -> None: + st = self._store.get(self._key) + if self.book is None or st is None or not st.furthest_anchor: + self.notify("Keine weiteste Stelle bekannt.", timeout=3) + return + if blocks.parse_anchor(st.furthest_anchor) is None: + return + self._scroll_to_anchor(st.furthest_anchor) + # A jump, not reading: nothing is saved until the reader carries on from there. + self._last_anchor = self._current_anchor_str() + self._last_move_ms = None + + # ---- navigation def action_scroll_down_line(self) -> None: self._book_view.scroll_relative(y=1, animate=False) diff --git a/tui/diora_tui/remote.py b/tui/diora_tui/remote.py index 58ec00b..3238ef2 100644 --- a/tui/diora_tui/remote.py +++ b/tui/diora_tui/remote.py @@ -1,27 +1,24 @@ -"""Fetch + decrypt books from a diora server into the local TUI library, and -pull reading progress for them into the local progress store. +"""Talk to the diora server: pull the book list and reading state, push queued positions, +download a book when it is opened. -Progress is pull-only here (server -> local); the reader pushes local -> -server itself while a book is open (see reader_screen.py). Both directions -use the same "blockIndex:innerFraction" anchor format as the web reader -(diora_tui/blocks.py) and the same furthest-wins merge rule -(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can -only ever advance local progress, never regress it, unless forced. +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_meta +from . import api, crypto, library_meta, progress from .config import RemoteConfig -from .epub import _book_id -from .progress import Position, save as save_progress +from .epub import server_key class SyncError(Exception): @@ -30,11 +27,11 @@ class SyncError(Exception): @dataclass class SyncResult: - downloaded: list[str] = field(default_factory=list) - unchanged: int = 0 + books: int = 0 skipped_pdf: list[str] = field(default_factory=list) failed: list[str] = field(default_factory=list) - progress_pulled: int = 0 + pushed: int = 0 + new_titles: list[str] = field(default_factory=list) def _sanitize_filename(name: str) -> str: @@ -42,69 +39,89 @@ def _sanitize_filename(name: str) -> str: return (name or "buch")[:80] -def _parse_meta(raw: bytes) -> dict: - return json.loads(raw.decode("utf-8")) +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: - snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token) + 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 - library_dir.mkdir(parents=True, exist_ok=True) - result = SyncResult() - local_path_by_server_id: dict[int, Path] = {} - - for book in snapshot.get("books", []): - book_id = book["id"] + 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 = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"])) - except crypto.DecryptError: - result.failed.append(f"#{book_id}") + 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(meta.get("title") or f"#{book_id}") + result.skipped_pdf.append(title) continue - - existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None) - if existing is not None: - result.unchanged += 1 - local_path_by_server_id[book_id] = existing - 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) - local_path_by_server_id[book_id] = dest - - for entry in snapshot.get("book_progress", []): - anchor = entry.get("position_anchor") or "" - if not anchor: - continue # PDF-only scroll_fraction progress — this TUI is EPUB-only - dest = local_path_by_server_id.get(entry.get("book_id")) - if dest is None: - continue - local_id = _book_id(dest) - if save_progress(local_id, Position(anchor=anchor)): - result.progress_pulled += 1 - - read_status = { - _book_id(path): bool(book.get("is_read")) - for book in snapshot.get("books", []) - if (path := local_path_by_server_id.get(book["id"])) is not None - } - library_meta.set_many(read_status) - + 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 ""} + if key not in known: + result.new_titles.append(title) + store.apply_server(key, b) + library_meta.set_remote(entries) + result.books = len(entries) return result + + +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 diff --git a/tui/diora_tui/secretstore.py b/tui/diora_tui/secretstore.py new file mode 100644 index 0000000..355b612 --- /dev/null +++ b/tui/diora_tui/secretstore.py @@ -0,0 +1,55 @@ +"""Protect the account's token and book key at rest. + +Windows: DPAPI (CryptProtectData), bound to this Windows user on this machine, as +tools/diora_client.py in diora-android does. Elsewhere there is no such store in the standard +library: the blob is plain base64 in a 0600 file (the same exposure the web client has with +its key in localStorage), marked as such so a later version can move it into a keyring. +""" + +from __future__ import annotations + +import base64 +import sys + +_ENTROPY = b"diora-tui" + + +def _dpapi(fn_name: str, data: bytes) -> bytes: + import ctypes + import ctypes.wintypes as wt + + class Blob(ctypes.Structure): + _fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))] + + def blob(b: bytes): + buf = ctypes.create_string_buffer(b, len(b)) + return Blob(len(b), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))), buf + + inp, _k1 = blob(data) + ent, _k2 = blob(_ENTROPY) + out = Blob() + fn = getattr(ctypes.windll.crypt32, fn_name) + if not fn(ctypes.byref(inp), None, ctypes.byref(ent), None, None, 1, ctypes.byref(out)): # 1: no UI + raise ctypes.WinError() + try: + return ctypes.string_at(out.pbData, out.cbData) + finally: + ctypes.windll.kernel32.LocalFree(out.pbData) + + +def protect(data: bytes) -> str: + if sys.platform == "win32": + return "dpapi:" + base64.b64encode(_dpapi("CryptProtectData", data)).decode() + return "plain:" + base64.b64encode(data).decode() + + +def unprotect(blob: str) -> bytes: + kind, _, b64 = blob.partition(":") + raw = base64.b64decode(b64) + if kind == "dpapi": + if sys.platform != "win32": + raise ValueError("Zugangsdaten sind mit Windows-DPAPI geschützt und nur dort lesbar") + return _dpapi("CryptUnprotectData", raw) + if kind == "plain": + return raw + raise ValueError("unbekanntes Format der gespeicherten Zugangsdaten") diff --git a/tui/tests/conftest.py b/tui/tests/conftest.py index 9fba1e0..c47e587 100644 --- a/tui/tests/conftest.py +++ b/tui/tests/conftest.py @@ -10,6 +10,7 @@ _HOME = tempfile.mkdtemp(prefix="diora-tui-test-") for var in ("LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"): os.environ[var] = _HOME os.environ["HOME"] = _HOME +os.environ["DIORA_TUI_HOME"] = _HOME # platformdirs ignores the variables above on Windows import pytest # noqa: E402 diff --git a/tui/tests/test_app_smoke.py b/tui/tests/test_app_smoke.py index 664fd5c..264d8a1 100644 --- a/tui/tests/test_app_smoke.py +++ b/tui/tests/test_app_smoke.py @@ -1,10 +1,21 @@ """The Textual app starts, lists a library, opens a book and leaves it again.""" import asyncio +import pytest + from diora_tui.app import DioraTuiApp from diora_tui.reader_screen import ReaderScreen +@pytest.fixture +def store_fresh(tmp_path, monkeypatch): + from diora_tui import progress + + st = progress.ProgressStore(tmp_path / "p.json") + monkeypatch.setattr(progress, "_default", st) + return st + + def test_open_read_and_leave(epub, tmp_path): lib = tmp_path / "lib" lib.mkdir() @@ -28,3 +39,30 @@ def test_open_read_and_leave(epub, tmp_path): assert not isinstance(app.screen, ReaderScreen) asyncio.run(run()) + + +def test_server_books_are_listed_before_they_are_downloaded(tmp_path, store_fresh): + from textual.widgets import Label, ListView + + from diora_tui import library_meta + + lib = tmp_path / "lib" + lib.mkdir() + library_meta.set_remote({"s9": {"server_id": 9, "title": "Cloud-Buch", "author": "", "is_read": False, "uploaded_at": "2026-10-01"}}) + store_fresh.apply_server("s9", {"position_anchor": "5:0.5", "scroll_fraction": 0.4, "read_at": 1_760_000_000_000}) + + async def run(): + app = DioraTuiApp(lib) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + items = app.screen.query_one(ListView).query(Label) + text = " | ".join(str(i.render()) for i in items) + assert "☁ Cloud-Buch" in text and "40 %" in text + await pilot.press("enter") # not signed in here: says so instead of failing + await pilot.pause() + assert not isinstance(app.screen, ReaderScreen) + + try: + asyncio.run(run()) + finally: + library_meta.set_remote({}) diff --git a/tui/tests/test_progress.py b/tui/tests/test_progress.py new file mode 100644 index 0000000..4444c41 --- /dev/null +++ b/tui/tests/test_progress.py @@ -0,0 +1,142 @@ +"""The reading-position rules of diora-web docs/progress-sync.md (the same cases the server's +books/tests.py, Ops.kt and ops.js hold): the place last read wins.""" +import json + +import pytest + +from diora_tui import progress +from diora_tui.progress import ProgressStore, cmp_progress + +T = 1_760_000_000_000 # a plausible ms time + + +@pytest.fixture +def store(tmp_path): + return ProgressStore(tmp_path / "progress.json") + + +def test_newer_reading_wins_even_if_behind(store): + store.write("s1", "50:0.0", 0.5, T, now=T) + store.write("s1", "10:0.0", 0.1, T + 1000, now=T + 1000) # read back earlier in the book, later in time + st = store.get("s1") + assert (st.anchor, st.read_at) == ("10:0.0", T + 1000) + assert st.furthest_anchor == "50:0.0" # furthest only grows + + +def test_older_write_is_queued_but_not_the_continue_point(store): + store.write("s1", "50:0.0", 0.5, T, now=T) + took = store.write("s1", "10:0.0", 0.1, T - 5000, now=T) # an offline catch-up from earlier + assert not took + assert store.get("s1").anchor == "50:0.0" + assert store.get("s1").pending["anchor"] == "50:0.0" # the later read stays the one to send + + +def test_clock_offset_and_nothing_from_the_future(store): + store.offset = 5000 # the server is 5 s ahead of this machine + store.write("s1", "3:0.0", 0.1, T, now=T) + assert store.get("s1").read_at == T + 5000 + store.write("s1", "4:0.0", 0.2, T + 10 * 3600_000, now=T + 100) # a read_at hours ahead of the clock + assert store.get("s1").read_at == T + 100 + 5000 + + +def test_force_takes_the_place_with_the_time_of_now(store): + store.write("s1", "9:0.0", 0.9, T, now=T) + store.write("s1", "2:0.0", 0.2, T - 1000, force=True, now=T + 50) + st = store.get("s1") + assert (st.anchor, st.read_at) == ("2:0.0", T + 50) + assert st.pending["force"] is True + + +def test_force_stays_forced_when_reading_goes_on(store): + store.write("s1", "2:0.0", 0.2, T, force=True, now=T) + store.write("s1", "3:0.0", 0.3, T + 1000, now=T + 1000) + assert store.get("s1").pending["force"] is True + assert store.get("s1").pending["anchor"] == "3:0.0" + + +def test_not_a_server_book_queues_nothing(store): + store.write("abc", "3:0.0", 0.3, T, queue=False, now=T) + assert store.get("abc").pending is None and store.get("abc").anchor == "3:0.0" + assert store.pending_keys() == [] + + +def server_entry(anchor, read_at, device="Browser", furthest=None): + return {"position_anchor": anchor, "scroll_fraction": 0.5, "read_at": read_at, "device": device, + "furthest_anchor": furthest or anchor, "furthest_fraction": 0.5} + + +def test_pull_shows_the_servers_place(store): + store.apply_server("s1", server_entry("20:0.5", T, "Handy")) + st = store.get("s1") + assert (st.anchor, st.read_at, st.device) == ("20:0.5", T, "Handy") + + +def test_pull_applies_a_newer_pending_on_top_and_drops_an_older_one(store): + store.write("s1", "7:0.0", 0.1, T + 2000, now=T + 2000) + store.apply_server("s1", server_entry("20:0.5", T)) # the server's is older: ours shows + assert store.get("s1").anchor == "7:0.0" + store.apply_server("s1", server_entry("30:0.0", T + 9000)) # the server's is newer: it shows, ours is still sent + st = store.get("s1") + assert st.anchor == "30:0.0" and st.pending["anchor"] == "7:0.0" + + +def test_sent_clears_the_queue_unless_something_newer_queued(store): + store.write("s1", "7:0.0", 0.1, T, now=T) + body = dict(store.get("s1").pending) + store.write("s1", "8:0.0", 0.2, T + 1000, now=T + 1000) # read on while the request was out + store.sent("s1", body, {"stored": True, "read_at": T, "position_anchor": "7:0.0", "server_now": T}) + assert store.get("s1").pending["anchor"] == "8:0.0" + body = dict(store.get("s1").pending) + store.sent("s1", body, {"stored": True, "read_at": T + 1000, "position_anchor": "8:0.0", "scroll_fraction": 0.2}) + st = store.get("s1") + assert st.pending is None and st.echo_read_at == T + 1000 and st.anchor == "8:0.0" + + +def test_refused_write_takes_the_servers_newer_place(store): + store.write("s1", "7:0.0", 0.1, T, now=T) + body = dict(store.get("s1").pending) + store.sent("s1", body, {"stored": False, "read_at": T + 5000, "position_anchor": "40:0.0", "device": "Handy"}) + st = store.get("s1") + assert (st.anchor, st.device, st.echo_read_at) == ("40:0.0", "Handy", None) + + +def test_server_clock_sets_the_offset(store): + store.set_server_now(T + 7000, T) + assert store.offset == 7000 + store.set_server_now(None) + store.set_server_now(12, T) # not a plausible time + assert store.offset == 7000 + + +def test_survives_a_restart(tmp_path): + a = ProgressStore(tmp_path / "p.json") + a.set_server_now(T + 100, T) + a.write("s1", "7:0.250000", 0.3, T, now=T) + b = ProgressStore(tmp_path / "p.json") + assert b.offset == 100 and b.get("s1").anchor == "7:0.250000" and b.get("s1").pending["read_at"] == T + + +def test_reads_the_old_furthest_wins_file(tmp_path): + (tmp_path / "p.json").write_text(json.dumps({"abc": {"anchor": "5:0.5", "updated_at": 1760000000.0}})) + st = ProgressStore(tmp_path / "p.json").get("abc") + assert st.anchor == "5:0.5" and st.read_at == 1760000000000 and st.pending is None + + +@pytest.mark.parametrize("a,b,sign", [ + (("5:0.1", 0), ("4:0.9", 0), 1), + (("5:0.5", 0), ("5:0.4", 0), 1), + (("4:0.9", 0), ("5:0.1", 0), -1), + (("", 0.7), ("5:0.1", 0.2), 1), # no anchor on one side: the fraction decides + (("", 0.1), ("", 0.1), 0), +]) +def test_cmp_progress(a, b, sign): + r = cmp_progress(a[0], a[1], b[0], b[1]) + assert (r > 0) - (r < 0) == sign + + +def test_plausible_ms(): + assert progress.plausible_ms(T) == T + assert progress.plausible_ms(5) is None + assert progress.plausible_ms(True) is None + assert progress.plausible_ms(float("nan")) is None + assert progress.plausible_ms(None) is None diff --git a/tui/tests/test_reader_progress.py b/tui/tests/test_reader_progress.py new file mode 100644 index 0000000..4f1d859 --- /dev/null +++ b/tui/tests/test_reader_progress.py @@ -0,0 +1,129 @@ +"""The reader saves a position only when the reader moved it, and offers — never applies — a +newer position from another device (docs/progress-sync.md).""" +import asyncio + +import pytest + +from diora_tui import blocks, config as config_mod, epub as epub_mod, layout, progress +from diora_tui.app import DioraTuiApp +from diora_tui.config import RemoteConfig +from diora_tui.reader_screen import JumpOfferScreen, ReaderScreen + +T = 1_760_000_000_000 + + +@pytest.fixture +def store(tmp_path, monkeypatch): + s = progress.ProgressStore(tmp_path / "p.json") + monkeypatch.setattr(progress, "_default", s) + return s + + +def book(epub, name="Testbuch"): + body = "<h1>Anfang</h1>" + "".join(f"<p>Absatz {i} " + "wort " * 40 + "</p>" for i in range(80)) + return epub([body], title=name) + + +async def open_reader(app, pilot, path): + app.push_screen(ReaderScreen(path)) + for _ in range(80): + await pilot.pause(0.1) + s = app.screen + if isinstance(s, ReaderScreen) and s.book is not None and s._last_anchor is not None: + await pilot.pause(0.2) + return s + raise AssertionError("reader did not load") + + +def test_only_the_readers_moves_are_saved(epub, store, tmp_path): + path = book(epub) + lib = tmp_path / "lib" + lib.mkdir() + + async def run(): + app = DioraTuiApp(lib) + async with app.run_test(size=(100, 30)) as pilot: + reader = await open_reader(app, pilot, path) + key = epub_mod._book_id(path) + reader._save_progress() + assert store.get(key) is None # opened and looked at: nothing written + + await pilot.press(*["j"] * 40) + reader._save_progress() + st = store.get(key) + assert st is not None and blocks.parse_anchor(st.anchor)[0] > 0 + assert st.pending is None # not a server book: nothing queued + assert 0 < st.fraction < 1 and abs(st.read_at - progress.now_ms()) < 5000 + first = st.read_at + + await pilot.resize_terminal(70, 24) # re-wrapped by the program, not read + await pilot.pause(0.3) + reader._save_progress() + assert store.get(key).read_at == first + + reader._save_progress() # unchanged: no new time either + assert store.get(key).read_at == first + + asyncio.run(run()) + + +def test_reopening_returns_to_the_saved_place_without_writing(epub, store, tmp_path): + path = book(epub) + key = epub_mod._book_id(path) + lib = tmp_path / "lib" + lib.mkdir() + + async def run(): + app = DioraTuiApp(lib) + async with app.run_test(size=(100, 30)) as pilot: + reader = await open_reader(app, pilot, path) + await pilot.press(*["j"] * 60) + reader._save_progress() + saved = store.get(key) + app.pop_screen() + await pilot.pause() + + reader = await open_reader(app, pilot, path) + assert reader._book_view.scroll_y > 30 + reader._save_progress() + assert store.get(key).read_at == saved.read_at # restored, not "read" again + + asyncio.run(run()) + + +@pytest.mark.parametrize("answer,jumps", [("y", True), ("n", False)]) +def test_newer_position_from_elsewhere_is_offered(epub, store, tmp_path, monkeypatch, answer, jumps): + lib = tmp_path / "lib" + lib.mkdir() + path = book(epub, "Server") + target = lib / "0042 - Server.epub" + path.rename(target) + monkeypatch.setattr(config_mod, "load", lambda: RemoteConfig("http://x", "t", "k")) + store.apply_server("s42", {"position_anchor": "5:0.0", "scroll_fraction": 0.05, "read_at": T, "device": "Handy"}) + + async def run(): + app = DioraTuiApp(lib) + async with app.run_test(size=(100, 30)) as pilot: + reader = await open_reader(app, pilot, target) + y0 = reader._book_view.scroll_y + + # another device read further on, later + store.apply_server("s42", {"position_anchor": "60:0.0", "scroll_fraction": 0.8, "read_at": T + 60_000, "device": "Handy"}) + reader._offer_newer() + await pilot.pause() + assert isinstance(app.screen, JumpOfferScreen) + assert reader._book_view.scroll_y == y0 # asked, not moved + await pilot.press(answer) + await pilot.pause(0.3) + assert isinstance(app.screen, ReaderScreen) + moved = reader._book_view.scroll_y > y0 + 20 + assert moved == jumps + + reader._offer_newer() # the same state is not offered twice + await pilot.pause() + assert isinstance(app.screen, ReaderScreen) + + reader._save_progress() # jumping isn't reading: still the server's time + assert store.get("s42").read_at == T + 60_000 + + asyncio.run(run()) diff --git a/tui/tests/test_remote.py b/tui/tests/test_remote.py new file mode 100644 index 0000000..2ecae39 --- /dev/null +++ b/tui/tests/test_remote.py @@ -0,0 +1,59 @@ +"""flush_pending / sync_library against a stubbed API (the server side is covered by the real +local server in tools/smoke_server.py).""" +import pytest +import requests + +from diora_tui import api, progress, remote +from diora_tui.config import RemoteConfig + +T = 1_760_000_000_000 +CFG = RemoteConfig("http://x", "tok", "key") + + +@pytest.fixture(autouse=True) +def fresh_store(tmp_path, monkeypatch): + store = progress.ProgressStore(tmp_path / "p.json") + monkeypatch.setattr(progress, "_default", store) + return store + + +def test_flush_sends_read_at_and_device_and_clears_the_queue(fresh_store, monkeypatch): + fresh_store.write("s7", "3:0.5", 0.25, T, now=T) + sent = [] + + def fake_post(server, token, book_id, **kw): + sent.append((book_id, kw)) + return {"ok": True, "stored": True, "read_at": T, "position_anchor": "3:0.5", "scroll_fraction": 0.25, "server_now": T} + + monkeypatch.setattr(api, "post_progress", fake_post) + assert remote.flush_pending(CFG) == 1 + assert sent == [(7, {"anchor": "3:0.5", "fraction": 0.25, "read_at": T, "device": "diora TUI", "force": False})] + assert fresh_store.get("s7").pending is None + + +def test_flush_keeps_the_queue_when_offline(fresh_store, monkeypatch): + fresh_store.write("s7", "3:0.5", 0.25, T, now=T) + + def offline(*a, **kw): + raise requests.ConnectionError("no network") + + monkeypatch.setattr(api, "post_progress", offline) + assert remote.flush_pending(CFG) == 0 + assert fresh_store.get("s7").pending is not None + + +def test_flush_drops_the_queue_of_a_deleted_book(fresh_store, monkeypatch): + fresh_store.write("s7", "3:0.5", 0.25, T, now=T) + + def gone(*a, **kw): + raise api.ApiError("Buch 7 nicht gefunden (gelöscht oder anderes Konto?)") + + monkeypatch.setattr(api, "post_progress", gone) + remote.flush_pending(CFG) + assert fresh_store.get("s7").pending is None + + +def test_flush_ignores_local_books(fresh_store, monkeypatch): + fresh_store.write("abcdef0123456789", "3:0.5", 0.25, T, now=T) # queue=True by default, but not a server key + monkeypatch.setattr(api, "post_progress", lambda *a, **k: pytest.fail("must not be sent")) + assert remote.flush_pending(CFG) == 0 diff --git a/tui/tools/smoke_server.py b/tui/tools/smoke_server.py new file mode 100644 index 0000000..6703671 --- /dev/null +++ b/tui/tools/smoke_server.py @@ -0,0 +1,88 @@ +"""End-to-end check of the sync against a LOCAL diora server (never a real account): +login, protected credentials, book list, lazy download, a position going out, another device +reading later, an offline catch-up that must lose. Throwaway state in a temp folder. + + python tools/smoke_server.py http://127.0.0.1:8001 <username> <password> + +It can run again on the same database (the furthest place only ever grows). + +The server is the diora-web checkout run with its own throwaway database, seeded like +diora-android's tools/seed_testserver.py does. +""" +import os +import sys +import tempfile +import time +from pathlib import Path + +home = tempfile.mkdtemp(prefix="diora-tui-smoke-") +for var in ("LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"): + os.environ[var] = home +os.environ["HOME"] = home +os.environ["DIORA_TUI_HOME"] = home + +from diora_tui import api, blocks, config, crypto, epub, login, progress, remote # noqa: E402 + +server, username, password = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3] +if "127.0.0.1" not in server and "localhost" not in server: + sys.exit("only against a local test server") + + +def check(what, ok, detail=""): + print(("OK " if ok else "FAIL ") + what + (f" [{detail}]" if detail else "")) + if not ok: + sys.exit(1) + + +token, name = login.web_login(server, username, password) +key_b64 = crypto.derive_key_b64(name, password) +cfg = config.RemoteConfig(server, token, key_b64, name) +config.save(cfg) +back = config.load() +check("credentials stored protected and read back", + back == cfg and (sys.platform != "win32" or "dpapi:" in Path(config._CONFIG_FILE).read_text())) +check("no token or key in the config file in the clear", + token not in Path(config._CONFIG_FILE).read_text() and key_b64 not in Path(config._CONFIG_FILE).read_text()) + +lib = Path(home) / "lib" +res = remote.sync_library(lib, cfg) +check("book list pulled, nothing downloaded", res.books >= 1 and not list(lib.glob("*.epub")), f"{res.books} books") +store = progress.default() +check("server clock offset learnt", abs(store.offset) < 5000, f"{store.offset} ms") + +entry = sorted(__import__("diora_tui.library_meta", fromlist=["x"]).remote_entries().items())[0] +skey, info = entry +path = remote.download_book(lib, cfg, info["server_id"], info["title"]) +check("book downloaded on demand and keyed by the server id", path.exists() and epub._book_id(path) == skey, path.name) +book = blocks.load_flat_book(path) + +# the reader arrives at block 12 now +now = progress.now_ms() +a1 = blocks.format_anchor(12, 0.5) +store.write(skey, a1, blocks.fraction_for_anchor(book, 12, 0.5), now) +check("position queued", store.get(skey).pending is not None) +check("flushed", remote.flush_pending(cfg) == 1 and store.get(skey).pending is None) +row = [b for b in api.fetch_books(server, token)[0] if b["id"] == info["server_id"]][0] +check("server holds it with device and a real fraction", + row["position_anchor"] == a1 and row["device"] == "diora TUI" and 0 < row["scroll_fraction"] < 1 + and abs(row["read_at"] - now) < 10_000, f"fraction {row['scroll_fraction']:.4f}, read_at diff {row['read_at'] - now} ms") + +# another device reads somewhere else, later +time.sleep(0.05) +other = blocks.format_anchor(3, 0.0) +api.post_progress(server, token, info["server_id"], anchor=other, fraction=0.02, read_at=progress.now_ms(), device="Handy") +remote.sync_library(lib, cfg) +st = store.get(skey) +check("a pull shows the later position of the other device", st.anchor == other and st.device == "Handy") +check("the furthest place is kept (never behind what was read)", + progress.cmp_progress(st.furthest_anchor, 0, a1, 0) >= 0, st.furthest_anchor) + +# an offline catch-up from before that must not become the continue point +store.write(skey, blocks.format_anchor(30, 0.0), 0.5, progress.now_ms() - 120_000) +remote.flush_pending(cfg) +row = [b for b in api.fetch_books(server, token)[0] if b["id"] == info["server_id"]][0] +check("an older write loses on the server", row["position_anchor"] == other and row["device"] == "Handy") +check("...and locally", store.get(skey).anchor == other and store.get(skey).pending is None) +check("...but raises the furthest place", + progress.cmp_progress(row["furthest_anchor"], 0, blocks.format_anchor(30, 0.0), 0) >= 0, row["furthest_anchor"]) +print("all good")