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