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
beide Richtungen synchronisieren (siehe unten).
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene
Position zurücksetzt.
Die Lesestelle folgt dem Vertrag aus `docs/progress-sync.md`: **die zuletzt gelesene Stelle
gewinnt** (auf welchem Gerät auch immer), die weiteste bleibt als Hinweis erhalten. Die
Regeln stehen in `progress.py` und sind dieselben wie auf dem Server und in den Apps.
## Setup
@ -33,6 +32,7 @@ Tastenkürzel:
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel
- `w` — zur weitesten Stelle des Buchs springen (auf Wunsch; beim Öffnen weist ein Hinweis darauf hin)
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q`
zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
`_looksLikeFootnoteLink` (Link in/um `<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,
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand
des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher
standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json`
gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung.
Die Bibliotheksansicht ist nach der zuletzt gelesenen Stelle sortiert (neueste zuerst, auch
was auf einem anderen Gerät gelesen wurde), zeigt den Lesestand in % und blendet gelesene
Bücher standardmäßig aus (`r` zeigt sie, für diese Sitzung). Bücher vom Server stehen mit ☁
in der Liste, bevor sie heruntergeladen sind.
## Bücher + Fortschritt vom Server holen (`diora-tui sync`)
Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe
unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue
Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von
selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App
tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für
Nicht-interaktive Nutzung (Cron o.ä.):
## Anmelden und synchronisieren
```bash
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern
diora-tui login # einmal; fragt Benutzername und Passwort
diora-tui # die Oberfläche, synct beim Start und beim Beenden
diora-tui sync # nur abgleichen, ohne Oberfläche
diora-tui logout # gespeicherte Zugangsdaten löschen
```
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter,
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch).
`login` meldet sich wie die Apps über das Web-Formular an, liest den vorhandenen API-Token
von der Einstellungsseite (es wird nie ein neuer erzeugt: das würde die Apps abmelden) und
leitet den Buchschlüssel aus dem Passwort ab (PBKDF2, wie „Mit Passwort entsperren“ im Web).
Das Passwort wird nicht gespeichert. Passt der abgeleitete Schlüssel nicht (Passwort seit dem
Hochladen geändert, Schlüssel zufällig erzeugt), fragt `login` nach dem Schlüssel
(diora: Einstellungen → Schlüssel kopieren; `--paste-key` fragt gleich danach).
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens-
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff.
Token und Schlüssel liegen geschützt in `config.json`: unter Windows per DPAPI (nur dieser
Windows-Benutzer auf diesem Rechner kann sie lesen), sonst als Base64 in einer 0600-Datei.
Eine ältere Konfiguration mit Klartextfeldern wird beim ersten Lesen geschützt neu geschrieben.
Alle Dateien liegen unter `platformdirs`' Verzeichnissen; `DIORA_TUI_HOME=<Ordner>` legt sie
stattdessen gemeinsam in einen Ordner (zweites Konto, Tests).
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
`~/.config/diora-tui/config.json`, `chmod 600`):
**Was synchronisiert wird:** die Bücherliste (`GET /books/`, ohne die Blobs der Markierungen)
samt Lesestand, Gelesen-Status und der Uhr des Servers. Die Bücher selbst werden erst beim
Öffnen geladen und entschlüsselt (`<id> - <Titel>.epub` im Bibliotheksordner). PDFs werden
übersprungen.
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token".
- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig
verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege:
1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's
"Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000
Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`).
Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt
hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese
Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als
Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis.
2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der
Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in
`app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in
der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die
lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins
Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem
`NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage`
lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`.
**Lesestand** (`progress.py`, `reader_screen.py`):
Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei.
- Gespeichert wird nur, was der Leser selbst bewegt hat (Tasten, Mausrad), mit dem Zeitpunkt
des Ankommens (`read_at`). Wiederherstellen, Umbruch bei Größenänderung und Sprünge auf
Angebote ändern nichts. Ein offenes, ruhendes Buch kann deshalb nie eine neuere Stelle von
woanders überschreiben.
- Zeiten sind Serverzeit: die TUI lernt aus jeder Antwort den Versatz ihrer Uhr, und der
Server korrigiert mit `sent_at` nach. Ein falsch gehender Rechner ordnet trotzdem richtig.
- Jede Stelle wird lokal angewendet und vorgemerkt (`pending`, je Buch die später gelesene),
bis der Server sie hat, auch offline; nach einem Abgleich wird sie nach derselben Regel auf
den Stand des Servers gelegt.
- Alle zwei Minuten fragt ein offenes Buch den Server. Liegt dort eine neuere Stelle von einem
anderen Gerät, fragt die TUI „Auf *X* weitergelesen — bei n %. Springen?“ (`y`/`n`), und
bewegt sich nie von allein.
- Der Fortschritt in % ist wie im Web der Anteil am Buchtext in Zeichen
(`blocks.fraction_for_anchor`), nicht mehr 0.
## Performance bei großen Büchern
@ -139,8 +130,7 @@ fügt Leerraum ein).
## Grenzen der aktuellen Version
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
- Nur EPUB, kein PDF (der Abgleich überspringt PDFs und meldet sie).
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung

View file

