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:
parent
edc9c6d4c3
commit
51b7316398
21 changed files with 1454 additions and 372 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
109
tui/diora_tui/login.py
Normal 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
30
tui/diora_tui/paths.py
Normal 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"))
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
55
tui/diora_tui/secretstore.py
Normal file
55
tui/diora_tui/secretstore.py
Normal 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")
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
142
tui/tests/test_progress.py
Normal 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
|
||||||
129
tui/tests/test_reader_progress.py
Normal file
129
tui/tests/test_reader_progress.py
Normal 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
59
tui/tests/test_remote.py
Normal 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
88
tui/tools/smoke_server.py
Normal 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")
|
||||||
Loading…
Add table
Reference in a new issue