TUI: Lesestand-Vertrag (zuletzt gelesen gewinnt), Login wie diora_client, Bücher beim Öffnen laden

progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.

Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-26 13:08:59 +01:00
parent edc9c6d4c3
commit 51b7316398
21 changed files with 1454 additions and 372 deletions

View file

@ -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 zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
beide Richtungen synchronisieren (siehe unten). beide Richtungen synchronisieren (siehe unten).
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben Die Lesestelle folgt dem Vertrag aus `docs/progress-sync.md`: **die zuletzt gelesene Stelle
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via gewinnt** (auf welchem Gerät auch immer), die weiteste bleibt als Hinweis erhalten. Die
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene Regeln stehen in `progress.py` und sind dieselben wie auf dem Server und in den Apps.
Position zurücksetzt.
## Setup ## Setup
@ -33,6 +32,7 @@ Tastenkürzel:
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen - `↑`/`k`, `↓`/`j` — zeilenweise scrollen
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel - `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` - `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 zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
`_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder `_looksLikeFootnoteLink` (Link in/um `<sup>`, 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, Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht. sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand Die Bibliotheksansicht ist nach der zuletzt gelesenen Stelle sortiert (neueste zuerst, auch
des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher was auf einem anderen Gerät gelesen wurde), zeigt den Lesestand in % und blendet gelesene
standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json` Bücher standardmäßig aus (`r` zeigt sie, für diese Sitzung). Bücher vom Server stehen mit ☁
gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung. in der Liste, bevor sie heruntergeladen sind.
## Bücher + Fortschritt vom Server holen (`diora-tui sync`) ## Anmelden und synchronisieren
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.ä.):
```bash ```bash
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage diora-tui login # einmal; fragt Benutzername und Passwort
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern 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/<id>/data/` herunter, `login` meldet sich wie die Apps über das Web-Formular an, liest den vorhandenen API-Token
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als von der Einstellungsseite (es wird nie ein neuer erzeugt: das würde die Apps abmelden) und
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an leitet den Buchschlüssel aus dem Passwort ab (PBKDF2, wie „Mit Passwort entsperren“ im Web).
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden Das Passwort wird nicht gespeichert. Passt der abgeleitete Schlüssel nicht (Passwort seit dem
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot Hochladen geändert, Schlüssel zufällig erzeugt), fragt `login` nach dem Schlüssel
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch). (diora: Einstellungen → Schlüssel kopieren; `--paste-key` fragt gleich danach).
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens- Token und Schlüssel liegen geschützt in `config.json`: unter Windows per DPAPI (nur dieser
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server Windows-Benutzer auf diesem Rechner kann sie lesen), sonst als Base64 in einer 0600-Datei.
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter Eine ältere Konfiguration mit Klartextfeldern wird beim ersten Lesen geschützt neu geschrieben.
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein Alle Dateien liegen unter `platformdirs`' Verzeichnissen; `DIORA_TUI_HOME=<Ordner>` legt sie
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff. stattdessen gemeinsam in einen Ordner (zweites Konto, Tests).
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter **Was synchronisiert wird:** die Bücherliste (`GET /books/`, ohne die Blobs der Markierungen)
`~/.config/diora-tui/config.json`, `chmod 600`): samt Lesestand, Gelesen-Status und der Uhr des Servers. Die Bücher selbst werden erst beim
Öffnen geladen und entschlüsselt (`<id> - <Titel>.epub` im Bibliotheksordner). PDFs werden
übersprungen.
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`. **Lesestand** (`progress.py`, `reader_screen.py`):
- **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)`.
Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der - Gespeichert wird nur, was der Leser selbst bewegt hat (Tasten, Mausrad), mit dem Zeitpunkt
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei. 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 ## Performance bei großen Büchern
@ -139,8 +130,7 @@ fügt Leerraum ein).
## Grenzen der aktuellen Version ## Grenzen der aktuellen Version
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen, - Nur EPUB, kein PDF (der Abgleich überspringt PDFs und meldet sie).
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt. - Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung - `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung (zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung

View file

@ -1,48 +1,64 @@
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for """HTTP client for the diora server, authenticated by the account's API token
local clients"). Status: that API is provisional — expect endpoint/field (Authorization: Bearer; the server sets request.user for every endpoint).
changes as the server-side implementation settles.
Only what the reader needs: the book list with the reading state (`GET /books/` – the same
rows the apps pull, without the notes' blobs that `/api/sync/` carries), a book's encrypted
file, and a position write (`POST /books/<id>/progress/`, docs/progress-sync.md).
""" """
from __future__ import annotations from __future__ import annotations
import time
import requests import requests
USER_AGENT = "diora-tui"
class ApiError(Exception): class ApiError(Exception):
pass pass
def _headers(token: str) -> dict[str, str]: 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: def _check(resp: requests.Response, what: str) -> None:
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
if resp.status_code == 401: 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() 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: 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) resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=300)
if resp.status_code == 401: _check(resp, f"Buch {book_id}")
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()
return resp.json() return resp.json()
def post_progress( def post_progress(server_url: str, token: str, book_id: int, *, anchor: str, fraction: float, read_at: int,
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False device: str, force: bool = False) -> dict:
) -> None: """Send a queued position. `sent_at` is the moment it leaves: the server corrects this
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force} machine's clock error with it. The answer is the state after the write."""
resp = requests.post( body = {"scroll_fraction": fraction, "position_anchor": anchor, "read_at": read_at,
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30 "sent_at": int(time.time() * 1000), "device": device}
) if force:
if resp.status_code == 401: body["force"] = True
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") resp = requests.post(f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30)
if resp.status_code == 404: _check(resp, f"Buch {book_id}")
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)") return resp.json()
resp.raise_for_status()

View file

@ -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).""" against a diora server (see project README)."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import getpass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from textual import work from textual import work
@ -15,13 +15,27 @@ from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView from textual.widgets import Footer, Header, Label, ListItem, ListView
from . import config as config_mod 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 .reader_screen import ReaderScreen
from .statusbar import StatusBar from .statusbar import StatusBar
DEFAULT_LIBRARY = Path.home() / "Books" 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): class LibraryScreen(Screen):
BINDINGS = [ BINDINGS = [
Binding("enter", "open_selected", "Öffnen"), Binding("enter", "open_selected", "Öffnen"),
@ -32,8 +46,8 @@ class LibraryScreen(Screen):
def __init__(self, library_dir: Path) -> None: def __init__(self, library_dir: Path) -> None:
super().__init__() super().__init__()
self.library_dir = library_dir self.library_dir = library_dir
self.paths: list[Path] = [] self.all_entries: list[Entry] = []
self.visible_paths: list[Path] = [] self.shown: list[Entry] = []
self.show_read = False self.show_read = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@ -49,47 +63,63 @@ class LibraryScreen(Screen):
if cfg is not None: if cfg is not None:
self._auto_sync(cfg) 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) @work(thread=True)
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None: def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
try: try:
result = remote.sync_library(self.library_dir, cfg) 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 return # best-effort — e.g. offline; the local library still works
self.app.call_from_thread(self._on_auto_sync_done, result) self.app.call_from_thread(self._on_auto_sync_done, result)
def _on_auto_sync_done(self, result: remote.SyncResult) -> None: def _on_auto_sync_done(self, result: remote.SyncResult) -> None:
if result.downloaded or result.progress_pulled:
self._refresh_list() self._refresh_list()
if result.downloaded: if result.new_titles:
self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3) 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: def _refresh_list(self) -> None:
self.paths = epub.scan_library(self.library_dir) self.all_entries = self._gather()
self.shown = [e for e in self.all_entries if self.show_read or not e.is_read]
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]
list_view = self.query_one("#library-list", ListView) list_view = self.query_one("#library-list", ListView)
index = list_view.index
list_view.clear() 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}"))) list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return return
if not entries: if not self.shown:
list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen"))) list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen")))
return return
for _, path, read in entries: for e in self.shown:
label = f"✓ {path.stem}" if read else path.stem mark = "✓ " if e.is_read else ""
list_view.append(ListItem(Label(label))) cloud = "☁ " if e.path is None else ""
list_view.index = 0 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() list_view.focus()
def action_toggle_read_filter(self) -> None: def action_toggle_read_filter(self) -> None:
@ -100,9 +130,27 @@ class LibraryScreen(Screen):
def action_open_selected(self) -> None: def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView) 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 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: def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected() self.action_open_selected()
@ -123,87 +171,45 @@ class DioraTuiApp(App):
cfg = config_mod.load() cfg = config_mod.load()
if cfg is not None: if cfg is not None:
try: try:
# Sends what is queued, then pulls: nothing read stays only here.
await asyncio.to_thread(remote.sync_library, self.library_dir, cfg) await asyncio.to_thread(remote.sync_library, self.library_dir, cfg)
except remote.SyncError: except remote.SyncError:
pass # best-effort — don't block quitting on a sync failure pass # best-effort — don't block quitting on a sync failure
self.exit() self.exit()
def _prompt(label: str, *, secret: bool = False) -> str: def run_login(server: str | None, *, paste_key: bool) -> None:
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip() try:
if not value: cfg = login_mod.login(server, paste_key=paste_key)
raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.") except login_mod.LoginError as e:
return value raise SystemExit(f"Fehler: {e}")
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) config_mod.save(cfg)
print("Gespeichert.") print("Gespeichert (Token und Schlüssel geschützt). Weiter mit diora-tui oder diora-tui sync.")
return cfg
def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None: def run_sync(library_dir: Path) -> None:
cfg = _resolve_remote_config(server_override, save=save) cfg = config_mod.load()
if cfg is None:
raise SystemExit("Nicht angemeldet: erst diora-tui login")
print(f"Verbinde zu {cfg.server_url} …") print(f"Verbinde zu {cfg.server_url} …")
try: try:
result = remote.sync_library(library_dir, cfg) result = remote.sync_library(library_dir, cfg)
except remote.SyncError as e: except remote.SyncError as e:
raise SystemExit(f"Fehler: {e}") raise SystemExit(f"Fehler: {e}")
print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.") print(f"{result.books} EPUB(s) auf dem Server (geladen wird beim Öffnen)." )
for name in result.downloaded: if result.pushed:
print(f" + {name}") print(f"Lesestand von {result.pushed} Buch/Bücher an den Server geschickt.")
if result.unchanged: for title in result.new_titles:
print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.") print(f" + {title}")
if result.skipped_pdf: if result.skipped_pdf:
titles = ", ".join(result.skipped_pdf) print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest nur EPUB): {', '.join(result.skipped_pdf)}")
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
if result.failed: if result.failed:
titles = ", ".join(result.failed) print(f"{len(result.failed)} Buch/Bücher nicht entschlüsselbar (falscher Schlüssel?): {', '.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.")
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="diora-tui — EPUB-Reader im Terminal, synct mit diora")
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
)
parser.add_argument( parser.add_argument(
"--library", "--library",
type=Path, type=Path,
@ -211,17 +217,23 @@ def main() -> None:
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})", help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
) )
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln") login_parser = subparsers.add_parser("login", help="Einmal anmelden (Token + Schlüssel, geschützt gespeichert)")
sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf") login_parser.add_argument("--server", help=f"Server-URL (Standard: {login_mod.DEFAULT_SERVER})")
sync_parser.add_argument( login_parser.add_argument("--paste-key", action="store_true", help="Buchschlüssel einfügen statt aus dem Passwort ableiten")
"--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen" 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() args = parser.parse_args()
library_dir = args.library.expanduser() 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": if args.command == "sync":
run_sync(library_dir, args.server, save=args.save) run_sync(library_dir)
return return
DioraTuiApp(library_dir).run() DioraTuiApp(library_dir).run()

View file

@ -47,3 +47,20 @@ FootnoteScreen {
color: $text-muted; color: $text-muted;
padding: 0 2; 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;
}

View file

@ -7,6 +7,9 @@ paint time, only with viewport height.
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from typing import Callable
from textual.geometry import Size from textual.geometry import Size
from textual.scroll_view import ScrollView from textual.scroll_view import ScrollView
from textual.strip import Strip 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__() super().__init__()
self.book_layout = book_layout self.book_layout = book_layout
self.virtual_size = Size(book_layout.width, book_layout.total_rows) 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: def render_line(self, y: int) -> Strip:
_scroll_x, scroll_y = self.scroll_offset _scroll_x, scroll_y = self.scroll_offset

View file

@ -14,13 +14,12 @@ import hashlib
import pickle import pickle
from pathlib import Path from pathlib import Path
from platformdirs import user_cache_dir from . import paths
from .blocks import FlatBook from .blocks import FlatBook
from .layout import BookLayout from .layout import BookLayout
_CACHE_VERSION = 2 # 2: blocks carry char_start/char_len _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: def _cache_key(path: Path, width: int) -> str:

View file

@ -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, Token and key are stored protected (secretstore.py), the server and user name in the clear.
getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept A config from before 2026-10 (token and key as plain JSON fields) is still read and is
in plaintext on disk, scoped to this machine/user (0600), with no additional rewritten protected at once.
at-rest encryption — same exposure as the browser's localStorage already has.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import stat import stat
from dataclasses import asdict, dataclass from dataclasses import dataclass
from pathlib import Path 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" _CONFIG_FILE = _CONFIG_DIR / "config.json"
@ -24,19 +23,42 @@ class RemoteConfig:
server_url: str server_url: str
api_token: str api_token: str
enc_key_b64: str enc_key_b64: str
username: str = ""
def load() -> RemoteConfig | None: def load() -> RemoteConfig | None:
if not _CONFIG_FILE.exists():
return None
try: try:
data = json.loads(_CONFIG_FILE.read_text()) data = json.loads(_CONFIG_FILE.read_text())
return RemoteConfig(**data) except (OSError, ValueError):
except (json.JSONDecodeError, OSError, TypeError):
return None 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: def save(cfg: RemoteConfig) -> None:
_CONFIG_DIR.mkdir(parents=True, exist_ok=True) _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2)) 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) _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

View file

@ -1,15 +1,31 @@
"""Library scanning and the stable per-book id shared across progress.py, """Library scanning and the stable per-book key shared across progress.py, blocks.py and
blocks.py, and remote.py.""" remote.py."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import re
from pathlib import Path from pathlib import Path
# Books downloaded from the server are named "<server id, 4+ digits> - <title>.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: def _book_id(path: Path) -> str:
# Identifies a book by its resolved path for now. Once the sync API exists, """A book's key for reading state: the server's id for a downloaded book (the same whether
# this should switch to whatever stable id diora assigns server-side. 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] return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]

View file

@ -1,43 +1,51 @@
"""Local per-book metadata pulled from the server — currently just "read" """What the TUI knows about the server's books beyond the files: title, author, read status
status (EBook.is_read from the /api/sync/ snapshot). Kept separate from and which books exist at all (so a book can be listed before it is downloaded). Set only by
progress.py (reading position): different concern, different cadence — this `sync`; the reader's position is progress.py.
is only ever set by `diora-tui sync`, never by the reader itself.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path 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" _META_FILE = _DATA_DIR / "library.json"
def _load_all() -> dict[str, dict]: def _load_all() -> dict[str, dict]:
if not _META_FILE.exists():
return {}
try: try:
return json.loads(_META_FILE.read_text()) data = json.loads(_META_FILE.read_text())
except (json.JSONDecodeError, OSError): except (OSError, ValueError):
return {} return {}
return data if isinstance(data, dict) else {}
def _save_all(data: dict[str, dict]) -> None: def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True) _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: def is_read(book_key: str) -> bool:
entry = _load_all().get(book_id) entry = _load_all().get(book_key)
return bool(entry and entry.get("is_read")) return bool(entry and entry.get("is_read"))
def set_many(read_status: dict[str, bool]) -> None: def get(book_key: str) -> dict | None:
if not read_status: return _load_all().get(book_key)
return
data = _load_all()
for book_id, read in read_status.items(): def remote_entries() -> dict[str, dict]:
data.setdefault(book_id, {})["is_read"] = read """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) _save_all(data)

109
tui/diora_tui/login.py Normal file
View file

@ -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)

30
tui/diora_tui/paths.py Normal file
View file

@ -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"))

View file

@ -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 This is the contract in diora-web docs/progress-sync.md, the same rules the server
server uses (books/models.py, EBookProgress.save_progress) — see (books/views.py save_progress) and the other clients apply (diora-android Ops.kt,
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js diora-desktop src/main/ops.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 * A position carries `read_at`, the moment the reader arrived there. Local times are the
only ever advances (unless forced), so an older/offline run can't regress a server's clock: `offset` (server - this machine, ms, learnt from the server's clock on every
further-along read position — matching that same rule locally. 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 from __future__ import annotations
import json import json
import functools
import os
import threading
import time import time
from dataclasses import dataclass from dataclasses import asdict, dataclass, replace
from pathlib import Path 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" _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 @dataclass
class Position: class BookState:
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000" anchor: str = ""
updated_at: float = 0.0 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]: def _locked(fn):
if not _PROGRESS_FILE.exists(): @functools.wraps(fn)
return {} def wrapper(self, *a, **kw):
with self._lock:
return fn(self, *a, **kw)
return wrapper
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: try:
return json.loads(_PROGRESS_FILE.read_text()) raw = json.loads(self.path.read_text())
except (json.JSONDecodeError, OSError): except (OSError, ValueError):
return {} 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 _save_all(data: dict[str, dict]) -> None: def _merge_pending(old: dict | None, new: dict) -> dict:
_DATA_DIR.mkdir(parents=True, exist_ok=True) """Two queued writes for the book: the place read later is the one to send; a forced place
_PROGRESS_FILE.write_text(json.dumps(data, indent=2)) 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 load(book_id: str) -> Position | None: _default: ProgressStore | None = 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 save(book_id: str, position: Position, *, force: bool = False) -> bool: def default() -> ProgressStore:
"""Returns True if the position was actually written (i.e. it was further global _default
along, or forced) — callers that push to the server only need to do so if _default is None:
when this returns True.""" _default = ProgressStore()
data = _load_all() return _default
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

View file

@ -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 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 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. 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 from __future__ import annotations
import bisect import bisect
import re import threading
from pathlib import Path from pathlib import Path
from rich.console import Console from rich.console import Console
@ -23,12 +29,17 @@ from textual.geometry import Size
from textual.screen import ModalScreen, Screen from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static 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 .book_view import ContinuousBookView
from .statusbar import StatusBar from .statusbar import StatusBar
MAX_LINE_WIDTH = 120 MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0 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) # ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
# so this stays constant — measuring the container's width and subtracting # so this stays constant — measuring the container's width and subtracting
# this fixed amount avoids a measure-after-constrain race against book_view's # 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) _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]): class FootnoteScreen(ModalScreen[None]):
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")] BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
@ -64,6 +66,27 @@ class FootnoteScreen(ModalScreen[None]):
self.dismiss(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): class ReaderScreen(Screen):
BINDINGS = [ BINDINGS = [
Binding("j,down", "scroll_down_line", "Runter", show=False), Binding("j,down", "scroll_down_line", "Runter", show=False),
@ -71,6 +94,7 @@ class ReaderScreen(Screen):
Binding("n", "next_chapter", "Nächstes Kapitel"), Binding("n", "next_chapter", "Nächstes Kapitel"),
Binding("p", "prev_chapter", "Vorheriges Kapitel"), Binding("p", "prev_chapter", "Vorheriges Kapitel"),
Binding("f", "peek_footnote", "Fußnote"), Binding("f", "peek_footnote", "Fußnote"),
Binding("w", "goto_furthest", "Weiteste Stelle"),
Binding("q,escape", "back", "Zurück zur Bibliothek"), Binding("q,escape", "back", "Zurück zur Bibliothek"),
] ]
@ -79,9 +103,19 @@ class ReaderScreen(Screen):
self.path = path self.path = path
self.book: blocks.FlatBook | None = None self.book: blocks.FlatBook | None = None
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
self._server_id = _server_book_id(path) self._key = epub._book_id(path)
self._remote_cfg = config_mod.load() if self._server_id is not None else None self._server_id = epub.server_id_of(path)
self._last_pushed_anchor = "" 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): def compose(self):
yield Header() yield Header()
@ -93,7 +127,7 @@ class ReaderScreen(Screen):
# to visible hasn't been through a layout pass yet, so its `.size` # 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 # is still (0, 0) and an immediate scroll_to() right after has
# nothing to clamp against and silently resets to 0. # 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 self._book_view
yield StatusBar() yield StatusBar()
yield Footer() yield Footer()
@ -129,8 +163,12 @@ class ReaderScreen(Screen):
self.sub_title = book.title self.sub_title = book.title
self._apply_layout(book_layout, width) self._apply_layout(book_layout, width)
self.query_one("#reader-loading", LoadingIndicator).display = False 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.call_after_refresh(self._restore_position)
self.set_interval(AUTOSAVE_INTERVAL, self._autosave) 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: def _content_width(self) -> int:
# Measuring the *container* rather than book_view's own size avoids a # Measuring the *container* rather than book_view's own size avoids a
@ -168,21 +206,31 @@ class ReaderScreen(Screen):
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y) anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
self._build_layout() self._build_layout()
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac) new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=new_y, animate=False, immediate=True) 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: def _restore_position(self, attempt: int = 0) -> None:
if self.book is None: if self.book is None:
return return
saved = progress.load(self.book.id) st = self._store.get(self._key)
if saved is None: if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None:
self._settle()
return return
parsed = blocks.parse_anchor(saved.anchor) parsed = blocks.parse_anchor(st.anchor)
if parsed is None: y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
return
block_index, inner_fraction = parsed
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
if y <= 0: if y <= 0:
self._settle()
return return
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True) 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 # 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 # honor an immediate scroll on the first attempt (its own size/scroll
@ -190,41 +238,143 @@ class ReaderScreen(Screen):
# retry a bounded number of times rather than guessing a fixed delay. # retry a bounded number of times rather than guessing a fixed delay.
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1: if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
self.call_after_refresh(lambda: self._restore_position(attempt + 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: def _current_anchor_str(self) -> str:
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return blocks.format_anchor(block_index, inner_fraction) 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: def _autosave(self) -> None:
self._save_progress() self._save_progress()
def _save_progress(self) -> None: 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 return
anchor = self._current_anchor_str() anchor = self._current_anchor_str()
advanced = progress.save(self.book.id, progress.Position(anchor=anchor)) if anchor == self._last_anchor:
if advanced and self._remote_cfg is not None and self._server_id is not None: self._last_move_ms = None # moved and came back: nothing new
self._push_remote_progress(anchor) 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") @work(thread=True, exclusive=True, group="progress-push")
def _push_remote_progress(self, anchor: str) -> None: def _push(self) -> None:
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
return
try: try:
api.post_progress( remote.flush_pending(self._cfg)
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
except Exception: 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: def on_unmount(self) -> None:
self._save_progress() 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: def action_scroll_down_line(self) -> None:
self._book_view.scroll_relative(y=1, animate=False) self._book_view.scroll_relative(y=1, animate=False)

View file

@ -1,27 +1,24 @@
"""Fetch + decrypt books from a diora server into the local TUI library, and """Talk to the diora server: pull the book list and reading state, push queued positions,
pull reading progress for them into the local progress store. download a book when it is opened.
Progress is pull-only here (server -> local); the reader pushes local -> Reading state follows docs/progress-sync.md (progress.py holds the rules). Books are listed
server itself while a book is open (see reader_screen.py). Both directions from the server's list and fetched only when opened (the file is encrypted: decrypted here
use the same "blockIndex:innerFraction" anchor format as the web reader with the account's key and stored as a plain .epub in the library folder).
(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.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import requests import requests
from . import api, crypto, library_meta from . import api, crypto, library_meta, progress
from .config import RemoteConfig from .config import RemoteConfig
from .epub import _book_id from .epub import server_key
from .progress import Position, save as save_progress
class SyncError(Exception): class SyncError(Exception):
@ -30,11 +27,11 @@ class SyncError(Exception):
@dataclass @dataclass
class SyncResult: class SyncResult:
downloaded: list[str] = field(default_factory=list) books: int = 0
unchanged: int = 0
skipped_pdf: list[str] = field(default_factory=list) skipped_pdf: list[str] = field(default_factory=list)
failed: 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: def _sanitize_filename(name: str) -> str:
@ -42,69 +39,89 @@ def _sanitize_filename(name: str) -> str:
return (name or "buch")[:80] return (name or "buch")[:80]
def _parse_meta(raw: bytes) -> dict: def local_path(library_dir: Path, server_id: int) -> Path | None:
return json.loads(raw.decode("utf-8")) 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: 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: 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: except api.ApiError as e:
raise SyncError(str(e)) from e raise SyncError(str(e)) from e
except requests.RequestException as e: except requests.RequestException as e:
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
library_dir.mkdir(parents=True, exist_ok=True) store = progress.default()
result = SyncResult() store.set_server_now(server_now, device_now)
local_path_by_server_id: dict[int, Path] = {} known = library_meta.remote_entries()
entries: dict[str, dict] = {}
for book in snapshot.get("books", []): for b in books:
book_id = book["id"]
try: try:
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"])) meta = json.loads(crypto.decrypt(cfg.enc_key_b64, b["meta_iv"], b["meta_ct"]).decode("utf-8"))
except crypto.DecryptError: except (crypto.DecryptError, ValueError):
result.failed.append(f"#{book_id}") result.failed.append(f"#{b['id']}")
continue continue
title = meta.get("title") or f"Buch {b['id']}"
if meta.get("type") == "pdf": if meta.get("type") == "pdf":
result.skipped_pdf.append(meta.get("title") or f"#{book_id}") result.skipped_pdf.append(title)
continue continue
key = server_key(b["id"])
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None) entries[key] = {"server_id": b["id"], "title": title, "author": meta.get("author") or "",
if existing is not None: "is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or ""}
result.unchanged += 1 if key not in known:
local_path_by_server_id[book_id] = existing result.new_titles.append(title)
continue store.apply_server(key, b)
library_meta.set_remote(entries)
try: result.books = len(entries)
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)
return result 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

View file

@ -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")

View file

@ -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"): for var in ("LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"):
os.environ[var] = _HOME os.environ[var] = _HOME
os.environ["HOME"] = _HOME os.environ["HOME"] = _HOME
os.environ["DIORA_TUI_HOME"] = _HOME # platformdirs ignores the variables above on Windows
import pytest # noqa: E402 import pytest # noqa: E402

View file

@ -1,10 +1,21 @@
"""The Textual app starts, lists a library, opens a book and leaves it again.""" """The Textual app starts, lists a library, opens a book and leaves it again."""
import asyncio import asyncio
import pytest
from diora_tui.app import DioraTuiApp from diora_tui.app import DioraTuiApp
from diora_tui.reader_screen import ReaderScreen 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): def test_open_read_and_leave(epub, tmp_path):
lib = tmp_path / "lib" lib = tmp_path / "lib"
lib.mkdir() lib.mkdir()
@ -28,3 +39,30 @@ def test_open_read_and_leave(epub, tmp_path):
assert not isinstance(app.screen, ReaderScreen) assert not isinstance(app.screen, ReaderScreen)
asyncio.run(run()) 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({})

142
tui/tests/test_progress.py Normal file
View file

@ -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

View file

@ -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())

59
tui/tests/test_remote.py Normal file
View file

@ -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

88
tui/tools/smoke_server.py Normal file
View file

@ -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")