@ -1,48 +1,64 @@
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for
local clients"). Status: that API is provisional — expect endpoint/field
changes as the server-side implementation settles.
"""HTTP client for the diora server, authenticated by the account's API token
(Authorization: Bearer; the server sets request.user for every endpoint).
Only what the reader needs: the book list with the reading state (`GET /books/` – the same
rows the apps pull, without the notes' blobs that `/api/sync/` carries), a book's encrypted
file, and a position write (`POST /books/<id>/progress/`, docs/progress-sync.md).
"""
from __future__ import annotations
import time
import requests
USER_AGENT = "diora-tui"
class ApiError(Exception):
pass
def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
return {"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT}
def fetch_sync_snapshot(server_url: str, token: str) -> dict:
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
def _check(resp: requests.Response, what: str) -> None:
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
raise ApiError("Anmeldung abgelehnt — Token ungültig oder neu erzeugt? `diora-tui login` ausführen.")
if resp.status_code == 404:
raise ApiError(f"{what} nicht gefunden (gelöscht oder anderes Konto?)")
resp.raise_for_status()
return resp.json()
def fetch_books(server_url: str, token: str) -> tuple[list[dict], int | None, int]:
"""(books, the server's clock in ms from X-Server-Now or None, this machine's clock in ms when
the answer came). Each book: id, meta_ct, meta_iv, uploaded_at, is_read, has_cover and the
reading state (scroll_fraction, position_anchor, read_at, device, furthest_*)."""
resp = requests.get(f"{server_url}/books/", headers=_headers(token), timeout=60)
device_now = int(time.time() * 1000)
_check(resp, "Bücherliste")
try:
server_now = int(resp.headers.get("X-Server-Now", ""))
except ValueError:
server_now = None
return resp.json(), server_now, device_now
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=120)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
if resp.status_code == 404:
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
resp.raise_for_status()
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=300)
_check(resp, f"Buch {book_id}")
return resp.json()
def post_progress(
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False
) -> None:
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force}
resp = requests.post(
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30
)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
if resp.status_code == 404:
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
resp.raise_for_status()
def post_progress(server_url: str, token: str, book_id: int, *, anchor: str, fraction: float, read_at: int,
device: str, force: bool = False) -> dict:
"""Send a queued position. `sent_at` is the moment it leaves: the server corrects this
machine's clock error with it. The answer is the state after the write."""
body = {"scroll_fraction": fraction, "position_anchor": anchor, "read_at": read_at,
"sent_at": int(time.time() * 1000), "device": device}
if force:
body["force"] = True
resp = requests.post(f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30)
_check(resp, f"Buch {book_id}")
return resp.json()

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)."""
from __future__ import annotations
import argparse
import asyncio
import getpass
from dataclasses import dataclass
from pathlib import Path
from textual import work
@ -15,13 +15,27 @@ from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView
from . import config as config_mod
from . import crypto, epub, library_meta, progress, remote
from . import epub, library_meta, login as login_mod, progress, remote
from .reader_screen import ReaderScreen
from .statusbar import StatusBar
DEFAULT_LIBRARY = Path.home() / "Books"
@dataclass
class Entry:
"""A line of the library: a file in the folder, a server book not downloaded yet, or both."""
key: str
title: str
path: Path | None
server_id: int | None
is_read: bool
read_at: int
uploaded_at: str
fraction: float
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
@ -32,8 +46,8 @@ class LibraryScreen(Screen):
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.paths: list[Path] = []
self.visible_paths: list[Path] = []
self.all_entries: list[Entry] = []
self.shown: list[Entry] = []
self.show_read = False
def compose(self) -> ComposeResult:
@ -49,47 +63,63 @@ class LibraryScreen(Screen):
if cfg is not None:
self._auto_sync(cfg)
def on_screen_resume(self) -> None:
self._refresh_list() # a book was read: the order and the percentages changed
@work(thread=True)
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
try:
result = remote.sync_library(self.library_dir, cfg)
except remote.SyncError:
except remote.SyncError as e:
self.app.call_from_thread(self.notify, str(e), severity="warning", timeout=5)
return # best-effort — e.g. offline; the local library still works
self.app.call_from_thread(self._on_auto_sync_done, result)
def _on_auto_sync_done(self, result: remote.SyncResult) -> None:
if result.downloaded or result.progress_pulled:
self._refresh_list()
if result.downloaded:
self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3)
self._refresh_list()
if result.new_titles:
self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3)
def _gather(self) -> list[Entry]:
store = progress.default()
remote_books = library_meta.remote_entries()
by_key: dict[str, Entry] = {}
for path in epub.scan_library(self.library_dir):
key = epub._book_id(path)
info = remote_books.get(key) or {}
by_key[key] = Entry(key, info.get("title") or path.stem, path, epub.server_id_of(path),
bool(info.get("is_read")), 0, info.get("uploaded_at") or "", 0.0)
for key, info in remote_books.items():
if key not in by_key:
by_key[key] = Entry(key, info["title"], None, info["server_id"], bool(info.get("is_read")),
0, info.get("uploaded_at") or "", 0.0)
for key, e in by_key.items():
st = store.get(key)
if st is not None:
e.read_at = st.read_at or 0
e.fraction = st.fraction
# last read first; never opened ones by when they came
return sorted(by_key.values(), key=lambda e: (e.read_at, e.uploaded_at), reverse=True)
def _refresh_list(self) -> None:
self.paths = epub.scan_library(self.library_dir)
entries = []
for path in self.paths:
book_id = epub._book_id(path)
read = library_meta.is_read(book_id)
if read and not self.show_read:
continue
saved = progress.load(book_id)
last_opened = saved.updated_at if saved else 0.0
entries.append((last_opened, path, read))
entries.sort(key=lambda e: e[0], reverse=True)
self.visible_paths = [path for _, path, _ in entries]
self.all_entries = self._gather()
self.shown = [e for e in self.all_entries if self.show_read or not e.is_read]
list_view = self.query_one("#library-list", ListView)
index = list_view.index
list_view.clear()
if not self.paths:
if not self.all_entries:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return
if not entries:
if not self.shown:
list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen")))
return
for _, path, read in entries:
label = f"✓ {path.stem}" if read else path.stem
list_view.append(ListItem(Label(label)))
list_view.index = 0
for e in self.shown:
mark = "✓ " if e.is_read else ""
cloud = "☁ " if e.path is None else ""
pct = f" {round(e.fraction * 100)} %" if e.fraction > 0 else ""
list_view.append(ListItem(Label(f"{mark}{cloud}{e.title}{pct}")))
list_view.index = min(index or 0, len(self.shown) - 1)
list_view.focus()
def action_toggle_read_filter(self) -> None:
@ -100,9 +130,27 @@ class LibraryScreen(Screen):
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.visible_paths or list_view.index is None:
if not self.shown or list_view.index is None or list_view.index >= len(self.shown):
return
self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index]))
entry = self.shown[list_view.index]
if entry.path is not None:
self.app.push_screen(ReaderScreen(entry.path))
return
cfg = config_mod.load()
if cfg is None or entry.server_id is None:
self.notify("Nicht angemeldet — `diora-tui login`.", severity="error")
return
self.notify(f"Lade „{entry.title}“ …", timeout=4)
self._download_and_open(cfg, entry)
@work(thread=True, exclusive=True, group="download")
def _download_and_open(self, cfg: config_mod.RemoteConfig, entry: Entry) -> None:
try:
path = remote.download_book(self.library_dir, cfg, entry.server_id, entry.title)
except remote.SyncError as e:
self.app.call_from_thread(self.notify, str(e), severity="error", timeout=6)
return
self.app.call_from_thread(self.app.push_screen, ReaderScreen(path))
def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected()
@ -123,87 +171,45 @@ class DioraTuiApp(App):
cfg = config_mod.load()
if cfg is not None:
try:
# Sends what is queued, then pulls: nothing read stays only here.
await asyncio.to_thread(remote.sync_library, self.library_dir, cfg)
except remote.SyncError:
pass # best-effort — don't block quitting on a sync failure
self.exit()
def _prompt(label: str, *, secret: bool = False) -> str:
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip()
if not value:
raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.")
return value
def run_login(server: str | None, *, paste_key: bool) -> None:
try:
cfg = login_mod.login(server, paste_key=paste_key)
except login_mod.LoginError as e:
raise SystemExit(f"Fehler: {e}")
config_mod.save(cfg)
print("Gespeichert (Token und Schlüssel geschützt). Weiter mit diora-tui oder diora-tui sync.")
def _ask_yes_no(question: str) -> bool:
return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja")
def _prompt_enc_key() -> str:
print()
print("Verschlüsselungs-Key — zwei Wege:")
print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)")
print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)")
choice = input("Wahl [1/2, Standard 1]: ").strip() or "1"
if choice == "2":
return _prompt("Verschlüsselungs-Key (Base64)", secret=True)
print(
"Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über "
"diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. "
"War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer "
"(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne "
"etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen."
)
username = _prompt("diora-Benutzername")
password = _prompt("diora-Passwort", secret=True)
return crypto.derive_key_b64(username, password)
def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig:
cfg = None if server_override else config_mod.load()
if cfg is not None:
return cfg
print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.")
server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/")
token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True)
enc_key = _prompt_enc_key()
cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key)
if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"):
config_mod.save(cfg)
print("Gespeichert.")
return cfg
def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None:
cfg = _resolve_remote_config(server_override, save=save)
def run_sync(library_dir: Path) -> None:
cfg = config_mod.load()
if cfg is None:
raise SystemExit("Nicht angemeldet: erst diora-tui login")
print(f"Verbinde zu {cfg.server_url} …")
try:
result = remote.sync_library(library_dir, cfg)
except remote.SyncError as e:
raise SystemExit(f"Fehler: {e}")
print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.")
for name in result.downloaded:
print(f" + {name}")
if result.unchanged:
print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.")
print(f"{result.books} EPUB(s) auf dem Server (geladen wird beim Öffnen)." )
if result.pushed:
print(f"Lesestand von {result.pushed} Buch/Bücher an den Server geschickt.")
for title in result.new_titles:
print(f" + {title}")
if result.skipped_pdf:
titles = ", ".join(result.skipped_pdf)
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest nur EPUB): {', '.join(result.skipped_pdf)}")
if result.failed:
titles = ", ".join(result.failed)
print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}")
if result.progress_pulled:
print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.")
print(f"{len(result.failed)} Buch/Bücher nicht entschlüsselbar (falscher Schlüssel?): {', '.join(result.failed)}")
def main() -> None:
parser = argparse.ArgumentParser(
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
)
parser = argparse.ArgumentParser(description="diora-tui — EPUB-Reader im Terminal, synct mit diora")
parser.add_argument(
"--library",
type=Path,
@ -211,17 +217,23 @@ def main() -> None:
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
)
subparsers = parser.add_subparsers(dest="command")
sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln")
sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf")
sync_parser.add_argument(
"--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen"
)
login_parser = subparsers.add_parser("login", help="Einmal anmelden (Token + Schlüssel, geschützt gespeichert)")
login_parser.add_argument("--server", help=f"Server-URL (Standard: {login_mod.DEFAULT_SERVER})")
login_parser.add_argument("--paste-key", action="store_true", help="Buchschlüssel einfügen statt aus dem Passwort ableiten")
subparsers.add_parser("logout", help="Gespeicherte Zugangsdaten löschen")
subparsers.add_parser("sync", help="Bücherliste und Lesestand abgleichen (ohne die Oberfläche)")
args = parser.parse_args()
library_dir = args.library.expanduser()
if args.command == "login":
run_login(args.server, paste_key=args.paste_key)
return
if args.command == "logout":
print("Zugangsdaten gelöscht." if config_mod.forget() else "Keine Zugangsdaten gespeichert.")
return
if args.command == "sync":
run_sync(library_dir, args.server, save=args.save)
run_sync(library_dir)
return
DioraTuiApp(library_dir).run()

View file

@ -47,3 +47,20 @@ FootnoteScreen {
color: $text-muted;
padding: 0 2;
}
JumpOfferScreen {
align: center middle;
}
#offer-body {
width: 60;
height: auto;
background: $panel;
border: round $primary;
padding: 1 2;
}
#offer-hint {
margin-top: 1;
color: $text-muted;
}

View file

@ -7,6 +7,9 @@ paint time, only with viewport height.
from __future__ import annotations
from contextlib import contextmanager
from typing import Callable
from textual.geometry import Size
from textual.scroll_view import ScrollView
from textual.strip import Strip
@ -27,10 +30,28 @@ class ContinuousBookView(ScrollView):
}
"""
def __init__(self, book_layout: BookLayout) -> None:
def __init__(self, book_layout: BookLayout, on_read_scroll: Callable[[], None] | None = None) -> None:
super().__init__()
self.book_layout = book_layout
self.virtual_size = Size(book_layout.width, book_layout.total_rows)
# Called when the reader moves the view (keys, wheel, scrollbar), not when the program does
# (restoring a position, re-wrapping after a resize, jumping on an offer): only the first
# is "arriving at a place by reading" for the reading position (docs/progress-sync.md).
self.on_read_scroll = on_read_scroll
self._program_scrolls = 0
@contextmanager
def program_scroll(self):
self._program_scrolls += 1
try:
yield
finally:
self._program_scrolls -= 1
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
super().watch_scroll_y(old_value, new_value)
if old_value != new_value and not self._program_scrolls and self.on_read_scroll is not None:
self.on_read_scroll()
def render_line(self, y: int) -> Strip:
_scroll_x, scroll_y = self.scroll_offset

View file

@ -14,13 +14,12 @@ import hashlib
import pickle
from pathlib import Path
from platformdirs import user_cache_dir
from . import paths
from .blocks import FlatBook
from .layout import BookLayout
_CACHE_VERSION = 2 # 2: blocks carry char_start/char_len
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache"
_CACHE_DIR = paths.cache_dir() / "layout_cache"
def _cache_key(path: Path, width: int) -> str:

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

View file

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

View file

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

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
server uses (books/models.py, EBookProgress.save_progress) — see
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
exactly, and anchor_is_further() for the identical "furthest wins" comparison
used server-side (_progress_is_further in books/views.py). A saved position
only ever advances (unless forced), so an older/offline run can't regress a
further-along read position — matching that same rule locally.
This is the contract in diora-web docs/progress-sync.md, the same rules the server
(books/views.py save_progress) and the other clients apply (diora-android Ops.kt,
diora-desktop src/main/ops.js):
* A position carries `read_at`, the moment the reader arrived there. Local times are the
server's clock: `offset` (server - this machine, ms, learnt from the server's clock on every
answer) turns this machine's `read_at` into it. Saving an unchanged position again carries the
old time, so an idle reader can never beat a newer position from elsewhere.
* A write is stored when its time is >= the stored one (`force`: always, time = now). The
furthest place ever reported only grows.
* A write is also queued (`pending`, one per book: the later `read_at` wins, `force` sticks)
until the server has it. After a pull the pending write is applied again on top of the
server's state with the same rule.
State lives in progress.json next to the book list; the file also holds the clock offset.
"""
from __future__ import annotations
import json
import functools
import os
import threading
import time
from dataclasses import dataclass
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from platformdirs import user_data_dir
from . import paths
from .blocks import parse_anchor
from .blocks import anchor_is_further
DEVICE_NAME = "diora TUI"
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_DATA_DIR = paths.data_dir()
_PROGRESS_FILE = _DATA_DIR / "progress.json"
_VERSION = 2
def now_ms() -> int:
return int(time.time() * 1000)
def plausible_ms(v) -> int | None:
"""A time (ms since epoch) from the server or a body, or None (as Ops.ms)."""
if isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v in (float("inf"), float("-inf")):
return None
t = int(v)
return t if 10**12 <= t < 10**14 else None
def cmp_progress(a_anchor: str, a_frac: float, b_anchor: str, b_frac: float) -> float:
"""> 0 if A is further into the book than B, < 0 if behind (books/views.py _cmp_progress)."""
a, b = parse_anchor(a_anchor or ""), parse_anchor(b_anchor or "")
if a is not None and b is not None:
return (a[0] - b[0]) if a[0] != b[0] else a[1] - b[1]
return a_frac - b_frac
@dataclass
class Position:
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
updated_at: float = 0.0
class BookState:
anchor: str = ""
fraction: float = 0.0
read_at: int | None = None # server clock, ms
device: str = ""
furthest_anchor: str = ""
furthest_fraction: float = 0.0
# The write the server hasn't got yet: {"anchor", "fraction", "read_at" (this machine's
# clock), "force"}.
pending: dict | None = None
# The server's read_at for the last write of ours that it stored: tells our own position
# coming back from a pull from another device's.
echo_read_at: int | None = None
def _load_all() -> dict[str, dict]:
if not _PROGRESS_FILE.exists():
return {}
try:
return json.loads(_PROGRESS_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _locked(fn):
@functools.wraps(fn)
def wrapper(self, *a, **kw):
with self._lock:
return fn(self, *a, **kw)
return wrapper
def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
class ProgressStore:
"""Shared by the UI thread (the reader writes) and the sync workers (they pull and push)."""
def __init__(self, path: Path | None = None) -> None:
self.path = path or _PROGRESS_FILE
self._lock = threading.RLock()
self.offset = 0
self.books: dict[str, BookState] = {}
self._load()
# ---- persistence
def _load(self) -> None:
try:
raw = json.loads(self.path.read_text())
except (OSError, ValueError):
return
if not isinstance(raw, dict):
return
if raw.get("version") == _VERSION:
self.offset = int(raw.get("offset") or 0)
for key, entry in (raw.get("books") or {}).items():
try:
self.books[key] = BookState(**entry)
except TypeError:
continue
return
# version 1: {book_id: {anchor, updated_at (s)}}, furthest-wins, nothing queued
for key, entry in raw.items():
if isinstance(entry, dict) and entry.get("anchor"):
self.books[key] = BookState(
anchor=entry["anchor"], read_at=int(entry.get("updated_at", 0) * 1000) or None,
furthest_anchor=entry["anchor"],
)
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
data = {"version": _VERSION, "offset": self.offset,
"books": {k: asdict(v) for k, v in self.books.items()}}
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=1))
os.replace(tmp, self.path)
# ---- clock
@_locked
def set_server_now(self, server_now: int | None, device_now: int | None = None) -> None:
t = plausible_ms(server_now)
if t is not None:
self.offset = t - (device_now if device_now is not None else now_ms())
# ---- reads
@_locked
def get(self, key: str) -> BookState | None:
"""A copy: the sync workers change the live state."""
st = self.books.get(key)
return replace(st, pending=dict(st.pending) if st.pending else None) if st else None
@_locked
def pending_keys(self) -> list[str]:
return [k for k, v in self.books.items() if v.pending]
# ---- writes
@_locked
def write(self, key: str, anchor: str, fraction: float, read_at: int, *, force: bool = False,
queue: bool = True, device: str = DEVICE_NAME, now: int | None = None) -> bool:
"""Apply a position the reader reached (`read_at`: this machine's clock) locally and
queue it for the server (`queue` False: the book isn't one of the server's). Returns
whether it is the continue point now."""
st = self.books.setdefault(key, BookState())
fraction = max(0.0, min(1.0, fraction))
took = self._apply(st, anchor, fraction, read_at, force, device, now)
if queue:
new = {"anchor": anchor, "fraction": fraction, "read_at": read_at, "force": force}
st.pending = _merge_pending(st.pending, new)
self.save()
return took
def _apply(self, st: BookState, anchor: str, fraction: float, read_at: int | None, force: bool,
device: str, now: int | None) -> bool:
now_server = (now if now is not None else now_ms()) + self.offset
if force:
when, take = now_server, True
else:
when = min((read_at or 0) + self.offset, now_server)
take = st.read_at is None or when >= st.read_at
if take:
st.anchor, st.fraction, st.read_at, st.device = anchor, fraction, when, device[:40]
if cmp_progress(anchor, fraction, st.furthest_anchor, st.furthest_fraction) > 0:
st.furthest_anchor, st.furthest_fraction = anchor, fraction
return take
@_locked
def apply_server(self, key: str, entry: dict, *, keep_pending: bool = True) -> None:
"""Take the server's state for the book (an entry of /books/ or a progress answer), then
apply the pending write on top by the same rule."""
st = self.books.setdefault(key, BookState())
st.anchor = entry.get("position_anchor") or ""
st.fraction = float(entry.get("scroll_fraction") or 0.0)
st.read_at = plausible_ms(entry.get("read_at"))
st.device = entry.get("device") or ""
st.furthest_anchor = entry.get("furthest_anchor") or ""
st.furthest_fraction = float(entry.get("furthest_fraction") or 0.0)
if st.pending and keep_pending:
p = st.pending
self._apply(st, p["anchor"], p["fraction"], p["read_at"], bool(p.get("force")), DEVICE_NAME, None)
self.save()
@_locked
def sent(self, key: str, body: dict, answer: dict | None) -> None:
"""The server took `body` (the pending write as it was sent): forget it unless a newer one
queued meanwhile, and take the server's state from the answer."""
st = self.books.get(key)
if st is None:
return
if st.pending and st.pending.get("read_at") == body.get("read_at") and bool(st.pending.get("force")) == bool(body.get("force")):
st.pending = None
if answer:
self.set_server_now(answer.get("server_now"))
if answer.get("stored") is True:
st.echo_read_at = plausible_ms(answer.get("read_at"))
self.apply_server(key, answer)
else:
self.save()
def load(book_id: str) -> Position | None:
entry = _load_all().get(book_id)
if not isinstance(entry, dict) or not entry.get("anchor"):
return None
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
def _merge_pending(old: dict | None, new: dict) -> dict:
"""Two queued writes for the book: the place read later is the one to send; a forced place
stays forced (Ops.merge)."""
if old is None:
return new
keep = new if new["read_at"] >= old["read_at"] else old
keep = dict(keep)
if old.get("force") or new.get("force"):
keep["force"] = True
return keep
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
"""Returns True if the position was actually written (i.e. it was further
along, or forced) — callers that push to the server only need to do so
when this returns True."""
data = _load_all()
existing = data.get(book_id)
if existing is not None and not force:
old_anchor = existing.get("anchor", "")
if not anchor_is_further(position.anchor, old_anchor):
return False
if not position.updated_at:
position.updated_at = time.time()
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
_save_all(data)
return True
_default: ProgressStore | None = None
def default() -> ProgressStore:
global _default
if _default is None:
_default = ProgressStore()
return _default

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
single giant renderable made Textual's layout/paint pass take upwards of a
minute, whereas the Line API only ever renders the rows on screen.
Reading position (docs/progress-sync.md, rules in progress.py): the place last read wins.
The reader saves a position only when the *reader* moved it (keys, wheel), stamped with the
moment of arriving; restoring, re-wrapping on resize and jumping on an offer change nothing.
While a book is open the server is asked now and then; a newer position from another device
is offered ("Auf X weitergelesen — springen?"), never applied by itself.
"""
from __future__ import annotations
import bisect
import re
import threading
from pathlib import Path
from rich.console import Console
@ -23,12 +29,17 @@ from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
from . import api, blocks, cache, config as config_mod, layout, progress
from . import blocks, cache, config as config_mod, epub, layout, library_meta, progress, remote
from .book_view import ContinuousBookView
from .statusbar import StatusBar
MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0
# How often the server is asked for a newer position while the book is open (the apps pull
# at most every 2 minutes).
REMOTE_CHECK_INTERVAL = 120.0
# "Weiteste Stelle" is offered when the furthest place is this much of the book ahead.
FURTHEST_OFFER_SHARE = 0.01
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
# so this stays constant — measuring the container's width and subtracting
# this fixed amount avoids a measure-after-constrain race against book_view's
@ -37,15 +48,6 @@ SCROLLBAR_GUTTER = 2
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see
# remote.py) — reused here to find the server book id for progress push.
_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ")
def _server_book_id(path: Path) -> int | None:
m = _SERVER_ID_RE.match(path.name)
return int(m.group(1)) if m else None
class FootnoteScreen(ModalScreen[None]):
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
@ -64,6 +66,27 @@ class FootnoteScreen(ModalScreen[None]):
self.dismiss(None)
class JumpOfferScreen(ModalScreen[bool]):
"""A question the reader answers with a key; nothing moves unless the answer is yes."""
BINDINGS = [
Binding("y,enter", "answer(True)", "Springen"),
Binding("n,escape,q", "answer(False)", "Hier bleiben"),
]
def __init__(self, text: str) -> None:
super().__init__()
self._text = text
def compose(self):
with Container(id="offer-body"):
yield Static(self._text)
yield Label("y/Enter: springen n/Escape: hier bleiben", id="offer-hint")
def action_answer(self, yes: bool) -> None:
self.dismiss(yes)
class ReaderScreen(Screen):
BINDINGS = [
Binding("j,down", "scroll_down_line", "Runter", show=False),
@ -71,6 +94,7 @@ class ReaderScreen(Screen):
Binding("n", "next_chapter", "Nächstes Kapitel"),
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
Binding("f", "peek_footnote", "Fußnote"),
Binding("w", "goto_furthest", "Weiteste Stelle"),
Binding("q,escape", "back", "Zurück zur Bibliothek"),
]
@ -79,9 +103,19 @@ class ReaderScreen(Screen):
self.path = path
self.book: blocks.FlatBook | None = None
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
self._server_id = _server_book_id(path)
self._remote_cfg = config_mod.load() if self._server_id is not None else None
self._last_pushed_anchor = ""
self._key = epub._book_id(path)
self._server_id = epub.server_id_of(path)
self._cfg = config_mod.load() if self._server_id is not None else None
self._store = progress.default()
# The position the view is on, as last saved or restored, and when the reader last
# moved it (this machine's clock). A change of the anchor is a reading position only
# when there is such a move.
self._last_anchor: str | None = None
self._last_move_ms: int | None = None
# read_at (server clock) of the state this view is based on: a newer one from the
# server is what gets offered.
self._held_read_at: int | None = None
self._offer_open = False
def compose(self):
yield Header()
@ -93,7 +127,7 @@ class ReaderScreen(Screen):
# to visible hasn't been through a layout pass yet, so its `.size`
# is still (0, 0) and an immediate scroll_to() right after has
# nothing to clamp against and silently resets to 0.
self._book_view = ContinuousBookView(_EMPTY_LAYOUT)
self._book_view = ContinuousBookView(_EMPTY_LAYOUT, on_read_scroll=self._on_read_scroll)
yield self._book_view
yield StatusBar()
yield Footer()
@ -129,8 +163,12 @@ class ReaderScreen(Screen):
self.sub_title = book.title
self._apply_layout(book_layout, width)
self.query_one("#reader-loading", LoadingIndicator).display = False
st = self._store.get(self._key)
self._held_read_at = st.read_at if st else None
self.call_after_refresh(self._restore_position)
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
if self._cfg is not None:
self.set_interval(REMOTE_CHECK_INTERVAL, self._check_remote)
def _content_width(self) -> int:
# Measuring the *container* rather than book_view's own size avoids a
@ -168,63 +206,175 @@ class ReaderScreen(Screen):
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
self._build_layout()
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
def _scroll_to_anchor(self, anchor: str) -> bool:
parsed = blocks.parse_anchor(anchor)
if parsed is None:
return False
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
return y > 0
def _restore_position(self, attempt: int = 0) -> None:
if self.book is None:
return
saved = progress.load(self.book.id)
if saved is None:
st = self._store.get(self._key)
if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None:
self._settle()
return
parsed = blocks.parse_anchor(saved.anchor)
if parsed is None:
return
block_index, inner_fraction = parsed
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
parsed = blocks.parse_anchor(st.anchor)
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
if y <= 0:
self._settle()
return
self._book_view.scroll_to(y=y, animate=False, immediate=True)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
# A widget that's only just become part of the layout doesn't always
# honor an immediate scroll on the first attempt (its own size/scroll
# bounds can still be mid-update) — verify it actually landed and
# retry a bounded number of times rather than guessing a fixed delay.
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
self.call_after_refresh(lambda: self._restore_position(attempt + 1))
return
self._settle()
self._offer_furthest(st)
def _settle(self) -> None:
"""The view is where the saved position put it: from here a changed anchor with a move
by the reader is a reading position."""
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
def _current_anchor_str(self) -> str:
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return blocks.format_anchor(block_index, inner_fraction)
# ---- saving
def _on_read_scroll(self) -> None:
self._last_move_ms = progress.now_ms()
def _autosave(self) -> None:
self._save_progress()
def _save_progress(self) -> None:
if self.book is None or not self.book_layout.heights:
if self.book is None or not self.book_layout.heights or self._last_anchor is None:
return
anchor = self._current_anchor_str()
advanced = progress.save(self.book.id, progress.Position(anchor=anchor))
if advanced and self._remote_cfg is not None and self._server_id is not None:
self._push_remote_progress(anchor)
if anchor == self._last_anchor:
self._last_move_ms = None # moved and came back: nothing new
return
moved_at = self._last_move_ms
self._last_anchor = anchor
self._last_move_ms = None
if moved_at is None:
return # the layout moved it, not the reader
block_index, inner = blocks.parse_anchor(anchor)
fraction = blocks.fraction_for_anchor(self.book, block_index, inner)
self._store.write(self._key, anchor, fraction, moved_at, queue=self._cfg is not None)
st = self._store.get(self._key)
if st is not None:
self._held_read_at = st.read_at
if self._cfg is not None:
self._push()
@work(thread=True, exclusive=True, group="progress-push")
def _push_remote_progress(self, anchor: str) -> None:
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
return
def _push(self) -> None:
try:
api.post_progress(
self._remote_cfg.server_url,
self._remote_cfg.api_token,
self._server_id,
scroll_fraction=0.0,
position_anchor=anchor,
force=False,
)
self._last_pushed_anchor = anchor
remote.flush_pending(self._cfg)
except Exception:
pass # best-effort — local progress is already saved regardless
return # best-effort — the position is saved locally and stays queued
self.app.call_from_thread(self._after_push)
def _after_push(self) -> None:
# What the server stored of ours is no news; a newer position of another device in its
# answer (our write was refused) is.
st = self._store.get(self._key)
if st is not None and st.echo_read_at is not None and st.echo_read_at >= (self._held_read_at or 0):
self._held_read_at = st.echo_read_at
self._offer_newer()
def on_unmount(self) -> None:
self._save_progress()
if self._cfg is not None and self._store.pending_keys():
# The screen's workers die with it; the queue still has to go out.
threading.Thread(target=self._flush_quietly, args=(self._cfg,), daemon=False).start()
@staticmethod
def _flush_quietly(cfg) -> None:
try:
remote.flush_pending(cfg)
except Exception:
pass
# ---- the server's newer positions
@work(thread=True, exclusive=True, group="progress-check")
def _check_remote(self) -> None:
try:
remote.sync_library(self.path.parent, self._cfg)
except Exception:
return # offline etc.: ask again later
self.app.call_from_thread(self._offer_newer)
def _offer_newer(self) -> None:
if self.book is None or self._offer_open or self._last_anchor is None:
return
st = self._store.get(self._key)
if st is None or not st.anchor or st.read_at is None:
return
if self._held_read_at is not None and st.read_at <= self._held_read_at:
return
parsed = blocks.parse_anchor(st.anchor)
if parsed is None:
return
shown_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
self._held_read_at = st.read_at # asked once per newer state
if parsed[0] == shown_block:
return
pct = round(blocks.fraction_for_anchor(self.book, *parsed) * 100)
who = st.device or "einem anderen Gerät"
self._ask(f"Auf {who} weitergelesen — bei {pct} %.\nSpringen?", st.anchor)
def _offer_furthest(self, st: progress.BookState) -> None:
if self.book is None or not st.furthest_anchor:
return
here, there = blocks.parse_anchor(st.anchor), blocks.parse_anchor(st.furthest_anchor)
if there is None:
return
share = blocks.fraction_for_anchor(self.book, *there) - (blocks.fraction_for_anchor(self.book, *here) if here else 0.0)
if share >= FURTHEST_OFFER_SHARE:
pct = round(blocks.fraction_for_anchor(self.book, *there) * 100)
self.notify(f"Weiteste Stelle: {pct} % — mit w springen.", timeout=6)
def _ask(self, text: str, anchor: str) -> None:
self._offer_open = True
def answered(yes: bool | None) -> None:
self._offer_open = False
if yes:
self._scroll_to_anchor(anchor)
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
self.app.push_screen(JumpOfferScreen(text), answered)
def action_goto_furthest(self) -> None:
st = self._store.get(self._key)
if self.book is None or st is None or not st.furthest_anchor:
self.notify("Keine weiteste Stelle bekannt.", timeout=3)
return
if blocks.parse_anchor(st.furthest_anchor) is None:
return
self._scroll_to_anchor(st.furthest_anchor)
# A jump, not reading: nothing is saved until the reader carries on from there.
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
# ---- navigation
def action_scroll_down_line(self) -> None:
self._book_view.scroll_relative(y=1, animate=False)

View file

@ -1,27 +1,24 @@
"""Fetch + decrypt books from a diora server into the local TUI library, and
pull reading progress for them into the local progress store.
"""Talk to the diora server: pull the book list and reading state, push queued positions,
download a book when it is opened.
Progress is pull-only here (server -> local); the reader pushes local ->
server itself while a book is open (see reader_screen.py). Both directions
use the same "blockIndex:innerFraction" anchor format as the web reader
(diora_tui/blocks.py) and the same furthest-wins merge rule
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
only ever advance local progress, never regress it, unless forced.
Reading state follows docs/progress-sync.md (progress.py holds the rules). Books are listed
from the server's list and fetched only when opened (the file is encrypted: decrypted here
with the account's key and stored as a plain .epub in the library folder).
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
import requests
from . import api, crypto, library_meta
from . import api, crypto, library_meta, progress
from .config import RemoteConfig
from .epub import _book_id
from .progress import Position, save as save_progress
from .epub import server_key
class SyncError(Exception):
@ -30,11 +27,11 @@ class SyncError(Exception):
@dataclass
class SyncResult:
downloaded: list[str] = field(default_factory=list)
unchanged: int = 0
books: int = 0
skipped_pdf: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
progress_pulled: int = 0
pushed: int = 0
new_titles: list[str] = field(default_factory=list)
def _sanitize_filename(name: str) -> str:
@ -42,69 +39,89 @@ def _sanitize_filename(name: str) -> str:
return (name or "buch")[:80]
def _parse_meta(raw: bytes) -> dict:
return json.loads(raw.decode("utf-8"))
def local_path(library_dir: Path, server_id: int) -> Path | None:
return next(library_dir.glob(f"{server_id:04d} - *.epub"), None)
def flush_pending(cfg: RemoteConfig) -> int:
"""Send every queued position. Stops quietly at the first network trouble (the rest stays
queued); a book the server no longer has drops its queue."""
store = progress.default()
pushed = 0
for key in store.pending_keys():
st = store.get(key)
if not (key.startswith("s") and key[1:].isdigit() and st and st.pending):
continue
body = dict(st.pending)
try:
answer = api.post_progress(
cfg.server_url, cfg.api_token, int(key[1:]), anchor=body["anchor"], fraction=body["fraction"],
read_at=body["read_at"], device=progress.DEVICE_NAME, force=bool(body.get("force")),
)
except api.ApiError as e:
if "nicht gefunden" in str(e):
store.sent(key, body, None)
continue
raise SyncError(str(e)) from e
except requests.RequestException:
break
store.sent(key, body, answer)
pushed += 1
return pushed
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
"""Push what is queued, then pull the book list and every book's reading state."""
result = SyncResult()
try:
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
result.pushed = flush_pending(cfg)
books, server_now, device_now = api.fetch_books(cfg.server_url, cfg.api_token)
except api.ApiError as e:
raise SyncError(str(e)) from e
except requests.RequestException as e:
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
library_dir.mkdir(parents=True, exist_ok=True)
result = SyncResult()
local_path_by_server_id: dict[int, Path] = {}
for book in snapshot.get("books", []):
book_id = book["id"]
store = progress.default()
store.set_server_now(server_now, device_now)
known = library_meta.remote_entries()
entries: dict[str, dict] = {}
for b in books:
try:
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"]))
except crypto.DecryptError:
result.failed.append(f"#{book_id}")
meta = json.loads(crypto.decrypt(cfg.enc_key_b64, b["meta_iv"], b["meta_ct"]).decode("utf-8"))
except (crypto.DecryptError, ValueError):
result.failed.append(f"#{b['id']}")
continue
title = meta.get("title") or f"Buch {b['id']}"
if meta.get("type") == "pdf":
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
result.skipped_pdf.append(title)
continue
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
if existing is not None:
result.unchanged += 1
local_path_by_server_id[book_id] = existing
continue
try:
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
except (api.ApiError, crypto.DecryptError, requests.RequestException):
result.failed.append(meta.get("title") or f"#{book_id}")
continue
title = meta.get("title") or f"book-{book_id}"
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
dest.write_bytes(raw)
result.downloaded.append(dest.name)
local_path_by_server_id[book_id] = dest
for entry in snapshot.get("book_progress", []):
anchor = entry.get("position_anchor") or ""
if not anchor:
continue # PDF-only scroll_fraction progress — this TUI is EPUB-only
dest = local_path_by_server_id.get(entry.get("book_id"))
if dest is None:
continue
local_id = _book_id(dest)
if save_progress(local_id, Position(anchor=anchor)):
result.progress_pulled += 1
read_status = {
_book_id(path): bool(book.get("is_read"))
for book in snapshot.get("books", [])
if (path := local_path_by_server_id.get(book["id"])) is not None
}
library_meta.set_many(read_status)
key = server_key(b["id"])
entries[key] = {"server_id": b["id"], "title": title, "author": meta.get("author") or "",
"is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or ""}
if key not in known:
result.new_titles.append(title)
store.apply_server(key, b)
library_meta.set_remote(entries)
result.books = len(entries)
return result
def download_book(library_dir: Path, cfg: RemoteConfig, server_id: int, title: str) -> Path:
existing = local_path(library_dir, server_id)
if existing is not None:
return existing
try:
data = api.fetch_book_data(cfg.server_url, cfg.api_token, server_id)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
except api.ApiError as e:
raise SyncError(str(e)) from e
except crypto.DecryptError as e:
raise SyncError("Das Buch lässt sich mit dem gespeicherten Schlüssel nicht entschlüsseln.") from e
except requests.RequestException as e:
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
library_dir.mkdir(parents=True, exist_ok=True)
dest = library_dir / f"{server_id:04d} - {_sanitize_filename(title)}.epub"
tmp = dest.with_suffix(".part")
tmp.write_bytes(raw)
os.replace(tmp, dest)
return dest

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"):
os.environ[var] = _HOME
os.environ["HOME"] = _HOME
os.environ["DIORA_TUI_HOME"] = _HOME # platformdirs ignores the variables above on Windows
import pytest # noqa: E402

View file

@ -1,10 +1,21 @@
"""The Textual app starts, lists a library, opens a book and leaves it again."""
import asyncio
import pytest
from diora_tui.app import DioraTuiApp
from diora_tui.reader_screen import ReaderScreen
@pytest.fixture
def store_fresh(tmp_path, monkeypatch):
from diora_tui import progress
st = progress.ProgressStore(tmp_path / "p.json")
monkeypatch.setattr(progress, "_default", st)
return st
def test_open_read_and_leave(epub, tmp_path):
lib = tmp_path / "lib"
lib.mkdir()
@ -28,3 +39,30 @@ def test_open_read_and_leave(epub, tmp_path):
assert not isinstance(app.screen, ReaderScreen)
asyncio.run(run())
def test_server_books_are_listed_before_they_are_downloaded(tmp_path, store_fresh):
from textual.widgets import Label, ListView
from diora_tui import library_meta
lib = tmp_path / "lib"
lib.mkdir()
library_meta.set_remote({"s9": {"server_id": 9, "title": "Cloud-Buch", "author": "", "is_read": False, "uploaded_at": "2026-10-01"}})
store_fresh.apply_server("s9", {"position_anchor": "5:0.5", "scroll_fraction": 0.4, "read_at": 1_760_000_000_000})
async def run():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
items = app.screen.query_one(ListView).query(Label)
text = " | ".join(str(i.render()) for i in items)
assert "☁ Cloud-Buch" in text and "40 %" in text
await pilot.press("enter") # not signed in here: says so instead of failing
await pilot.pause()
assert not isinstance(app.screen, ReaderScreen)
try:
asyncio.run(run())
finally:
library_meta.set_remote({})

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