Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
6047ae44c4 TUI: TODO (Pull Request anlegen, Forgejo-Zugang, selbst ausprobieren)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:55:56 +01:00
e246a32212 TUI: Markierungen, Notizen und Lesezeichen (anzeigen, anlegen, löschen, abgleichen)
Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs
(Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet
(marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert
(Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen,
a öffnet die Liste (springen, Notiz ändern, löschen).

Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben
dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt.
Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf
(tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der
Server-Smoke schreibt und mischt zwei Geräte. 75 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:42:32 +01:00
695037fb9c TUI: Bibliothek wie im Web (Weiterlesen-Karte, Status- und Ordner-Chips, Suche, Sortierung, Länge)
library.py hält die Regeln der Web-Bibliothek (53-library.js) als reine Funktionen:
Status Angefangen/Ungelesen/Gelesen, Ordner (mehrere je Buch, Reihenfolge aus den
verschlüsselten Bibliothekseinstellungen, "Ohne Ordner"), Suche ohne Akzente, sechs
Sortierungen mit natürlicher Zahlenordnung, Weiterlesen-Karte aus den zuletzt gelesenen,
Bücher/Zeitschriften. library_screen.py zeigt sie an (Tasten 1-4, o, /, s, m, c; Chips und
Karte klickbar). Metadaten (Ordner, Seitenzahl, Zeitschrift) und die Ordnerreihenfolge
kommen beim Abgleich; fehlt die Länge, rechnet die TUI sie für geladene Bücher aus dem Text
(1800 Zeichen je Seite). crypto.encrypt für Stufe 3. 54 Tests, Smoke prüft Ordner/Länge/Prefs
gegen den lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:18:49 +01:00
51b7316398 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>
2026-10-26 13:08:59 +01:00
edc9c6d4c3 TUI: EPUB wie der Browser lesen (HTML5, Rohdaten) und Zeichenpositionen je Block
Blocktext, Block-Index und Zeichenoffsets stimmen nun bei sechs echten Büchern exakt mit
parseEpub des Web-Readers überein. Grundlage für einen echten Fortschrittsanteil
(fraction_for_anchor) statt 0.0. Parser html5lib statt lxml, Rohinhalt statt
ebooklib-get_content (formatiert um), UTF-8 wie JSZip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:59:03 +01:00
7095e176b7 TUI: Blocktext wie der Web-Reader, Tests und Anker-Parität
HTML-Kommentare zählten als Text (1435 Blöcke in einem Testbuch), Leerraum wurde mit
Pythons statt JavaScripts \s zusammengezogen, Fußnotenmarker bekamen Leerzeichen.
Neu: tests/ (Blockliste, Anker, App-Start mit Textual-Pilot) und tools/parity/ – vergleicht
Anzahl und Tags der Blöcke mit parseEpub aus dem Web-Reader in headless Chromium
(6 Bücher, bis 25.798 Blöcke: gleich).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:40:20 +01:00
58f59476d8 Merge master into wt-tui 2026-10-26 12:33:14 +01:00
marwin
e9683fbdbd TUI: Zeichen am rechten Rand bei schmalen Terminals nicht mehr abgeschnitten
ContinuousBookViews vertikale Scrollbar reserviert 2 Spalten, die vorher
nicht aus der Wrap-Breite herausgerechnet wurden — der Text wurde also
2 Zeichen breiter gewrappt, als tatsächlich sichtbar war, wodurch die
Scrollbar die letzten 1-2 Buchstaben jeder Zeile überdeckt hat. Bei
schmalen Terminals (großer Font, wenig Spalten) war das besonders
auffällig, betraf strukturell aber jede Breite.

Fix: overflow-x: hidden (eine ungewollte horizontale Scrollbar hat
zusätzlich eine Zeile unten geklaut) + overflow-y: scroll (hält die
Scrollbar-Breite von Anfang an konstant, kein Rätselraten je nach
Inhaltsgröße) in book_view.py. reader_screen.py misst die Wrap-Breite
jetzt am Container statt an book_view selbst (vermeidet einen
Miss-nach-Einschränken-Zirkelbezug bei wiederholten Resizes) und rechnet
die feste Scrollbar-Breite (SCROLLBAR_GUTTER=2) heraus; beim Anwenden
des Layouts wird sie wieder daraufgerechnet, damit book_view.styles.width
weiterhin fürs Zentrieren passt.

Getestet: content_region-Breite stimmt jetzt exakt mit der Wrap-Breite
über sechs verschiedene Terminalbreiten (60-200 Spalten, inkl. des
120-Zeichen-Cap-Bereichs) überein — vorher lag sie durchgehend 2 Spalten
darunter. Voller Regressionstest (Scroll, Fußnote, Resize) läuft weiter
fehlerfrei.
2026-08-15 21:07:41 +02:00
marwin
20d04361bc TUI: Statusleiste mit Akkustand und Uhrzeit
Neues diora_tui/statusbar.py: StatusBar-Widget, unten rechts auf Bibliotheks-
und Reader-Screen, zeigt Akkustand (psutil.sensors_battery(), degradiert
sauber zu reiner Uhrzeit auf Geräten ohne Akku) + aktuelle Uhrzeit,
sekündlich aktualisiert.

Getestet: Anzeige und Aktualisierung auf beiden Screens verifiziert.
2026-08-15 20:36:54 +02:00
marwin
b1a04d2a65 TUI: Auto-Sync bei Start/Beenden, Sortierung nach zuletzt geöffnet, Gelesen-Filter
Library-Screen synct jetzt selbstständig statt den expliziten `sync`-Befehl
vorauszusetzen: einmal im Hintergrund direkt nach dem Start (neue Bücher/
Fortschritt erscheinen, sobald fertig), einmal abgewartet beim Beenden über
q (DioraTuiApp.action_quit). Beides best-effort — ohne gespeicherte
Zugangsdaten oder bei Netzwerkfehlern bleibt die lokale Bibliothek
unangetastet nutzbar.

Bibliotheksliste sortiert jetzt nach zuletzt geöffnetem Buch (progress.json
updated_at, neueste zuerst) und blendet gelesene Bücher standardmäßig aus
(EBook.is_read aus dem Sync-Snapshot, lokal in library.json gespiegelt) —
r-Taste zeigt sie für die Sitzung wieder an.

Getestet: vollständig isoliert (Pfad-Konstanten gemonkeypatcht statt echter
~/.config-/~/.local/share-Dateien) gegen einen echten Dev-Server mit zwei
Büchern unterschiedlichen Gelesen-Status und Fortschritts-Zeitstempeln —
Auto-Sync, Filter-Default, Toggle und Sortierreihenfolge korrekt bestätigt;
Sync-vor-dem-Beenden separat verifiziert (kein echter Hänger, nur ein
Test-Timing-Artefakt ohne pilot.pause()).
2026-08-15 20:30:09 +02:00
marwin
db3f520632 TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync
Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel
statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py,
reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR
gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive
linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte
sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen
den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung
(Terminal hat keine Pixel-Geometrie), was funktioniert, weil der
Furthest-Wins-Vergleich primär nach blockIndex sortiert.

Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt
eines einzelnen riesigen Static — bei großen Büchern (mehrere reale
heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den
Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached
diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte
für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und
zentriert.

Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js'
_looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen
Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele
häufig auf einem inneren <a> statt dem umschließenden <p> sitzen.

Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da
beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus
dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei
offenen server-verknüpften Büchern Updates zurück (force: false, im
Hintergrund-Worker).

Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback
localStorage.getItem(...) falls die Clipboard-API in der Konsole
verweigert wird.

Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a.
3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik
per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll,
Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere
Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s,
warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine
Race-Condition beim ersten Post-Load-Scroll, und Progress-Push
End-to-End gegen einen echten Dev-Server verifiziert.
2026-08-15 17:59:46 +02:00
marwin
50f711831c TUI: Verschlüsselungs-Key per Benutzername+Passwort ableiten statt Konsolen-Export
diora_tui/crypto.py:derive_key_b64 reproduziert app.js' deriveAndStoreKey()
(PBKDF2-HMAC-SHA256, 200000 Iterationen, Salt "diora:"+username) — der
`sync`-Prompt bietet das jetzt als Standardweg zum Schlüssel an, als
Alternative zum bisherigen Weg über die Browser-Konsole (await
exportEncKey()). Funktioniert nur für Accounts, die den Key je über
diora's "Unlock with password"-Formular abgeleitet haben; ein falsches
Passwort/Account führt zu einem abgefangenen DecryptError ("falscher
Key?"), nie zu stillem Fehlverhalten.

Getestet: zwei unabhängige PBKDF2-Implementierungen (hashlib,
cryptography) stimmen für die verwendeten Parameter byte-genau überein;
vollständiger sync-Lauf gegen einen echten Dev-Server mit einem Buch, das
unter einem exakt so abgeleiteten Key verschlüsselt wurde, entschlüsselt
korrekt; falsches Passwort wird sauber als Fehler gemeldet statt
abzustürzen.
2026-08-15 16:55:03 +02:00
marwin
a4b10ae265 TUI: Bücher per diora-tui sync vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.

Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.

Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
marwin
3fcb74631c Merge master (Sync-API) into worktree-tui-reader 2026-08-15 16:22:54 +02:00
marwin
f63bd1f879 TUI: lokaler EPUB-Reader als erste Stufe eines diora-TUI-Clients
Python + Textual, unter tui/. Liest EPUBs aus einem Bibliotheksordner,
zeigt Kapitel als Fließtext, j/k/Pfeiltasten zum Scrollen, n/p für
Kapitelwechsel. Fortschritt wird lokal (progress.json via platformdirs)
nach demselben "furthest wins"-Prinzip wie diora's Web-Reader gespeichert,
damit ein Sync-Layer gegen die künftige diora-API später ohne Rewrite
andocken kann. Vorerst rein lokal, kein Server-Kontakt, keine
Verschlüsselung.
2026-08-15 16:04:18 +02:00
43 changed files with 5396 additions and 0 deletions

191
tui/README.md Normal file
View file

@ -0,0 +1,191 @@
# diora-tui
EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
Zeigt ein Buch als **eine durchgehende Ansicht** über alle Kapitel hinweg (wie diora's
Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
`"blockIndex:innerFraction"`-Positionsangabe geführt wie der Web-Reader (`books/models.py`,
`EBookProgress`) — `blockIndex` zählt dabei exakt wie `static/js/app.js`s
`EPUB_BLOCK_SELECTOR` (`p, h1-h6, li, blockquote, dt, dd, figcaption` + textbasierte
`div`s ohne Element-Kinder), fortlaufend über das ganze Buch. Dadurch ist die Position
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
beide Richtungen synchronisieren (siehe unten).
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
```bash
cd tui
pip install -e .
```
## Nutzung
```bash
diora-tui --library ~/Books # Standard: ~/Books
```
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)
- `v` — Text markieren (siehe unten), `b` — Lesezeichen setzen, `a` — Liste der Lesezeichen,
Markierungen und Notizen
- `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
`epub:type="noteref"`)
- Bibliothek: `Enter` öffnet das markierte Buch, `c` das Buch der „Weiterlesen“-Karte, `1`–`4`
Status (Alle · Angefangen · Ungelesen · Gelesen), `o` Ordner, `/` Suche, `s` Sortierung,
`m` Bücher ⇄ Zeitschriften, `Escape` setzt Suche und Filter zurück. Chips und Karte lassen
sich auch anklicken.
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek)
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
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 Bibliothek
Wie die Bibliothek im Web (`docs/library.md`), nur im Terminal:
- **Weiterlesen-Karte** oben (Titel, Autor, Balken, „Seite 132 von 186“, wann und auf welchem
Gerät zuletzt gelesen) und darunter die Bücher, die davor gelesen wurden. Das Buch der Karte
steht nicht noch einmal in der Liste. Bei Suche und Filtern entfällt die Karte.
- **Chips:** Alle · Angefangen · Ungelesen · Gelesen (mit Anzahl), dann die Ordner in der
Reihenfolge, die im Web eingestellt ist (verschlüsselte Bibliothekseinstellungen), und
„Ohne Ordner“. Ein Buch kann in mehreren Ordnern stehen.
- **Suche** in Titel und Autor, ohne Beachtung von Groß-/Kleinschreibung und Akzenten.
- **Sortierung:** Zuletzt · Titel · Autor · Neu hinzugefügt · Kürzeste/Längste zuerst (Zahlen
nach Wert: „Band 2“ vor „Band 10“). Sie bleibt gespeichert.
- **Länge:** die Seitenzahl aus den Metadaten (`pages`); fehlt sie, rechnet die TUI sie für
schon geladene Bücher einmal aus dem Text aus (1800 Zeichen je Seite, wie der Web-Reader), wenn
nach Länge sortiert wird („Länge wird ermittelt …“). Nicht geladene Bücher ohne Länge stehen am
Ende. Zurück auf den Server schreibt die TUI die Länge nicht; das macht der Web-Reader.
- **Bücher ⇄ Zeitschriften** (`m`): Zeitschriften sind, was in den Metadaten so markiert ist
oder im Ordner „Zeitschriften“/„Magazine“ liegt. Die Heft-Ansicht mit Artikeln gibt es in der
TUI nicht; ein Heft ist ein Buch.
- ☁ vor dem Titel: auf dem Server, noch nicht geladen. ✓: gelesen.
Nicht in der TUI: Serien-Stapel, Ausgaben (mehrere Fassungen eines Werks stehen einzeln),
Cover, Ordner anlegen/zuweisen, Eingang aus der Cloud.
## Markierungen, Notizen, Lesezeichen
Dieselben Daten wie im Web-Reader (verschlüsselt mit dem Buchschlüssel, je Buch und Art eine Liste
`/books/<id>/highlights/` und `/bookmarks/`; Format in `annotations.py`). Was im Web, in den Apps
oder hier gemacht wird, erscheint überall.
- **Anzeige:** Markierungen stehen im Text mit der Farbe (Gelb, Grün, Blau, Rot); eine freie Notiz
ist unterstrichen. Die Anker des Web-Readers (Block-Nummer plus Zeichenposition im Rohtext des
Absatzes) werden auf den angezeigten Text umgerechnet; alte Anker ohne Blocknummer findet die TUI
wie das Web über das Zitat. `a` öffnet die Liste (Enter springt dorthin, `e` ändert die Notiz,
`d` löscht, Escape schließt).
- **Markieren (`v`):** Der Cursor steht auf einem Wort; `h`/`l` wechseln das Wort, `j`/`k` den
Absatz. Enter setzt den Anfang, danach `h`/`l`/`j`/`k` zum Ende und Enter: Farbe wählen, oder
„Gelb mit Notiz“. `n` legt statt dessen eine Notiz auf das Wort bzw. die Auswahl. Escape bricht
ab. Stellen, die sich nicht genau zuordnen lassen (ein Fußnotenmarker mit Leerraum im Absatz),
lehnt die TUI ab, statt eine falsche Position zu schreiben.
- **Lesezeichen (`b`):** wie im Web das aktuelle Kapitel (Name der Kapiteldatei, neueste zuerst).
- **Abgleich:** Der Server ersetzt die Liste als Ganzes und führt nichts zusammen; die Apps
schreiben deshalb ihre ganze Liste. Die TUI merkt sich, was sie zuletzt vom Server gesehen hat, und
legt vor dem Schreiben ihre Änderungen (neu, gelöscht, geändert, je Eintrag-ID) auf die aktuelle
Liste des Servers: was ein anderes Gerät inzwischen markiert hat, geht nicht verloren. Offline
bleiben Änderungen vorgemerkt und gehen beim nächsten Abgleich, beim Verlassen des Buchs oder
beim Beenden hinaus. Beim Öffnen eines Buchs werden seine Listen geholt.
- **Prüfung gegen das Web:** `tools/parity/web_anchors.cjs` lässt den echten `resolveAnchorRange`
des Web-Readers die in der TUI erzeugten Anker auflösen (an zwei echten Büchern: alle 81 Anker
an der gemeinten Stelle); umgekehrt lassen sich 34 von 34 echten Web-Markierungen anzeigen (eine alte
XPath-Notiz über ihr Zitat). Nicht in der TUI: PDF-Markierungen.
## Anmelden und synchronisieren
```bash
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
```
`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).
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).
**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.
**Lesestand** (`progress.py`, `reader_screen.py`):
- 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
Sehr große Bücher (mehrstellige Tausend Absätze — z.B. Sammelbände) können beim
*ersten* Öffnen mehrere Sekunden bis niedrige zweistellige Sekunden brauchen (Parsing +
Zeilenumbruch-Berechnung für die durchgehende Ansicht). Ein zweites Öffnen desselben
Buchs bei gleicher Terminalbreite ist dank Cache (`~/.cache/diora-tui/layout_cache/`)
deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße — nur die
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
auf einmal.
## Tests und Anker-Parität mit dem Web-Reader
```bash
pip install -e . pytest
pytest tests # Blockliste, Anker, App-Start (Textual-Pilot)
```
Die Position ist ein Block-Index, den alle Clients gleich zählen müssen. Ob die TUI das tut,
prüft `tools/parity/` gegen den echten Web-Code (`parseEpub` aus `static/js/app/40-epub.js`
in headless Chromium):
```bash
NODE_PATH=<diora-web>/node_modules PLAYWRIGHT_BROWSERS_PATH=... node tools/parity/web_blocks.cjs <diora-web> web.json buch1.epub buch2.epub
python tools/parity/compare_blocks.py web.json
```
Stand 2026-10-26 (6 Bücher, bis 25.798 Blöcke): Anzahl, Tags, Text, Zeichenposition und
-länge aller Blöcke sowie die Gesamtlänge sind identisch. Das setzt voraus, dass die TUI den
EPUB-Inhalt wie der Browser liest: UTF-8-dekodiert, mit dem HTML5-Parser (`html5lib`, nicht
libxml2) und aus den Rohdaten (`item.content`; `get_content()` von ebooklib formatiert um und
fügt Leerraum ein).
## Grenzen der aktuellen Version
- Nur EPUB, kein PDF (der Abgleich überspringt PDFs und meldet sie); keine PDF-Markierungen.
- Notizen sind einzeilig; mehrzeilige Notizen aus dem Web werden angezeigt und lassen sich ersetzen.
- 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
zählt primär `blockIndex`, der exakt mit dem Web-Reader übereinstimmt; `innerFraction`
ist nur ein Tiebreaker innerhalb desselben Blocks.

36
tui/TODO.md Normal file
View file

@ -0,0 +1,36 @@
# TODO diora-tui
## Offen
- [ ] **Pull Request `wt-tui` → `master` anlegen** (nur anlegen, nicht mergen). Forgejo verlangt dafür eine
Anmeldung; Claude hat keinen Token. Vergleich ist vorbereitet:
https://fg.creamfresh.xyz/mrwnslz/diora-web/compare/master...wt-tui
(Stand 2026-10-26: 14 Commits, mit dem damaligen `origin/master` konfliktfrei mergebar.)
**Titel:** `diora-tui: auf den aktuellen Stand (Lesestand, Bibliothek, Markierungen)`
**Beschreibung:**
```
Bringt die TUI (tui/) auf den Stand von Web und Apps. Nur tui/ ist betroffen, der Server bleibt unverändert.
- Liest EPUBs wie der Browser (html5lib, Rohdaten, UTF-8): Block-Index, Text und Zeichenpositionen stimmen bei 6 echten Büchern mit parseEpub des Web-Readers überein (tools/parity/).
- Lesestand "zuletzt gelesen gewinnt" nach docs/progress-sync.md (read_at/sent_at/device, Warteschlange, Angebote "Auf X weitergelesen" und "Weiteste Stelle"); Fortschritt als Textanteil statt 0.0.
- `diora-tui login` wie diora_client (Token auslesen, Schlüssel aus dem Passwort, geschützt gespeichert: DPAPI unter Windows), Bücher erst beim Öffnen geladen, Abgleich über GET /books/.
- Bibliothek wie im Web: Weiterlesen-Karte, Status- und Ordner-Chips, Suche, 6 Sortierungen inkl. Länge, Bücher/Zeitschriften.
- Markierungen, Notizen, Lesezeichen anzeigen, anlegen, löschen; die TUI legt ihre Änderungen je ID auf die aktuelle Server-Liste, bevor sie schreibt.
Geprüft: 75 Tests (pytest tests), tools/smoke_server.py gegen einen lokalen Server, tools/parity/web_anchors.cjs (der echte resolveAnchorRange des Webs findet 81 TUI-Anker exakt; 34 von 34 echten Web-Markierungen werden angezeigt).
Nicht enthalten: Podcasts/Radio, PDF-Markierungen, Serien-Stapel, Ausgaben, Cover.
```
- [ ] **Forgejo-Zugang für Claude** (optional): API-Token als Umgebungsvariable oder `tea` einrichten, dann
legt Claude künftige PRs selbst an.
- [ ] **TUI selbst ausprobieren** mit dem echten Konto: `pip install -e tui`, `diora-tui login`,
`diora-tui`. Bisher nur in automatisierten Terminal-Läufen und gegen einen lokalen Testserver
gesehen, nicht von Hand bedient.
## Bewusst nicht in der TUI
Podcasts und Radio, PDF-Markierungen, Serien-Stapel, Ausgaben (mehrere Fassungen eines Werks),
Cover, Ordner anlegen/zuweisen, Eingang aus der Cloud, Heft-Ansicht für Zeitschriften.

View file

@ -0,0 +1 @@
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""

View file

@ -0,0 +1,4 @@
from diora_tui.app import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,179 @@
"""The reader's note editor and the list of a book's bookmarks, highlights and notes."""
from __future__ import annotations
from dataclasses import dataclass
from rich.text import Text
from textual.binding import Binding
from textual.containers import Container
from textual.screen import ModalScreen
from textual.widgets import Input, Label, ListItem, ListView, Static
from . import annotations, blocks
class NoteScreen(ModalScreen[str]):
"""Type a note; Enter keeps it, Escape leaves it as it was (dismisses with None)."""
BINDINGS = [Binding("escape", "cancel", "Abbrechen")]
def __init__(self, title: str, text: str = "") -> None:
super().__init__()
self._title = title
self._text = text
def compose(self):
with Container(id="note-body"):
yield Label(self._title, id="note-title")
yield Input(value=self._text, placeholder="Notiz …", id="note-input")
yield Label("Enter: speichern Escape: abbrechen", id="note-hint")
def on_mount(self) -> None:
self.query_one(Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value.strip())
def action_cancel(self) -> None:
self.dismiss(None)
@dataclass
class Entry:
kind: str # 'bookmarks' | 'highlights'
item: dict
block: int # where it belongs, for the jump
percent: int
def entries(book: blocks.FlatBook, ann: annotations.BookAnnotations, mark_index=None) -> list[Entry]:
out: list[Entry] = []
for bm in ann.bookmarks:
block = bookmark_block(book, bm)
out.append(Entry("bookmarks", bm, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
for h in sorted(ann.highlights, key=annotations.sort_key):
a = h.get("anchor") or {}
block = a.get("startBlockIndex") if isinstance(a.get("startBlockIndex"), int) else None
if block is None and mark_index is not None: # an old anchor: where its quote is
found = mark_index._find_quote(a)
block = found[0] if found else None
block = min(max(block or 0, 0), len(book.blocks) - 1)
out.append(Entry("highlights", h, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
return out
def bookmark_block(book: blocks.FlatBook, bm: dict) -> int:
"""The block a bookmark points at: its chapter's first block; else its fraction of the text."""
src = bm.get("anchor") or ""
if src in book.chapter_srcs:
return book.chapter_start_block[book.chapter_srcs.index(src)]
target = float(bm.get("scrollFraction") or 0) * book.total_chars
return next((i for i, b in enumerate(book.blocks) if b.char_start + b.char_len >= target), 0)
class AnnotationsScreen(ModalScreen[int]):
"""Lesezeichen, then Markierungen and Notizen in the order of the book. Enter jumps (dismisses
with the block), d deletes, e edits a note; Escape closes."""
BINDINGS = [
Binding("escape,q,a", "close", "Schließen"),
Binding("d", "delete", "Löschen"),
Binding("e", "edit", "Notiz"),
]
def __init__(self, book: blocks.FlatBook, store: annotations.AnnotationStore, key: str, on_change, mark_index=None) -> None:
super().__init__()
self.book = book
self.store = store
self.key = key
self.mark_index = mark_index
self.on_change = on_change # called after a delete or an edit: marks and the queue follow
self.rows: list[Entry] = []
def compose(self):
with Container(id="ann-body"):
yield Label("Lesezeichen, Markierungen und Notizen", id="ann-title")
yield ListView(id="ann-list")
yield Static("", id="ann-detail")
yield Label("Enter: hinspringen d: löschen e: Notiz bearbeiten Escape: schließen", id="ann-hint")
def on_mount(self) -> None:
self._fill()
def _fill(self, keep: int = 0) -> None:
self.rows = entries(self.book, self.store.get(self.key), self.mark_index)
lv = self.query_one(ListView)
lv.clear()
if not self.rows:
lv.append(ListItem(Label("Noch nichts. b: Lesezeichen, v: Text markieren, im Lesen.")))
for r in self.rows:
lv.append(ListItem(Label(Text(self._label(r)))))
if self.rows:
lv.index = min(keep, len(self.rows) - 1)
lv.focus()
self._detail()
@staticmethod
def _label(r: Entry) -> str:
if r.kind == "bookmarks":
return f"★ {r.item.get('label') or 'Lesezeichen'} {r.percent} %"
h = r.item
quote = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
quote = quote if len(quote) <= 60 else quote[:59] + "…"
if h.get("type") == "note":
return f"✎ {h.get('note') or ''} {r.percent} %"
mark = {"yellow": "▌", "green": "▌", "blue": "▌", "red": "▌"}.get(h.get("color") or "yellow", "▌")
note = f" ✎ {h['note']}" if h.get("note") else ""
return f"{mark} „{quote}“{note} {r.percent} %"
def _detail(self) -> None:
lv = self.query_one(ListView)
detail = self.query_one("#ann-detail", Static)
if not self.rows or lv.index is None or lv.index >= len(self.rows):
detail.update("")
return
h = self.rows[lv.index].item
text = ""
if self.rows[lv.index].kind == "highlights":
text = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
if h.get("note"):
text += "\n✎ " + h["note"]
detail.update(Text(text))
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
self._detail()
def on_list_view_selected(self, event: ListView.Selected) -> None:
lv = self.query_one(ListView)
if self.rows and lv.index is not None and lv.index < len(self.rows):
self.dismiss(self.rows[lv.index].block)
def _current(self) -> Entry | None:
lv = self.query_one(ListView)
return self.rows[lv.index] if self.rows and lv.index is not None and lv.index < len(self.rows) else None
def action_close(self) -> None:
self.dismiss(None)
def action_delete(self) -> None:
r = self._current()
if r is None:
return
idx = self.query_one(ListView).index or 0
self.store.delete(self.key, r.kind, r.item["id"])
self.on_change()
self._fill(idx)
def action_edit(self) -> None:
r = self._current()
if r is None or r.kind != "highlights":
return
idx = self.query_one(ListView).index or 0
def edited(text: str | None) -> None:
if text is not None and self.store.set_note(self.key, r.item["id"], text):
self.on_change()
self._fill(idx)
self.app.push_screen(NoteScreen("Notiz", r.item.get("note") or ""), edited)

View file

@ -0,0 +1,258 @@
"""Highlights, notes and bookmarks of a book, as the web reader keeps them.
Format (static/js/app/72-highlights.js, 68-bookmarks.js): per book and kind one JSON list,
encrypted with the book key (AES-GCM, {ct, iv}), replaced as a whole on the server:
highlight {id, type: 'highlight'|'note', anchor, color, note, createdAt}
anchor {type: 'epub', chapterSrc, startBlockIndex, startChar, endBlockIndex, endChar, quote}
bookmark {id, label, anchor (the chapter's file), scrollFraction, createdAt} newest first
The server does no merging, so the apps write their whole list. The TUI keeps what it last
saw (`base`), and before it writes it merges its list with the server's current one by id
(three-way: what it added, deleted or changed since `base`, on top of what the server holds), so
a highlight another device made meanwhile is not wiped out. Its edits are queued (`dirty`) while
offline.
"""
from __future__ import annotations
import functools
import json
import os
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from . import crypto, paths
COLORS = {"yellow": "#f1c40f", "green": "#2ecc71", "blue": "#3498db", "red": "#e63946"}
COLOR_NAMES = {"yellow": "Gelb", "green": "Grün", "blue": "Blau", "red": "Rot"}
KINDS = ("highlights", "bookmarks")
MAX_BYTES = {"highlights": 700 * 1024, "bookmarks": 100 * 1024} # the server's size guards (raw bytes)
QUOTE_MAX = 200
_DIR = paths.data_dir() / "annotations"
def _now_iso() -> str:
"""Like the browser's toISOString(): 2026-10-26T12:00:00.123Z."""
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
# ---- the items
def new_highlight(anchor: dict, color: str = "yellow", note: str = "") -> dict:
return {"id": str(uuid.uuid4()), "type": "highlight", "anchor": anchor, "color": color, "note": note, "createdAt": _now_iso()}
def new_note(anchor: dict, text: str) -> dict:
"""A freeform note: no mark in the text, its anchor only says where it belongs."""
return {"id": str(uuid.uuid4()), "type": "note", "anchor": anchor, "color": None, "note": text, "createdAt": _now_iso()}
def new_bookmark(label: str, anchor: str, scroll_fraction: float) -> dict:
return {"id": str(uuid.uuid4()), "label": label, "anchor": anchor, "scrollFraction": scroll_fraction, "createdAt": _now_iso()}
def make_anchor(chapter_src: str, start_block: int, start_char: int, end_block: int, end_char: int, quote: str) -> dict:
return {"type": "epub", "chapterSrc": chapter_src, "startBlockIndex": start_block, "startChar": start_char,
"endBlockIndex": end_block, "endChar": end_char, "quote": quote[:QUOTE_MAX]}
def sort_key(h: dict) -> tuple:
a = h.get("anchor") or {}
return (a.get("startBlockIndex", 10**9), a.get("startChar", 0))
# ---- merge
def merge(base: list[dict] | None, ours: list[dict], theirs: list[dict], *, prepend_added: bool = False) -> list[dict]:
"""The server's list (`theirs`) with our changes since `base` laid on it, by id: what we
deleted is gone, what we added is in, what we changed is ours (also against their change).
`base` None: we never saw the server's list, so nothing counts as deleted."""
base_by = {x["id"]: x for x in (base or []) if isinstance(x, dict) and "id" in x}
ours_by = {x["id"]: x for x in ours if isinstance(x, dict) and "id" in x}
deleted = set(base_by) - set(ours_by) if base is not None else set()
added = [x for x in ours if x["id"] not in base_by]
changed = {i for i, x in ours_by.items() if i in base_by and x != base_by[i]}
out, seen = [], set()
for x in theirs:
i = x.get("id")
if i in deleted:
continue
out.append(ours_by[i] if i in changed else x)
seen.add(i)
new = [x for x in added if x["id"] not in seen]
# a change of ours to something they deleted stays: losing an edit is worse than a revived item
revived = [ours_by[i] for i in changed if i not in seen and ours_by[i] not in new]
return (new + revived + out) if prepend_added else (out + revived + new)
# ---- the blobs
def decode(key_b64: str, blob: dict) -> list[dict]:
"""The server's {ct, iv} (ct may be null: none yet) as a list."""
if not blob or not blob.get("ct"):
return []
data = json.loads(crypto.decrypt(key_b64, blob["iv"], blob["ct"]).decode("utf-8"))
return [x for x in data if isinstance(x, dict) and x.get("id")] if isinstance(data, list) else []
def encode(key_b64: str, items: list[dict]) -> dict:
iv, ct = crypto.encrypt(key_b64, json.dumps(items, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
return {"ct": ct, "iv": iv}
# ---- local state
@dataclass
class BookAnnotations:
highlights: list[dict] = field(default_factory=list)
bookmarks: list[dict] = field(default_factory=list)
base: dict[str, list[dict] | None] = field(default_factory=lambda: {"highlights": None, "bookmarks": None})
dirty: dict[str, bool] = field(default_factory=lambda: {"highlights": False, "bookmarks": False})
def items(self, kind: str) -> list[dict]:
return self.highlights if kind == "highlights" else self.bookmarks
def _locked(fn):
@functools.wraps(fn)
def wrapper(self, *a, **kw):
with self._lock:
return fn(self, *a, **kw)
return wrapper
class AnnotationStore:
"""One JSON file per book key; shared by the reader (edits) and the sync workers."""
def __init__(self, directory: Path | None = None) -> None:
self.dir = directory or _DIR
self._lock = threading.RLock()
self._books: dict[str, BookAnnotations] = {}
def _file(self, key: str) -> Path:
return self.dir / f"{key}.json"
def _load(self, key: str) -> BookAnnotations:
if key in self._books:
return self._books[key]
ann = BookAnnotations()
try:
raw = json.loads(self._file(key).read_text(encoding="utf-8"))
ann.highlights = list(raw.get("highlights") or [])
ann.bookmarks = list(raw.get("bookmarks") or [])
ann.base = {k: raw.get("base", {}).get(k) for k in KINDS}
ann.dirty = {k: bool(raw.get("dirty", {}).get(k)) for k in KINDS}
except (OSError, ValueError, AttributeError):
pass
self._books[key] = ann
return ann
def _save(self, key: str) -> None:
ann = self._books[key]
self.dir.mkdir(parents=True, exist_ok=True)
tmp = self._file(key).with_suffix(".tmp")
tmp.write_text(json.dumps({"highlights": ann.highlights, "bookmarks": ann.bookmarks, "base": ann.base,
"dirty": ann.dirty}, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, self._file(key))
@_locked
def get(self, key: str) -> BookAnnotations:
"""A copy (the lists are copied, not the items: don't change those)."""
a = self._load(key)
return BookAnnotations(list(a.highlights), list(a.bookmarks),
{k: (list(v) if v is not None else None) for k, v in a.base.items()}, dict(a.dirty))
@_locked
def dirty_keys(self) -> list[str]:
keys = set(self._books)
if self.dir.exists():
keys |= {p.stem for p in self.dir.glob("*.json")}
return sorted(k for k in keys if any(self._load(k).dirty.values()))
# ---- edits (they make the kind dirty: queued for the server)
@_locked
def add(self, key: str, kind: str, item: dict) -> None:
ann = self._load(key)
if kind == "bookmarks":
ann.bookmarks.insert(0, item) # newest first, like the web
else:
ann.highlights.append(item)
ann.dirty[kind] = True
self._save(key)
@_locked
def delete(self, key: str, kind: str, item_id: str) -> bool:
ann = self._load(key)
items = ann.items(kind)
kept = [x for x in items if x.get("id") != item_id]
if len(kept) == len(items):
return False
items[:] = kept
ann.dirty[kind] = True
self._save(key)
return True
@_locked
def set_note(self, key: str, item_id: str, text: str) -> bool:
ann = self._load(key)
for i, h in enumerate(ann.highlights):
if h.get("id") == item_id:
if h.get("note") == text:
return False
ann.highlights[i] = {**h, "note": text}
ann.dirty["highlights"] = True
self._save(key)
return True
return False
# ---- the server
@_locked
def take_remote(self, key: str, kind: str, theirs: list[dict]) -> None:
"""The server's current list: ours becomes it, with our queued edits laid on top."""
ann = self._load(key)
if ann.dirty[kind]:
merged = merge(ann.base[kind], ann.items(kind), theirs, prepend_added=kind == "bookmarks")
ann.items(kind)[:] = merged
else:
ann.items(kind)[:] = theirs
ann.base[kind] = list(theirs)
self._save(key)
@_locked
def for_push(self, key: str, kind: str, theirs: list[dict]) -> tuple[list[dict], list[dict]]:
"""(what to write over the server's current list `theirs`, our list as it is now)."""
ann = self._load(key)
ours = list(ann.items(kind))
return merge(ann.base[kind], ours, theirs, prepend_added=kind == "bookmarks"), ours
@_locked
def pushed(self, key: str, kind: str, written: list[dict], snapshot: list[dict]) -> None:
"""The server holds `written`, made from our list `snapshot`. Ours becomes `written`;
what was edited while it was on its way stays queued on top."""
ann = self._load(key)
current = ann.items(kind)
ann.base[kind] = list(written)
if current == snapshot:
current[:] = written
ann.dirty[kind] = False
else:
current[:] = merge(snapshot, current, written, prepend_added=kind == "bookmarks")
ann.dirty[kind] = True
self._save(key)
_default: AnnotationStore | None = None
def default() -> AnnotationStore:
global _default
if _default is None:
_default = AnnotationStore()
return _default

84
tui/diora_tui/api.py Normal file
View file

@ -0,0 +1,84 @@
"""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}", "User-Agent": USER_AGENT}
def _check(resp: requests.Response, what: str) -> None:
if resp.status_code == 401:
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()
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_prefs(server_url: str, token: str) -> dict:
"""The library's encrypted settings: {prefs_ct, prefs_iv}, both '' while there are none."""
resp = requests.get(f"{server_url}/books/prefs/", headers=_headers(token), timeout=30)
_check(resp, "Bibliothekseinstellungen")
return resp.json()
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=300)
_check(resp, f"Buch {book_id}")
return resp.json()
def fetch_annotations(server_url: str, token: str, book_id: int, kind: str) -> dict:
"""A book's encrypted highlights or bookmarks: {ct, iv} (both null while there are none)."""
resp = requests.get(f"{server_url}/books/{book_id}/{kind}/", headers=_headers(token), timeout=60)
_check(resp, f"Buch {book_id}")
return resp.json()
def post_annotations(server_url: str, token: str, book_id: int, kind: str, blob: dict) -> None:
"""Replace a book's highlights or bookmarks as a whole (the server doesn't merge)."""
resp = requests.post(f"{server_url}/books/{book_id}/{kind}/", headers=_headers(token), json=blob, timeout=60)
_check(resp, f"Buch {book_id}")
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()

103
tui/diora_tui/app.py Normal file
View file

@ -0,0 +1,103 @@
"""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
from pathlib import Path
from textual.app import App
from . import config as config_mod
from . import login as login_mod, remote
from .library_screen import LibraryScreen
DEFAULT_LIBRARY = Path.home() / "Books"
class DioraTuiApp(App):
CSS_PATH = "app.tcss"
TITLE = "diora-tui"
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
def on_mount(self) -> None:
self.push_screen(LibraryScreen(self.library_dir))
async def action_quit(self) -> None:
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 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 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"{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:
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest nur EPUB): {', '.join(result.skipped_pdf)}")
if result.failed:
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 — EPUB-Reader im Terminal, synct mit diora")
parser.add_argument(
"--library",
type=Path,
default=DEFAULT_LIBRARY,
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
)
subparsers = parser.add_subparsers(dest="command")
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)
return
DioraTuiApp(library_dir).run()
if __name__ == "__main__":
main()

161
tui/diora_tui/app.tcss Normal file
View file

@ -0,0 +1,161 @@
#library-list {
height: 1fr;
}
#lib-chips {
height: auto;
padding: 0 1;
}
#lib-search {
margin: 0 1;
}
#lib-card {
height: auto;
margin: 1 1 0 1;
padding: 0 2;
border: round $primary;
}
#lib-note {
height: auto;
padding: 0 2;
color: $text-muted;
}
ChoiceScreen {
align: center middle;
}
#choice-title {
width: 50;
background: $panel;
padding: 0 2;
text-style: bold;
}
#choice-list {
width: 50;
height: auto;
max-height: 70%;
border: round $primary;
}
StatusBar {
dock: bottom;
height: 1;
padding: 0 1;
background: $panel;
color: $text-muted;
text-align: right;
}
#reader-scroll {
height: 1fr;
padding: 1 2;
align-horizontal: center;
}
#reader-loading {
height: 1fr;
}
FootnoteScreen {
align: center middle;
}
#footnote-body {
width: 80%;
max-width: 100;
height: auto;
max-height: 80%;
background: $panel;
border: round $primary;
padding: 1 2;
}
.footnote-note {
margin-bottom: 1;
}
#footnote-hint {
dock: bottom;
width: 80%;
max-width: 100;
background: $panel;
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;
}
NoteScreen {
align: center middle;
}
#note-body {
width: 70;
height: auto;
background: $panel;
border: round $primary;
padding: 1 2;
}
#note-title {
text-style: bold;
margin-bottom: 1;
}
#note-hint {
color: $text-muted;
margin-top: 1;
}
AnnotationsScreen {
align: center middle;
}
#ann-body {
width: 90%;
max-width: 110;
height: 80%;
background: $panel;
border: round $primary;
padding: 1 2;
}
#ann-title {
text-style: bold;
}
#ann-list {
height: 1fr;
}
#ann-detail {
height: auto;
max-height: 6;
color: $text-muted;
margin-top: 1;
}
#ann-hint {
color: $text-muted;
margin-top: 1;
}

354
tui/diora_tui/blocks.py Normal file
View file

@ -0,0 +1,354 @@
"""Whole-book flat block extraction, matching static/js/app.js's EPUB_BLOCK_SELECTOR
and getPositionAnchor()/restoreFromAnchor() exactly in *structure* (blockIndex is
purely DOM order, no rendering needed) so position anchors are comparable across
the web reader and this TUI. See CLAUDE.md's `tui/` section for the full picture.
innerFraction on the web is defined via getBoundingClientRect() pixel geometry,
which has no TUI equivalent — a terminal has no font metrics/reflow the way a
browser does. We approximate it with row-based geometry from Rich's own text
wrapping (see reader.py), which is close enough because the server's "furthest
wins" comparison (_progress_is_further) only falls back to comparing fractions
when two anchors share the exact same block index — block index is the primary,
exactly-reproducible signal.
Footnote references are detected with the same heuristic as app.js's
_looksLikeFootnoteLink: wrapped in/wrapping a <sup>, a class name containing
note/footnote/fn, or an epub:type="noteref" attribute.
"""
from __future__ import annotations
import re
import warnings
from dataclasses import dataclass, field
from pathlib import Path
import ebooklib
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
from bs4.element import PreformattedString
from ebooklib import epub
# EPUB content documents are XHTML; treating them as HTML (matching app.js's
# `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638)
# is intentional, not a mistake — silence bs4's XML-vs-HTML nudge for it.
# html5lib builds the tree with the HTML5 algorithm, as the browser's DOMParser the web reader
# uses does: same nesting for broken markup (`<h1/>` opens a block) and the same text nodes
# (whitespace included), which the block indexes and the character offsets depend on. lxml's
# HTML parser (libxml2) trims indentation and closes elements differently.
_PARSER = "html5lib"
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
# blockquote, dt, dd, figcaption, div:not(:has(*))'
_BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt", "dd", "figcaption"}
# Matches app.js's sanitizeEpubHtml() strip list (script/style are also stripped
# via regex before DOMParser even runs there; decomposing here is equivalent).
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
# Matches app.js's _looksLikeFootnoteLink's class-name check.
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
# JavaScript's `\s`, not Python's (which also matches \x1c-\x1f and \x85 but not ): a
# block's text is cut and measured the way the web reader does.
_JS_SPACE_RE = re.compile("[ \t\n\v\f\r   - 

   ]+")
# Anchor format the server accepts (books/views.py:save_progress); anything else
# is silently discarded back to ''.
_ANCHOR_RE = re.compile(r"\d{1,7}:\d(\.\d{1,6})?")
@dataclass
class FootnoteRef:
marker: str # visible text of the reference link, e.g. "1"
offset: int # character offset into the block's text where the marker sits
target_id: str # fragment id to resolve against block ids
@dataclass
class Block:
text: str
tag: str
chapter_index: int
id: str | None = None
footnotes: list[FootnoteRef] = field(default_factory=list)
# Every id found anywhere in this block's subtree, not just on the block
# element itself — footnote *targets* are frequently an <a id="..."> or
# similar nested a level or two inside the actual containing paragraph
# (see showFootnotePopover's `.closest('.footnote') || .parentElement`
# walk-up in app.js), so a target id resolves to "the block containing
# it" rather than requiring the id to sit on the block tag itself.
ids: list[str] = field(default_factory=list)
# Where the block sits in the book's text, counted in characters the way the web reader
# counts them (44-books.js _bookCharIndex): char_start = offset of the block's first text
# node among all text nodes of the book, char_len = its textContent length (nested blocks
# included). Progress as a share of the text (position -> fraction) is computed from these.
char_start: int = 0
char_len: int = 0
@dataclass
class FlatBook:
id: str
title: str
author: str
path: Path
blocks: list[Block]
chapter_titles: list[str]
chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block
footnote_targets: dict[str, int] # fragment id -> block index of its content
total_chars: int = 1 # all text nodes of the book (never 0: it divides)
# chapter i's file as the web reader names it (data-epub-src: the OPF's folder + the manifest
# href): what highlights and bookmarks store as chapterSrc / anchor
chapter_srcs: list[str] = field(default_factory=list)
def _is_leaf_div(tag: Tag) -> bool:
return tag.name == "div" and tag.find(True) is None
def _looks_like_footnote_link(el: Tag, href: str) -> bool:
if "#" not in href:
return False
cls = " ".join(el.get("class") or []).lower()
if _FOOTNOTE_CLASS_RE.search(cls):
return True
epub_type = (el.get("epub:type") or "").lower()
if "noteref" in epub_type:
return True
return el.find_parent("sup") is not None or el.find("sup") is not None
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
for child in el.children:
if isinstance(child, PreformattedString):
continue # comments, CDATA, declarations: not text (textContent skips them)
if isinstance(child, NavigableString):
out.append(str(child))
elif isinstance(child, Tag):
if child.name == "a":
href = child.get("href") or ""
if _looks_like_footnote_link(child, href):
marker = _JS_SPACE_RE.sub(" ", child.get_text("")).strip(" ")
offset = len("".join(out))
target_id = href.split("#", 1)[1] if "#" in href else ""
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
out.append(marker)
continue
_walk_text(child, footnotes, out)
def _text_nodes(el: Tag):
for node in el.descendants:
if isinstance(node, NavigableString) and not isinstance(node, PreformattedString):
yield node
def _extract_blocks(html: bytes, chapter_index: int) -> tuple[list[Block], int]:
"""The chapter's blocks (char_start relative to the chapter) and its text length in chars."""
# Decoded as UTF-8 first, as the web reader reads the file (JSZip 'text'); handing html5lib
# bytes would have it guess an encoding.
soup = BeautifulSoup(html.decode("utf-8", "replace"), _PARSER)
for name in _STRIP_TAGS:
for el in soup.find_all(name):
el.decompose()
root = soup.body or soup
blocks: list[Block] = []
offset = 0
for node in root.descendants:
if isinstance(node, Tag):
if node.name in _BLOCK_TAGS or _is_leaf_div(node):
blocks.append(_make_block(node, chapter_index, offset))
elif isinstance(node, NavigableString) and not isinstance(node, PreformattedString):
offset += len(node)
return blocks, offset
def _make_block(el: Tag, chapter_index: int, char_start: int) -> Block:
footnotes: list[FootnoteRef] = []
parts: list[str] = []
_walk_text(el, footnotes, parts)
text = _JS_SPACE_RE.sub(" ", "".join(parts)).strip(" ")
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id]
return Block(
text=text,
tag=el.name,
chapter_index=chapter_index,
id=el.get("id"),
footnotes=footnotes,
ids=ids,
char_start=char_start,
char_len=sum(len(n) for n in _text_nodes(el)),
)
def _opf_dir(path: Path) -> str:
"""The folder of the book's OPF inside the zip, with a trailing slash ('' at the root)."""
import re
import zipfile
try:
with zipfile.ZipFile(path) as z:
m = re.search(r'full-path="([^"]+)"', z.read("META-INF/container.xml").decode("utf-8", "replace"))
except (OSError, KeyError, zipfile.BadZipFile):
return ""
return m.group(1).rsplit("/", 1)[0] + "/" if m and "/" in m.group(1) else ""
def load_flat_book(path: Path) -> FlatBook:
from .epub import _book_id # reuse the same path-hash id scheme
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
title_meta = raw.get_metadata("DC", "title")
title = title_meta[0][0] if title_meta else path.stem
author_meta = raw.get_metadata("DC", "creator")
author = author_meta[0][0] if author_meta else "Unbekannt"
blocks: list[Block] = []
chapter_titles: list[str] = []
chapter_start_block: list[int] = []
chapter_srcs: list[str] = []
opf_dir = _opf_dir(path)
total_chars = 0
# app.js's parseEpub() includes every spine itemref unconditionally — no
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
# targets are commonly parked in a linear="no" document. Skipping it here
# would both break footnote-target resolution and shift blockIndex
# numbering out of sync with the web reader for every block after it.
for idref, _linear in raw.spine:
item = raw.get_item_with_id(idref)
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
continue
chapter_index = len(chapter_titles)
chapter_blocks, chapter_chars = _extract_blocks(item.content, chapter_index) # raw bytes: EpubHtml.get_content() pretty-prints, adding whitespace
for b in chapter_blocks:
b.char_start += total_chars
total_chars += chapter_chars
chapter_start_block.append(len(blocks))
first_text = next((b.text for b in chapter_blocks if b.text), None)
chapter_titles.append((first_text or item.get_name())[:60])
chapter_srcs.append(opf_dir + item.file_name)
blocks.extend(chapter_blocks)
footnote_targets: dict[str, int] = {}
for idx, b in enumerate(blocks):
for tag_id in b.ids:
footnote_targets.setdefault(tag_id, idx)
if not b.char_len: # no text of its own: where the previous block ends (0 for the first), as the web reader
b.char_start = blocks[idx - 1].char_start + blocks[idx - 1].char_len if idx else 0
return FlatBook(
id=_book_id(path),
title=title,
author=author,
path=path,
blocks=blocks,
chapter_titles=chapter_titles,
chapter_start_block=chapter_start_block,
footnote_targets=footnote_targets,
total_chars=total_chars or 1,
chapter_srcs=chapter_srcs,
)
def fraction_for_anchor(book: FlatBook, block_index: int, inner_fraction: float) -> float:
"""The share of the book's text before the anchored place (0..1), as the web reader's
_fractionFromAnchor: what other clients show as progress."""
if not 0 <= block_index < len(book.blocks):
return 0.0
b = book.blocks[block_index]
return max(0.0, min(1.0, (b.char_start + inner_fraction * b.char_len) / book.total_chars))
def format_anchor(block_index: int, inner_fraction: float) -> str:
inner_fraction = max(0.0, min(1.0, inner_fraction))
return f"{block_index}:{inner_fraction:.6f}"
def parse_anchor(anchor: str) -> tuple[int, float] | None:
if not anchor or not _ANCHOR_RE.fullmatch(anchor):
return None
block_str, _, frac_str = anchor.partition(":")
return int(block_str), float(frac_str)
def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
"""Mirrors _progress_is_further / _cmpProgress (books/views.py, app.js)."""
new_parts = parse_anchor(new_anchor)
old_parts = parse_anchor(old_anchor)
if new_parts is None or old_parts is None:
return bool(new_anchor) and not old_anchor
nb, ni = new_parts
ob, oi = old_parts
return ni >= oi if nb == ob else nb >= ob
def book_pages(path: Path) -> int:
"""The book's length in printed pages from its text alone (bookPagesOfFile, 40-epub.js): every
spine document's body text by 1800 characters a page. Cheaper than load_flat_book: no blocks."""
from .library import pages_of_chars
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
chars = 0
for idref, _linear in raw.spine:
item = raw.get_item_with_id(idref)
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
continue
soup = BeautifulSoup(item.content.decode("utf-8", "replace"), _PARSER)
for name in ("script", "style"):
for el in soup.find_all(name):
el.decompose()
chars += sum(len(n) for n in _text_nodes(soup.body or soup))
return pages_of_chars(chars)
# ---- raw text of blocks (for highlights: the web stores character offsets into a block's raw text)
def raw_block_texts(path: Path, chapter_index: int) -> list[str]:
"""The raw text (all text nodes, whitespace as in the file) of every block of one spine
document, in block order: what the web's _charOffsetInBlock counts in. `chapter_index` is the
index load_flat_book gave the document."""
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
seen = -1
for idref, _linear in raw.spine:
item = raw.get_item_with_id(idref)
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
continue
seen += 1
if seen != chapter_index:
continue
soup = BeautifulSoup(item.content.decode("utf-8", "replace"), _PARSER)
for name in _STRIP_TAGS:
for el in soup.find_all(name):
el.decompose()
root = soup.body or soup
return ["".join(str(n) for n in _text_nodes(el)) for el in root.descendants
if isinstance(el, Tag) and (el.name in _BLOCK_TAGS or _is_leaf_div(el))]
return []
def collapse_map(raw: str) -> tuple[list[int], list[int], int]:
"""How a block's raw text becomes its displayed text (whitespace runs to one space, the ends
cut). Returns (norm_of_raw, raw_of_norm, length):
norm_of_raw[i] = offset in the displayed text of raw offset i (0..len(raw));
raw_of_norm[k] = raw offset of the character that is displayed character k."""
norm_of_raw: list[int] = []
raw_of_norm: list[int] = []
n = 0
started = False
prev_space = False
for i, ch in enumerate(raw):
norm_of_raw.append(n)
if _JS_SPACE_RE.fullmatch(ch):
if started and not prev_space:
raw_of_norm.append(i)
n += 1
prev_space = True
else:
started, prev_space = True, False
raw_of_norm.append(i)
n += 1
norm_of_raw.append(n)
length = n - 1 if prev_space and started else (n if started else 0)
if prev_space and started:
raw_of_norm.pop()
return [min(x, length) for x in norm_of_raw], raw_of_norm[:length], length

View file

@ -0,0 +1,63 @@
"""Line-API scroll view for the continuous reader — renders only the rows
actually visible on screen (via Widget.render_line), reading from the flat
Strip list diora_tui.layout.build_layout() precomputed once. This is what
keeps very large books responsive: nothing here scales with book size at
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
from .layout import BookLayout
class ContinuousBookView(ScrollView):
# We always wrap text to fit exactly, so a horizontal scrollbar should
# never be needed — and "scroll" (not "auto") for the vertical one keeps
# its gutter reserved from the very first (still-empty) layout pass, so
# the width we wrap text at later never has to guess whether a scrollbar
# will appear and steal columns out from under already-wrapped lines.
DEFAULT_CSS = """
ContinuousBookView {
overflow-x: hidden;
overflow-y: scroll;
}
"""
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
row = scroll_y + y
strips = self.book_layout.row_strips
width = self.scrollable_content_region.width
if row < 0 or row >= len(strips):
return Strip.blank(width, self.rich_style)
return strips[row].crop_extend(0, width, self.rich_style)

52
tui/diora_tui/cache.py Normal file
View file

@ -0,0 +1,52 @@
"""On-disk cache for the (FlatBook, BookLayout) pair a book open computes —
extraction + row-layout for very large books (tens of thousands of blocks)
can take several seconds each; reopening the same book at the same terminal
width should be near-instant instead of paying that cost again every time.
Not a correctness-critical cache: any miss (new book, different width, edited
file) just falls back to recomputing from scratch, so a stale/corrupt cache
entry is handled by overwriting it, never by crashing the reader.
"""
from __future__ import annotations
import hashlib
import pickle
from pathlib import Path
from . import paths
from .blocks import FlatBook
from .layout import BookLayout
_CACHE_VERSION = 3 # 2: blocks carry char_start/char_len; 3: chapter_srcs
_CACHE_DIR = paths.cache_dir() / "layout_cache"
def _cache_key(path: Path, width: int) -> str:
stat = path.stat()
raw = f"v{_CACHE_VERSION}|{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def load(path: Path, width: int) -> tuple[FlatBook, BookLayout] | None:
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
if not cache_file.exists():
return None
try:
with cache_file.open("rb") as f:
book, book_layout = pickle.load(f)
if not isinstance(book, FlatBook) or not isinstance(book_layout, BookLayout):
return None
return book, book_layout
except Exception:
return None
def save(path: Path, width: int, book: FlatBook, book_layout: BookLayout) -> None:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
try:
with cache_file.open("wb") as f:
pickle.dump((book, book_layout), f, protocol=pickle.HIGHEST_PROTOCOL)
except Exception:
pass # best-effort — a failed cache write shouldn't break reading

64
tui/diora_tui/config.py Normal file
View file

@ -0,0 +1,64 @@
"""The account the TUI syncs with: server, API token and the book key.
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 dataclass
from pathlib import Path
from . import paths, secretstore
_CONFIG_DIR = paths.config_dir()
_CONFIG_FILE = _CONFIG_DIR / "config.json"
@dataclass
class RemoteConfig:
server_url: str
api_token: str
enc_key_b64: str
username: str = ""
def load() -> RemoteConfig | None:
try:
data = json.loads(_CONFIG_FILE.read_text())
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)
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

59
tui/diora_tui/crypto.py Normal file
View file

@ -0,0 +1,59 @@
"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes
(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled
as raw bytes) — diora's books are end-to-end encrypted client-side, so the
server (and this module) only ever sees ciphertext plus the key the user
supplies out-of-band.
"""
from __future__ import annotations
import base64
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
_PBKDF2_ITERATIONS = 200_000
_KEY_LENGTH_BYTES = 32
class DecryptError(Exception):
"""Ciphertext could not be decrypted with the given key (wrong key, or corrupt data)."""
def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
try:
key = base64.b64decode(key_b64)
iv = bytes.fromhex(iv_hex)
ct = base64.b64decode(ciphertext_b64)
return AESGCM(key).decrypt(iv, ct, None)
except (InvalidTag, ValueError) as e:
raise DecryptError(str(e)) from e
def encrypt(key_b64: str, plaintext: bytes) -> tuple[str, str]:
"""(iv hex, ciphertext base64): what the web client's encryptBytes sends as iv/ct."""
iv = os.urandom(12)
ct = AESGCM(base64.b64decode(key_b64)).encrypt(iv, plaintext, None)
return iv.hex(), base64.b64encode(ct).decode()
def derive_key_b64(username: str, password: str) -> str:
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only
yields the key that actually decrypts a user's books if that account's
key was ever set up via diora's "unlock with password" flow — a browser
that only ever auto-generated a random key (the default) has a key this
can't reproduce.
"""
salt = f"diora:{username}".encode("utf-8")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=_KEY_LENGTH_BYTES,
salt=salt,
iterations=_PBKDF2_ITERATIONS,
)
raw = kdf.derive(password.encode("utf-8"))
return base64.b64encode(raw).decode()

35
tui/diora_tui/epub.py Normal file
View file

@ -0,0 +1,35 @@
"""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:
"""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]
def scan_library(library_dir: Path) -> list[Path]:
if not library_dir.exists():
return []
return sorted(library_dir.rglob("*.epub"))

108
tui/diora_tui/layout.py Normal file
View file

@ -0,0 +1,108 @@
"""Row-based layout for the continuous reader view — the TUI's terminal-grid
analogue of the browser's pixel-based getBoundingClientRect() geometry (see
blocks.py's module docstring for why blockIndex is exact but innerFraction is
only an approximation here).
Each block is wrapped independently at a known width, once, into a flat list
of pre-rendered Strip objects (one per terminal row) that book_view.py's
Line-API widget indexes directly in render_line() — this is what makes very
large books (tens of thousands of blocks) open in seconds rather than
minutes: Textual only ever renders the rows actually on screen, instead of
laying out the whole book up front the way a single giant Static would.
"""
from __future__ import annotations
from dataclasses import dataclass
from rich.console import Console
from rich.text import Text
from textual.strip import Strip
_SEPARATOR_ROWS = 1 # one blank row between blocks, matching the old "\n\n".join style
@dataclass
class BookLayout:
starts: list[int] # row where block i begins
heights: list[int] # rendered row count for block i
row_strips: list[Strip] # one Strip per absolute row, len == total_rows
total_rows: int
width: int
def render_block(text: str, width: int, console: Console, spans=()) -> list[Strip]:
"""One block wrapped to `width`, a Strip per row. `spans`: (start, end, style) over the text
(highlights, a selection); styling never changes how the text wraps."""
t = Text(text)
for start, end, style in spans:
t.stylize(style, start, end)
lines = list(t.wrap(console, width)) if text else []
if not lines:
lines = [Text("")]
return [Strip(line.render(console), None).adjust_cell_length(width) for line in lines]
def restyle_block(book_layout: "BookLayout", index: int, text: str, console: Console, spans=()) -> bool:
"""Draw block `index` again with `spans`, in place (its rows stay where they are)."""
strips = render_block(text, book_layout.width, console, spans)
if len(strips) != book_layout.heights[index]:
return False
start = book_layout.starts[index]
book_layout.row_strips[start:start + len(strips)] = strips
return True
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
width = max(1, width)
starts: list[int] = []
heights: list[int] = []
row_strips: list[Strip] = []
blank = Strip.blank(width)
row = 0
for text in block_texts:
starts.append(row)
strips = render_block(text, width, console)
heights.append(len(strips))
row_strips.extend(strips)
row_strips.append(blank)
row += len(strips) + _SEPARATOR_ROWS
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
def get_position_anchor(layout: BookLayout, scroll_y: int) -> tuple[int, float]:
"""Mirrors app.js's getPositionAnchor(): the last block whose top row is at
or above scroll_y, or the first block if none has scrolled that far yet."""
n = len(layout.heights)
if n == 0:
return 0, 0.0
best_index = 0
found = False
for i in range(n):
if layout.heights[i] < 1:
continue
if layout.starts[i] > scroll_y:
break
best_index = i
found = True
if not found:
best_index = next((i for i in range(n) if layout.heights[i] >= 1), 0)
top = layout.starts[best_index]
height = max(1, layout.heights[best_index])
inner_fraction = max(0.0, min(1.0, (scroll_y - top) / height))
return best_index, inner_fraction
def scroll_y_for_anchor(layout: BookLayout, block_index: int, inner_fraction: float) -> int:
"""Mirrors app.js's restoreFromAnchor()."""
n = len(layout.heights)
if n == 0:
return 0
idx = max(0, min(block_index, n - 1))
top = layout.starts[idx]
height = layout.heights[idx]
return top + round(inner_fraction * height)

210
tui/diora_tui/library.py Normal file
View file

@ -0,0 +1,210 @@
"""The library list: what the web library shows (docs/library.md, static/js/app/53-library.js)
without drawing it — rows, the status and folder chips, search, sort, the "Weiterlesen" card.
Pure functions on plain rows, so the screen stays thin and the rules can be tested. Where the
web has a rule, this follows it (names in the comments are the web's).
"""
from __future__ import annotations
import re
import time
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
SORTS = [
("recent", "Zuletzt"),
("title", "Titel"),
("author", "Autor"),
("added", "Neu hinzugefügt"),
("short", "Kürzeste zuerst"),
("long", "Längste zuerst"),
]
STATUSES = [("all", "Alle"), ("started", "Angefangen"), ("unread", "Ungelesen"), ("read", "Gelesen")]
_MAGAZINE_FOLDER = re.compile(r"^(zeitschriften|magazine|magazines)$", re.I) # _MAGAZINE_FOLDER
CHARS_PER_BOOK_PAGE = 1800 # the reader's own estimate (44-books.js)
RECENT_POOL = 7 # _recentlyReadBooks: the 7 read last
RECENT_ROW = 6 # _libRecentBooks: the others shown beside the large one
@dataclass
class Row:
key: str
title: str
author: str = ""
path: Path | None = None # the file, if it is here
server_id: int | None = None
is_read: bool = False
uploaded_at: str = ""
folders: list[str] = field(default_factory=list)
pages: int = 0 # from the encrypted meta, else worked out here
magazine: bool | None = None # meta.magazine; None: decided by the folders
read_at: int = 0 # ms, server clock; 0: never opened
fraction: float = 0.0
device: str = ""
def is_magazine(r: Row) -> bool:
return r.magazine is True or (r.magazine is not False and any(_MAGAZINE_FOLDER.match(f.strip()) for f in r.folders))
def folders_of_meta(meta: dict) -> list[str]:
"""_foldersOfMeta: `folders`, else the older single `folder`."""
f = meta.get("folders")
if isinstance(f, list):
return [x for x in f if isinstance(x, str) and x.strip()]
return [meta["folder"]] if meta.get("folder") else []
def pages_of_chars(chars: int) -> int:
"""An EPUB's length in printed pages from its text (bookPagesOfFile): 1800 characters a page."""
return max(1, int(chars / CHARS_PER_BOOK_PAGE + 0.5)) if chars else 0
def build_rows(files: list[Path], remote: dict[str, dict], states: dict, local_pages: dict[str, int], key_of) -> list[Row]:
"""One row per book: each file in the folder, and each server book not downloaded yet.
`states` maps a key to its reading state (progress.BookState), `key_of(path)` is the book key."""
rows: dict[str, Row] = {}
def fill(row: Row, info: dict) -> Row:
row.title = info.get("title") or row.title
row.author = info.get("author") or ""
row.server_id = info.get("server_id")
row.is_read = bool(info.get("is_read"))
row.uploaded_at = info.get("uploaded_at") or ""
row.folders = list(info.get("folders") or [])
row.pages = int(info.get("pages") or 0)
row.magazine = info.get("magazine")
return row
for path in files:
key = key_of(path)
rows[key] = fill(Row(key, path.stem, path=path), remote.get(key) or {})
for key, info in remote.items():
if key not in rows:
rows[key] = fill(Row(key, "?"), info)
for key, r in rows.items():
st = states.get(key)
if st is not None:
r.read_at = st.read_at or 0
r.fraction = st.fraction
r.device = st.device
if not r.pages:
r.pages = int(local_pages.get(key) or 0)
return list(rows.values())
# ---- the filters
def started(r: Row) -> bool:
return not r.is_read and r.fraction > 0
def unread(r: Row) -> bool:
return not r.is_read and not r.fraction > 0
def norm(s: str) -> str:
"""_libNorm: without accents, lower case."""
return "".join(c for c in unicodedata.normalize("NFD", s or "") if not unicodedata.combining(c)).lower()
def collate(s: str) -> list:
"""German-ish order with numbers by value (localeCompare('de', {numeric: true})): accents
don't count at first, the plain text breaks ties."""
parts = re.split(r"(\d+)", norm(s))
return [(0, int(p)) if p.isdigit() else (1, p) for p in parts if p != ""] + [(2, s or "")]
def section(rows: list[Row], magazines: bool) -> list[Row]:
"""_bookSectionFilter: Bücher or Zeitschriften."""
return [r for r in rows if is_magazine(r) == magazines]
def folder_list(rows: list[Row], order: list[str] | None) -> list[tuple[str, int]]:
"""_libFolderList: every folder with its book count, in the user's order, the rest by name."""
counts: dict[str, int] = {}
for r in rows:
for f in r.folders:
counts[f] = counts.get(f, 0) + 1
order = order or []
return sorted(((n, c) for n, c in counts.items()),
key=lambda nc: (order.index(nc[0]) if nc[0] in order else 10**6, collate(nc[0])))
def status_counts(rows: list[Row]) -> dict[str, int]:
return {"all": len(rows), "started": sum(map(started, rows)), "unread": sum(map(unread, rows)),
"read": sum(r.is_read for r in rows)}
def shown(rows: list[Row], *, q: str = "", status: str = "all", folder: str | None = None, sort: str = "recent") -> list[Row]:
"""_libShown. `folder`: None all, '' without a folder, else a folder's name."""
needle = norm(q).strip()
def keep(r: Row) -> bool:
if status == "started" and not started(r):
return False
if status == "unread" and not unread(r):
return False
if status == "read" and not r.is_read:
return False
if folder is not None and (bool(r.folders) if folder == "" else folder not in r.folders):
return False
return not needle or needle in norm(r.title + " " + r.author)
by_title = lambda r: collate(r.title) # noqa: E731
out = [r for r in rows if keep(r)]
if sort == "title":
out.sort(key=by_title)
elif sort == "author":
out.sort(key=lambda r: (collate(r.author or "~"), by_title(r)))
elif sort == "added":
out.sort(key=lambda r: r.uploaded_at, reverse=True)
elif sort == "short": # a book whose length isn't known comes last
out.sort(key=lambda r: (r.pages or float("inf"), by_title(r)))
elif sort == "long":
out.sort(key=lambda r: (-(r.pages or 0), by_title(r)))
else: # recent: read last first, then newest upload
out.sort(key=lambda r: (r.read_at, r.uploaded_at), reverse=True)
return out
def continue_rows(rows: list[Row]) -> tuple[Row | None, list[Row]]:
"""The "Weiterlesen" card: of the books read last, the first not finished, and the others
of those read lately beside it (_libContinueBook, _libRecentBooks)."""
pool = sorted((r for r in rows if r.read_at), key=lambda r: r.read_at, reverse=True)[:RECENT_POOL]
going = next((r for r in pool if not r.is_read), None)
if going is None:
return None, []
return going, [r for r in pool if r is not going][:RECENT_ROW]
def ago(ms: int, now_ms: int | None = None) -> str:
"""_libAgo: "vor 5 Minuten", "gestern", "am 4. Okt."."""
if not ms:
return ""
now = now_ms if now_ms is not None else int(time.time() * 1000)
minutes = round((now - ms) / 60000)
if minutes < 2:
return "gerade eben"
if minutes < 60:
return f"vor {minutes} Minuten"
day = lambda t: time.strftime("%Y-%m-%d", time.localtime(t / 1000)) # noqa: E731
hours = round(minutes / 60)
if day(ms) == day(now):
return "vor einer Stunde" if hours == 1 else f"vor {hours} Stunden"
if day(ms) == day(now - 86400000):
return "gestern"
days = round((now - ms) / 86400000)
if days < 7:
return f"vor {days} Tagen"
months = ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."]
t = time.localtime(ms / 1000)
return f"am {t.tm_mday}. {months[t.tm_mon - 1]}"
def percent(r: Row) -> int:
return 100 if r.is_read else int(r.fraction * 100 + 0.5) # Math.round

View file

@ -0,0 +1,75 @@
"""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 . import paths
_DATA_DIR = paths.data_dir()
_META_FILE = _DATA_DIR / "library.json"
def _load_all() -> dict[str, dict]:
try:
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)
tmp = _META_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=1))
os.replace(tmp, _META_FILE)
def is_read(book_key: str) -> bool:
entry = _load_all().get(book_key)
return bool(entry and entry.get("is_read"))
def get(book_key: str) -> dict | None:
return _load_all().get(book_key)
def get_extra(name: str, default=None):
"""A setting kept beside the books (stored under '_<name>'): the folder order, the sort."""
entry = _load_all().get("_" + name)
return entry["value"] if isinstance(entry, dict) and "value" in entry else default
def set_extra(name: str, value) -> None:
data = _load_all()
data["_" + name] = {"value": value}
_save_all(data)
def set_local_pages(book_key: str, pages: int) -> None:
"""A book's length worked out from the file here (the server's meta may not have it)."""
data = _load_all()
entry = data.setdefault("_pages", {"value": {}})
entry.setdefault("value", {})[book_key] = int(pages)
_save_all(data)
def local_pages() -> dict[str, int]:
return dict(get_extra("pages", {}) or {})
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)

View file

@ -0,0 +1,388 @@
"""The library screen: the web library (docs/library.md) in a terminal — "Weiterlesen" card,
status and folder chips, search, sort, Bücher/Zeitschriften. The rules are in library.py.
Keys: 1–4 status (Alle/Angefangen/Ungelesen/Gelesen), o folder, / search, s sort, m Bücher or
Zeitschriften, c continue the book read last, Enter open. The chips and the card also answer
the mouse.
"""
from __future__ import annotations
from pathlib import Path
from rich.text import Text
from textual import work
from textual.binding import Binding
from textual.screen import ModalScreen, Screen
from textual.markup import escape
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, OptionList, Static
from . import blocks, config as config_mod, epub, library, library_meta, progress, remote
from .reader_screen import ReaderScreen
from .statusbar import StatusBar
PAGES_NOTE_EVERY = 4 # seconds between redraws while lengths are worked out
class ChoiceScreen(ModalScreen[int]):
"""Pick one of a few options; dismisses with its index, or None on Escape."""
BINDINGS = [Binding("escape,q", "cancel", "Abbrechen")]
def __init__(self, title: str, options: list[str], current: int = 0) -> None:
super().__init__()
self._title = title
self._options = options
self._current = current
def compose(self):
yield Label(self._title, id="choice-title")
yield OptionList(*[Text(o) for o in self._options], id="choice-list")
def on_mount(self) -> None:
ol = self.query_one(OptionList)
ol.highlighted = max(0, min(self._current, len(self._options) - 1))
ol.focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
self.dismiss(event.option_index)
def action_cancel(self) -> None:
self.dismiss(None)
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
Binding("c", "continue_reading", "Weiterlesen"),
Binding("/", "search", "Suchen"),
Binding("s", "pick_sort", "Sortierung"),
Binding("o", "pick_folder", "Ordner"),
Binding("m", "toggle_section", "Bücher/Zeitschriften"),
Binding("1", "set_status('all')", "Alle", show=False),
Binding("2", "set_status('started')", "Angefangen", show=False),
Binding("3", "set_status('unread')", "Ungelesen", show=False),
Binding("4", "set_status('read')", "Gelesen", show=False),
Binding("escape", "reset", "Zurücksetzen", show=False),
Binding("q", "quit", "Beenden"),
]
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.rows: list[library.Row] = [] # every book
self.section_rows: list[library.Row] = [] # of the section shown (Bücher or Zeitschriften)
self.shown: list[library.Row] = [] # the list on screen
self.going: library.Row | None = None # the card's book
self.folder_names: list[str] = []
self.q = ""
self.status = "all"
self.folder: str | None = None # None: all, '': without folder, else a name
self.sort = library_meta.get_extra("sort", "recent")
if self.sort not in dict(library.SORTS):
self.sort = "recent"
self.magazines = False
self._lengths_running = False
self._lengths_tried: set[str] = set()
self._lengths_note = ""
# ---- layout
def compose(self):
yield Header()
yield Static("", id="lib-chips")
yield Input(placeholder="Titel, Autor …", id="lib-search")
yield Static("", id="lib-card")
yield Static("", id="lib-note")
yield ListView(id="library-list")
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
self.query_one("#lib-search").display = False
self.sub_title = str(self.library_dir)
self._refresh_list()
cfg = config_mod.load()
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
# ---- sync
@work(thread=True)
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
try:
result = remote.sync_library(self.library_dir, cfg)
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:
self._refresh_list()
if result.new_titles:
self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3)
# ---- the list
def _gather(self) -> list[library.Row]:
store = progress.default()
remote_books = library_meta.remote_entries()
files = epub.scan_library(self.library_dir)
keys = {epub._book_id(p) for p in files} | set(remote_books)
states = {k: st for k in keys if (st := store.get(k)) is not None}
return library.build_rows(files, remote_books, states, library_meta.local_pages(), epub._book_id)
def _refresh_list(self) -> None:
self.rows = self._gather()
self.section_rows = library.section(self.rows, self.magazines)
order = library_meta.get_extra("folder_order", [])
self.folder_names = [n for n, _ in library.folder_list(self.section_rows, order)]
if self.folder not in (None, "") and self.folder not in self.folder_names:
self.folder = None
self._draw()
self._fill_lengths()
def _draw(self) -> None:
self._draw_chips()
list_view = self.query_one("#library-list", ListView)
selected = self.shown[list_view.index].key if self.shown and list_view.index is not None and list_view.index < len(self.shown) else None
shown = library.shown(self.section_rows, q=self.q, status=self.status, folder=self.folder, sort=self.sort)
# The card: only on the plain list, and its book is not repeated below (once, on top).
plain = self.status == "all" and self.folder is None and not self.q
self.going, recent = library.continue_rows(self.section_rows) if plain else (None, [])
self._draw_card(recent)
if self.going is not None and self.going in shown:
shown.remove(self.going)
self.shown = shown
list_view.clear()
if not self.rows:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
elif not self.section_rows:
list_view.append(ListItem(Label("Noch keine Zeitschriften — m wechselt zu den Büchern." if self.magazines
else "Alle Bücher stehen bei den Zeitschriften — m wechselt dorthin.")))
elif not self.shown and self.going is None:
list_view.append(ListItem(Label("Nichts gefunden.")))
by_length = self.sort in ("short", "long")
for r in self.shown:
list_view.append(ListItem(Label(Text(self._label(r, by_length)))))
if self.shown:
idx = next((i for i, r in enumerate(self.shown) if r.key == selected), 0)
list_view.index = idx
if not self.query_one("#lib-search", Input).display: # not while a search is open
list_view.focus()
self._draw_note()
@staticmethod
def _label(r: library.Row, by_length: bool) -> str:
mark = "✓ " if r.is_read else ""
cloud = "☁ " if r.path is None else ""
author = f" — {r.author}" if r.author else ""
pct = f" {library.percent(r)} %" if (r.fraction > 0 and not r.is_read) else ""
length = f" · {r.pages} S." if by_length and r.pages else ""
return f"{mark}{cloud}{r.title}{author}{pct}{length}"
def _draw_chips(self) -> None:
counts = library.status_counts(self.section_rows)
parts = []
for i, (sid, label) in enumerate(library.STATUSES):
text = label if sid == "all" else f"{label} {counts[sid]}"
parts.append(self._chip(f"{i + 1} {text}", f"screen.set_status('{sid}')", self.status == sid))
folders = library.folder_list(self.section_rows, library_meta.get_extra("folder_order", []))
if folders:
parts.append("[dim]│[/]")
parts.append(self._chip("Alle Ordner", "screen.set_folder('all')", self.folder is None))
for i, (name, count) in enumerate(folders):
parts.append(self._chip(f"{name} {count}", f"screen.set_folder('{i}')", self.folder == name))
none = sum(1 for r in self.section_rows if not r.folders)
if none:
parts.append(self._chip(f"Ohne Ordner {none}", "screen.set_folder('none')", self.folder == ""))
section = "Zeitschriften" if self.magazines else "Bücher"
sort = dict(library.SORTS)[self.sort]
head = f"[b]{section}[/] [dim]Sortierung:[/] {escape(sort)} "
self.query_one("#lib-chips", Static).update(head + " ".join(parts))
@staticmethod
def _chip(label: str, action: str, on: bool) -> str:
label = escape(label)
return f"[@click={action}]" + (f"[b reverse] {label} [/]" if on else f" [underline]{label}[/] ") + "[/]"
def _draw_card(self, recent: list[library.Row]) -> None:
card = self.query_one("#lib-card", Static)
b = self.going
if b is None:
card.display = False
return
card.display = True
ago = library.ago(b.read_at)
pct = library.percent(b)
bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
pages = f" · Seite {max(1, int(b.pages * b.fraction + 0.5))} von {b.pages}" if b.pages else ""
where = f" · zuletzt auf {escape(b.device)}" if b.device and b.device != progress.DEVICE_NAME else ""
lines = [
f"[@click=screen.continue_reading][dim]Weiterlesen{' · ' + ago if ago else ''}{where}[/]",
f"[b]{escape(b.title)}[/]",
escape(b.author) if b.author else "",
f"{bar} {pct} %{pages} [dim](c)[/][/]",
]
if recent:
lines.append("[dim]Zuletzt gelesen:[/] " + " · ".join(
f"{escape(r.title)}{' ✓' if r.is_read else ''}" for r in recent))
card.update("\n".join(l for l in lines if l))
def _draw_note(self) -> None:
note = self.query_one("#lib-note", Static)
text = self._lengths_note
if not text and self.sort in ("short", "long"):
unknown = sum(1 for r in self.section_rows if not r.pages)
if unknown:
text = f"{unknown} Bücher ohne bekannte Länge (noch nicht geladen) stehen am Ende."
note.update(escape(text))
note.display = bool(text)
# ---- lengths (the web works them out once a length sort is chosen: the file is needed)
def _fill_lengths(self) -> None:
if self._lengths_running or self.sort not in ("short", "long"):
return
todo = [r for r in self.rows if not r.pages and r.path is not None and r.key not in self._lengths_tried]
if not todo:
return
self._lengths_running = True
self._work_lengths([(r.key, r.path) for r in todo])
@work(thread=True, exclusive=True, group="lengths")
def _work_lengths(self, todo: list[tuple[str, Path]]) -> None:
import time
shown_at = time.monotonic()
try:
for done, (key, path) in enumerate(todo, 1):
self._lengths_tried.add(key)
try:
pages = blocks.book_pages(path)
except Exception:
pages = 0 # stays at the end
if pages:
library_meta.set_local_pages(key, pages)
self._lengths_note = f"Länge wird ermittelt … {done} von {len(todo)} Büchern"
if time.monotonic() - shown_at > PAGES_NOTE_EVERY:
shown_at = time.monotonic()
self.app.call_from_thread(self._refresh_list_keep_note)
finally:
self._lengths_note = ""
self._lengths_running = False
self.app.call_from_thread(self._refresh_list)
def _refresh_list_keep_note(self) -> None:
self.rows = self._gather()
self.section_rows = library.section(self.rows, self.magazines)
self._draw()
# ---- actions
def action_set_status(self, status: str) -> None:
self.status = "all" if self.status == status else status
self._draw()
def action_set_folder(self, which: str) -> None:
"""`which`: 'all', 'none' (without a folder) or the index of a folder chip."""
if which == "all":
self.folder = None
elif which == "none":
self.folder = ""
elif which.isdigit() and int(which) < len(self.folder_names):
name = self.folder_names[int(which)]
self.folder = None if self.folder == name else name
self._draw()
def action_pick_folder(self) -> None:
values: list[str | None] = [None] + self.folder_names + ([""] if any(not r.folders for r in self.section_rows) else [])
if len(values) == 1:
self.notify("Keine Ordner angelegt.", timeout=2)
return
names = ["Alle Ordner" if v is None else "Ohne Ordner" if v == "" else v for v in values]
def picked(i: int | None) -> None:
if i is not None:
self.folder = values[i]
self._draw()
self.app.push_screen(ChoiceScreen("Ordner", names, values.index(self.folder)), picked)
def action_pick_sort(self) -> None:
ids = [s for s, _ in library.SORTS]
def picked(i: int | None) -> None:
if i is None:
return
self.sort = ids[i]
library_meta.set_extra("sort", self.sort)
self._draw()
self._fill_lengths()
self.app.push_screen(ChoiceScreen("Sortieren nach", [n for _, n in library.SORTS], ids.index(self.sort)), picked)
def action_toggle_section(self) -> None:
self.magazines = not self.magazines
self.folder = None
self._refresh_list()
def action_search(self) -> None:
box = self.query_one("#lib-search", Input)
box.display = True
box.focus()
def on_input_changed(self, event: Input.Changed) -> None:
self.q = event.value
self._draw()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.query_one("#library-list", ListView).focus()
def action_reset(self) -> None:
box = self.query_one("#lib-search", Input)
if box.display or self.q or self.status != "all" or self.folder is not None:
box.value = ""
box.display = False
self.q, self.status, self.folder = "", "all", None
self._draw()
def action_continue_reading(self) -> None:
if self.going is not None:
self._open(self.going)
else:
self.notify("Nichts zum Weiterlesen — ein Buch öffnen.", timeout=3)
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.shown or list_view.index is None or list_view.index >= len(self.shown):
return
self._open(self.shown[list_view.index])
def _open(self, row: library.Row) -> None:
if row.path is not None:
self.app.push_screen(ReaderScreen(row.path))
return
cfg = config_mod.load()
if cfg is None or row.server_id is None:
self.notify("Nicht angemeldet — `diora-tui login`.", severity="error")
return
self.notify(f"Lade „{row.title}“ …", timeout=4)
self._download_and_open(cfg, row)
@work(thread=True, exclusive=True, group="download")
def _download_and_open(self, cfg: config_mod.RemoteConfig, row: library.Row) -> None:
try:
path = remote.download_book(self.library_dir, cfg, row.server_id, row.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()

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

@ -0,0 +1,109 @@
"""`diora-tui login`: sign in once like the apps do and keep what a sync needs.
The password goes through the web login form (CSRF + session) only to read the account's
existing API token off the settings page, and to derive the book key (PBKDF2, as the web
client's "unlock with password"). The password is not stored; the session is logged out again.
A new token is never created here: that would sign out the apps using the current one.
"""
from __future__ import annotations
import base64
import getpass
import html
import re
import requests
from . import api, crypto
from .config import RemoteConfig
DEFAULT_SERVER = "https://diora.creamfresh.xyz"
_CSRF = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"')
_TOKEN = re.compile(r'<code style="user-select:all[^"]*">\s*([A-Za-z0-9_-]+)\s*</code>')
_USERNAME = re.compile(r'<meta name="username" content="([^"]*)">')
class LoginError(Exception):
pass
def web_login(server: str, username: str, password: str) -> tuple[str, str]:
"""(API token, canonical user name) via the web login."""
s = requests.Session()
s.headers["User-Agent"] = api.USER_AGENT
def get(path):
return s.get(server + path, headers={"Referer": server + path}, allow_redirects=False, timeout=30)
def post(path, form):
return s.post(server + path, data=form, headers={"Referer": server + path, "Origin": server},
allow_redirects=False, timeout=30)
try:
page = get("/accounts/login/")
m = _CSRF.search(page.text)
if page.status_code != 200 or not m:
raise LoginError(f"Login-Seite nicht lesbar ({page.status_code}).")
r = post("/accounts/login/", {"csrfmiddlewaretoken": m.group(1), "username": username, "password": password})
if r.status_code == 200:
raise LoginError("Benutzername oder Passwort falsch.")
if r.status_code != 302:
raise LoginError(f"Anmeldung fehlgeschlagen ({r.status_code}).")
try:
t = _TOKEN.search(get("/accounts/settings/").text)
if not t:
raise LoginError("Kein API-Token gefunden. Melde dich einmal in einer diora-App an, dann noch mal.")
name = _USERNAME.search(get("/").text)
return t.group(1), html.unescape(name.group(1)) if name else username
finally:
c = _CSRF.search(get("/").text)
if c:
post("/accounts/logout/", {"csrfmiddlewaretoken": c.group(1)})
except requests.RequestException as e:
raise LoginError(f"Verbindung zu {server} fehlgeschlagen: {e}") from e
def _books_open(server: str, token: str, key_b64: str) -> tuple[int, int]:
"""(books, books the key opens)."""
books, _, _ = api.fetch_books(server, token)
ok = 0
for b in books:
try:
crypto.decrypt(key_b64, b["meta_iv"], b["meta_ct"])
ok += 1
except crypto.DecryptError:
pass
return len(books), ok
def login(server: str | None = None, *, paste_key: bool = False) -> RemoteConfig:
server = (server or DEFAULT_SERVER).rstrip("/")
username = input("diora-Benutzername: ").strip()
password = getpass.getpass("Passwort (wird nicht gespeichert, nicht angezeigt): ")
if not username or not password:
raise LoginError("Abgebrochen.")
token, username = web_login(server, username, password)
key = "" if paste_key else crypto.derive_key_b64(username, password)
del password
if key:
total, ok = _books_open(server, token, key)
if total and not ok:
print("Der Schlüssel aus dem Passwort öffnet die Bücher nicht (Passwort seit dem Hochladen geändert?).")
key = ""
if not key:
key = getpass.getpass("Schlüssel einfügen (diora: Einstellungen → Schlüssel kopieren, Base64; leer = abbrechen): ").strip()
if not key:
raise LoginError("Abgebrochen, nichts gespeichert.")
try:
if len(base64.b64decode(key)) != 32:
raise ValueError
except ValueError:
raise LoginError("Das ist kein gültiger Schlüssel (32 Byte, Base64).") from None
total, ok = _books_open(server, token, key)
if total and not ok:
raise LoginError("Auch dieser Schlüssel passt nicht. Nichts gespeichert.")
print(f"Angemeldet als {username} ({total} Bücher).")
return RemoteConfig(server_url=server, api_token=token, enc_key_b64=key, username=username)

164
tui/diora_tui/marks.py Normal file
View file

@ -0,0 +1,164 @@
"""Highlights in the terminal text: from the web's anchors to spans of the displayed text, and
back from a selection to an anchor.
An anchor addresses characters of a block's *raw* text (every text node, whitespace as in the
file); the TUI shows the block with whitespace collapsed (blocks.collapse_map relates the two).
The raw text is read from the file for the blocks involved only, a chapter at a time.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from rich.style import Style
from . import annotations, blocks
SELECTION_STYLE = Style(reverse=True)
NOTE_STYLE = Style(underline=True, dim=False)
_WORD_RE = re.compile(r"\S+")
def highlight_style(color: str | None) -> Style:
return Style(color="black", bgcolor=annotations.COLORS.get(color or "yellow", annotations.COLORS["yellow"]))
# ---- words (the cursor moves by word)
def word_starts(text: str) -> list[int]:
return [m.start() for m in _WORD_RE.finditer(text)]
def word_at(text: str, pos: int) -> tuple[int, int] | None:
"""(start, end) of the word at or after `pos`, or None if there is none."""
for m in _WORD_RE.finditer(text):
if m.end() > pos:
return m.start(), m.end()
return None
def next_word(text: str, pos: int) -> int | None:
return next((s for s in word_starts(text) if s > pos), None)
def prev_word(text: str, pos: int) -> int | None:
return next((s for s in reversed(word_starts(text)) if s < pos), None)
@dataclass
class Maps:
raw: str
norm_of_raw: list[int]
raw_of_norm: list[int]
length: int
class MarkIndex:
"""Relates a book's displayed blocks to the raw text anchors count in."""
def __init__(self, book: blocks.FlatBook) -> None:
self.book = book
self._raws: dict[int, list[str]] = {} # chapter -> raw text of its blocks
self._maps: dict[int, Maps | None] = {}
def maps(self, block_index: int) -> Maps | None:
"""None when the displayed text isn't the plain collapse of the raw text (a footnote
marker with spaces inside): such a block can't be addressed exactly."""
if block_index in self._maps:
return self._maps[block_index]
b = self.book.blocks[block_index]
chapter = b.chapter_index
if chapter not in self._raws:
self._raws[chapter] = blocks.raw_block_texts(self.book.path, chapter)
first = self.book.chapter_start_block[chapter]
raws = self._raws[chapter]
result = None
if 0 <= block_index - first < len(raws):
raw = raws[block_index - first]
norm_of_raw, raw_of_norm, length = blocks.collapse_map(raw)
if length == len(b.text) and "".join(raw[i] if not blocks._JS_SPACE_RE.fullmatch(raw[i]) else " " for i in raw_of_norm) == b.text:
result = Maps(raw, norm_of_raw, raw_of_norm, length)
self._maps[block_index] = result
return result
# ---- annotation -> spans
def spans(self, items: list[dict]) -> dict[int, list[tuple[int, int, Style]]]:
"""Per block the (start, end, style) spans of the highlights and notes."""
out: dict[int, list[tuple[int, int, Style]]] = {}
n = len(self.book.blocks)
for h in items:
a = h.get("anchor") or {}
if "startBlockIndex" not in a: # an old anchor (XPath): the web falls back to the quote, so do we
found = self._find_quote(a)
if found is not None:
bi, s0, e0 = found
out.setdefault(bi, []).append((s0, e0, NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))))
continue
try:
sb = int(a["startBlockIndex"])
eb = int(a.get("endBlockIndex", sb))
sc, ec = int(a.get("startChar", 0)), int(a.get("endChar", 0))
except (KeyError, TypeError, ValueError):
continue
if not (0 <= sb < n and sb <= eb < n):
continue
style = NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))
for bi, s, e in self._ranges(sb, sc, eb, ec, a.get("quote") or ""):
if e > s:
out.setdefault(bi, []).append((s, e, style))
return out
def _find_quote(self, anchor: dict) -> tuple[int, int, int] | None:
"""(block, start, end) of the first place in the anchor's chapter that holds its quote."""
needle = " ".join((anchor.get("quote") or "").split())
src = anchor.get("chapterSrc")
if not needle or src not in self.book.chapter_srcs:
return None
chapter = self.book.chapter_srcs.index(src)
first = self.book.chapter_start_block[chapter]
last = self.book.chapter_start_block[chapter + 1] if chapter + 1 < len(self.book.chapter_start_block) else len(self.book.blocks)
for bi in range(first, last):
i = self.book.blocks[bi].text.find(needle)
if i >= 0:
return bi, i, i + len(needle)
return None
def _ranges(self, sb: int, sc: int, eb: int, ec: int, quote: str):
text = self.book.blocks[sb].text
m = self.maps(sb)
if m is None or (eb != sb and self.maps(eb) is None):
# not addressable by offsets: find the quote
i = text.find(" ".join(quote.split())) if quote else -1
if i >= 0:
yield sb, i, i + len(" ".join(quote.split()))
return
s = m.norm_of_raw[min(sc, len(m.raw))]
if sb == eb:
yield sb, s, m.norm_of_raw[min(ec, len(m.raw))]
return
yield sb, s, m.length
for bi in range(sb + 1, eb):
yield bi, 0, len(self.book.blocks[bi].text)
me = self.maps(eb)
yield eb, 0, me.norm_of_raw[min(ec, len(me.raw))]
# ---- selection -> anchor
def anchor(self, start: tuple[int, int], end: tuple[int, int]) -> dict | None:
"""The web anchor for the displayed range start..end ((block, char) each, end exclusive),
or None when a block can't be addressed exactly."""
(sb, sc), (eb, ec) = start, end
ms, me = self.maps(sb), self.maps(eb)
if ms is None or me is None:
return None
s_raw = ms.raw_of_norm[sc] if sc < ms.length else len(ms.raw)
e_raw = me.raw_of_norm[ec - 1] + 1 if ec > 0 else 0
if sb == eb:
quote = ms.raw[s_raw:e_raw]
else:
quote = ms.raw[s_raw:] + me.raw[:e_raw]
chapter_src = self.book.chapter_srcs[self.book.blocks[sb].chapter_index] if self.book.chapter_srcs else ""
return annotations.make_anchor(chapter_src, sb, s_raw, eb, e_raw, quote)

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

@ -0,0 +1,30 @@
"""Where the TUI keeps its files. `DIORA_TUI_HOME` moves all of it into one folder (tests, the
smoke script and anyone who wants a second account side by side use it); platformdirs' own
answer on Windows doesn't follow the LOCALAPPDATA/APPDATA environment variables."""
from __future__ import annotations
import os
from pathlib import Path
from platformdirs import user_cache_dir, user_config_dir, user_data_dir
def _home() -> Path | None:
h = os.environ.get("DIORA_TUI_HOME")
return Path(h) if h else None
def data_dir() -> Path:
h = _home()
return h / "data" if h else Path(user_data_dir("diora-tui", "diora"))
def config_dir() -> Path:
h = _home()
return h / "config" if h else Path(user_config_dir("diora-tui", "diora"))
def cache_dir() -> Path:
h = _home()
return h / "cache" if h else Path(user_cache_dir("diora-tui", "diora"))

231
tui/diora_tui/progress.py Normal file
View file

@ -0,0 +1,231 @@
"""Reading position per book: the place last read wins.
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 asdict, dataclass, replace
from pathlib import Path
from . import paths
from .blocks import parse_anchor
DEVICE_NAME = "diora TUI"
_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 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 _locked(fn):
@functools.wraps(fn)
def wrapper(self, *a, **kw):
with self._lock:
return fn(self, *a, **kw)
return wrapper
class ProgressStore:
"""Shared by the UI thread (the reader writes) and the sync workers (they pull and push)."""
def __init__(self, path: Path | None = None) -> None:
self.path = path or _PROGRESS_FILE
self._lock = threading.RLock()
self.offset = 0
self.books: dict[str, BookState] = {}
self._load()
# ---- persistence
def _load(self) -> None:
try:
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 _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
_default: ProgressStore | None = None
def default() -> ProgressStore:
global _default
if _default is None:
_default = ProgressStore()
return _default

View file

@ -0,0 +1,703 @@
"""Continuous whole-book reader screen — mirrors the web reader's single
scrollable view (see blocks.py's module docstring) instead of the old
per-chapter pagination, so reading position is expressed in the same
"blockIndex:innerFraction" anchor space as the server and the web client.
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
from textual import work
from textual.binding import Binding
from textual.containers import Container, VerticalScroll
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
from . import annotations, blocks, cache, config as config_mod, epub, layout, library, library_meta, marks, progress, remote
from .annotation_screens import AnnotationsScreen, NoteScreen
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
# own (possibly already-constrained-from-a-previous-layout) width.
SCROLLBAR_GUTTER = 2
# A selection may span this many paragraphs (each is drawn again at every cursor move).
MAX_MARK_BLOCKS = 200
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
class FootnoteScreen(ModalScreen[None]):
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
def __init__(self, notes: list[str]) -> None:
super().__init__()
self._notes = notes
def compose(self):
with VerticalScroll(id="footnote-body"):
for note in self._notes:
yield Static(note, classes="footnote-note")
yield Label("Escape/f zum Schließen", id="footnote-hint")
def action_dismiss_self(self) -> 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),
Binding("k,up", "scroll_up_line", "Hoch", show=False),
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("v", "select", "Markieren"),
Binding("b", "bookmark", "Lesezeichen"),
Binding("a", "annotations", "Markierungen"),
Binding("h,left", "word_back", "Wort zurück", show=False),
Binding("l,right", "word_forward", "Wort vor", show=False),
Binding("enter,space", "confirm", "Weiter", show=False),
Binding("q,escape", "back", "Zurück zur Bibliothek"),
]
def __init__(self, path: Path) -> None:
super().__init__()
self.path = path
self.book: blocks.FlatBook | None = None
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
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
self._marks: marks.MarkIndex | None = None
self._ann = annotations.default()
self._styled: set[int] = set() # blocks drawn with marks at the moment
self._sel: dict | None = None # the selection being made: phase, cursor, start
self._ann_timer = None
def compose(self):
yield Header()
yield LoadingIndicator(id="reader-loading")
with Container(id="reader-scroll") as scroll_container:
self._scroll_container = scroll_container
# Stays visible (empty) from the start rather than toggling display
# on once loaded — a widget that's just been switched from hidden
# 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, on_read_scroll=self._on_read_scroll)
yield self._book_view
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
# Deferred rather than read synchronously here: right after mount the
# container hasn't been through a layout pass yet, so its size isn't
# reliable — same lesson as the scroll-restore race below.
self.call_after_refresh(self._start_loading)
def _start_loading(self) -> None:
if self._scroll_container.size.width == 0:
# Not sized yet after all — keep deferring instead of guessing a
# fixed number of refresh cycles (mirrors _restore_position below).
self.call_after_refresh(self._start_loading)
return
self._load_book(self._content_width())
@work(thread=True)
def _load_book(self, width: int) -> None:
cached = cache.load(self.path, width)
if cached is not None:
book, book_layout = cached
else:
book = blocks.load_flat_book(self.path)
console = Console(width=width)
book_layout = layout.build_layout([b.text for b in book.blocks], width, console)
cache.save(self.path, width, book, book_layout)
self.app.call_from_thread(self._on_book_loaded, book, book_layout, width)
def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None:
self.book = book
self.sub_title = book.title
self._apply_layout(book_layout, width)
self.query_one("#reader-loading", LoadingIndicator).display = False
library_meta.set_local_pages(self._key, library.pages_of_chars(book.total_chars))
st = self._store.get(self._key)
self._held_read_at = st.read_at if st else None
self._marks = marks.MarkIndex(book)
self._apply_marks()
if self._cfg is not None:
self._pull_annotations()
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
# measure-after-constrain race: once a layout has been applied,
# book_view's width is pinned to a previous value via styles.width
# (below), so re-measuring book_view itself on a later resize would
# just read that stale pinned width back instead of the new
# available space. The container's width is unaffected by that.
available = self._scroll_container.size.width - SCROLLBAR_GUTTER
return max(20, min(MAX_LINE_WIDTH, available))
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
self.book_layout = book_layout
# Pin book_view's outer width to content-width-plus-scrollbar so its
# *inner* content region (what render_line actually draws into) ends
# up exactly `width` — matching what block texts were wrapped at.
self._book_view.styles.width = width + SCROLLBAR_GUTTER
self._book_view.book_layout = book_layout
self._book_view.virtual_size = Size(width, book_layout.total_rows)
self._book_view.refresh()
def _build_layout(self) -> None:
assert self.book is not None
width = self._content_width()
console = Console(width=width)
block_texts = [b.text for b in self.book.blocks]
new_layout = layout.build_layout(block_texts, width, console)
cache.save(self.path, width, self.book, new_layout)
self._apply_layout(new_layout, width)
self._styled = set() # a fresh layout is drawn without marks
self._apply_marks()
def on_resize(self) -> None:
if self.book is None:
return
old_y = self._book_view.scroll_y
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)
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
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(st.anchor)
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
if y <= 0:
self._settle()
return
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 or self._last_anchor is None:
return
anchor = self._current_anchor_str()
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(self) -> None:
try:
remote.flush_pending(self._cfg)
except Exception:
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() or self._ann.dirty_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:
for send in (remote.flush_pending, remote.push_annotations):
try:
send(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:
if self._sel is not None:
self._move_block(1)
return
self._book_view.scroll_relative(y=1, animate=False)
def action_scroll_up_line(self) -> None:
if self._sel is not None:
self._move_block(-1)
return
self._book_view.scroll_relative(y=-1, animate=False)
def _current_chapter_index(self) -> int:
if self.book is None:
return 0
block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1
def _jump_to_block(self, block_index: int) -> None:
y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0)
self._book_view.scroll_to(y=y, animate=False, immediate=True)
def action_next_chapter(self) -> None:
if self._sel is not None:
self._selection_note() # n: a note, while marking
return
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter + 1 < len(self.book.chapter_start_block):
self._jump_to_block(self.book.chapter_start_block[chapter + 1])
self._save_progress()
def action_prev_chapter(self) -> None:
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter > 0:
self._jump_to_block(self.book.chapter_start_block[chapter - 1])
self._save_progress()
def action_peek_footnote(self) -> None:
if self.book is None or not self.book_layout.heights:
return
current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes]
backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes]
candidate = forward[0] if forward else (backward[-1] if backward else None)
if candidate is None:
self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3)
return
_, block = candidate
notes: list[str] = []
for ref in block.footnotes:
target_idx = self.book.footnote_targets.get(ref.target_id)
if target_idx is None:
continue
notes.append(self.book.blocks[target_idx].text)
if not notes:
self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3)
return
self.app.push_screen(FootnoteScreen(notes))
def action_back(self) -> None:
if self._sel is not None:
self._end_selection()
return
self.app.pop_screen()
# ---- marks on screen
def _marks_by_block(self) -> dict[int, list]:
spans: dict[int, list] = {}
if self._marks is not None:
for bi, lst in self._marks.spans(self._ann.get(self._key).highlights).items():
spans.setdefault(bi, []).extend(lst)
for bi, lst in self._selection_spans().items():
spans.setdefault(bi, []).extend(lst)
return spans
def _apply_marks(self) -> None:
"""Draw the highlights (and the selection) in place; blocks that lost theirs are drawn plain."""
if self.book is None or not self.book_layout.heights:
return
spans = self._marks_by_block()
console = Console(width=self.book_layout.width)
for bi in sorted(self._styled | set(spans)):
if 0 <= bi < len(self.book.blocks):
layout.restyle_block(self.book_layout, bi, self.book.blocks[bi].text, console, spans.get(bi, ()))
self._styled = set(spans)
self._book_view.refresh()
@work(thread=True, exclusive=True, group="ann-pull")
def _pull_annotations(self) -> None:
if remote.pull_annotations(self._cfg, self._server_id, self._key):
self.app.call_from_thread(self._apply_marks)
def _annotations_changed(self) -> None:
"""A highlight, note or bookmark was made, changed or deleted: draw it, and send it soon."""
self._apply_marks()
if self._cfg is None:
return
if self._ann_timer is not None:
self._ann_timer.stop()
self._ann_timer = self.set_timer(2.0, self._push_annotations) # like the web's debounce
@work(thread=True, exclusive=True, group="ann-push")
def _push_annotations(self) -> None:
try:
remote.push_annotations(self._cfg)
except Exception:
pass # stays queued
# ---- bookmarks and the list
def action_bookmark(self) -> None:
if self.book is None or not self.book_layout.heights or self._sel is not None:
return
block, inner = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
chapter = max(0, self._current_chapter_index())
src = self.book.chapter_srcs[chapter] if chapter < len(self.book.chapter_srcs) else ""
label = re.sub(r"\.x?html?$", "", src.rsplit("/", 1)[-1], flags=re.I) or "Lesezeichen" # as the web names it
bm = annotations.new_bookmark(label, src, blocks.fraction_for_anchor(self.book, block, inner))
self._ann.add(self._key, "bookmarks", bm)
self.notify(f"★ Lesezeichen: {label}", timeout=2.5)
self._annotations_changed()
def action_annotations(self) -> None:
if self.book is None or self._sel is not None:
return
def picked(block: int | None) -> None:
if block is not None:
self._jump_quiet(block)
self.app.push_screen(AnnotationsScreen(self.book, self._ann, self._key, self._annotations_changed, self._marks), picked)
def _jump_quiet(self, block: int) -> None:
"""Go to a block as a jump, not as reading: nothing is saved until the reader moves on."""
y = layout.scroll_y_for_anchor(self.book_layout, block, 0.0)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
# ---- selecting text (v): a cursor by word, Enter sets the start, Enter again the end
def _first_block_in_view(self) -> int:
y = self._book_view.scroll_y
i = bisect.bisect_left(self.book_layout.starts, y)
return min(i, len(self.book.blocks) - 1)
def _word_block(self, block: int, step: int) -> int | None:
"""The nearest block from `block` (inclusive) in direction `step` that has a word."""
while 0 <= block < len(self.book.blocks):
if marks.word_starts(self.book.blocks[block].text):
return block
block += step
return None
def action_select(self) -> None:
if self.book is None or not self.book_layout.heights:
return
if self._sel is not None:
self.action_confirm()
return
block = self._word_block(self._first_block_in_view(), 1)
if block is None:
return
self._sel = {"phase": "start", "cursor": (block, marks.word_starts(self.book.blocks[block].text)[0]), "start": None}
self.notify("Markieren: h/l Wort, j/k Absatz, Enter = Anfang, n = Notiz, Esc = Abbrechen", timeout=6)
self._selection_moved()
def _end_selection(self) -> None:
self._sel = None
self._apply_marks()
def _selection_moved(self) -> None:
self._apply_marks()
block, char = self._sel["cursor"]
text = self.book.blocks[block].text
row = self.book_layout.starts[block] + int(char / max(1, len(text)) * self.book_layout.heights[block])
top, height = self._book_view.scroll_y, self._book_view.size.height
if not (top <= row < top + height - 1):
with self._book_view.program_scroll():
self._book_view.scroll_to(y=max(0, row - height // 3), animate=False, immediate=True)
self._last_anchor = self._current_anchor_str()
def action_word_forward(self) -> None:
self._move_word(1)
def action_word_back(self) -> None:
self._move_word(-1)
def _move_word(self, step: int) -> None:
if self._sel is None:
return
block, char = self._sel["cursor"]
text = self.book.blocks[block].text
target = marks.next_word(text, char) if step > 0 else marks.prev_word(text, char)
if target is not None:
self._sel["cursor"] = (block, target)
else:
nb = self._word_block(block + step, step)
if nb is None:
return
ws = marks.word_starts(self.book.blocks[nb].text)
self._sel["cursor"] = (nb, ws[0] if step > 0 else ws[-1])
self._selection_moved()
def _move_block(self, step: int) -> None:
nb = self._word_block(self._sel["cursor"][0] + step, step)
if nb is not None:
self._sel["cursor"] = (nb, marks.word_starts(self.book.blocks[nb].text)[0])
self._selection_moved()
def _cursor_word(self) -> tuple[int, int, int]:
block, char = self._sel["cursor"]
w = marks.word_at(self.book.blocks[block].text, char) or (char, char)
return block, w[0], w[1]
def _selection_range(self) -> tuple[tuple[int, int], tuple[int, int]]:
"""(start, end) of what is selected, end exclusive, whichever way the cursor went."""
sb, sc = self._sel["start"]
sw = marks.word_at(self.book.blocks[sb].text, sc) or (sc, sc)
cb, cs, ce = self._cursor_word()
if (cb, cs) >= (sb, sw[0]):
return (sb, sw[0]), (cb, ce)
return (cb, cs), (sb, sw[1])
def _selection_spans(self) -> dict[int, list]:
if self._sel is None or self.book is None:
return {}
if self._sel["phase"] == "start":
block, s, e = self._cursor_word()
return {block: [(s, e, marks.SELECTION_STYLE)]}
(sb, sc), (eb, ec) = self._selection_range()
out: dict[int, list] = {}
for bi in range(sb, eb + 1):
text_len = len(self.book.blocks[bi].text)
out[bi] = [(sc if bi == sb else 0, ec if bi == eb else text_len, marks.SELECTION_STYLE)]
return out
def action_confirm(self) -> None:
if self._sel is None:
return
if self._sel["phase"] == "start":
block, s, _e = self._cursor_word()
self._sel["start"] = (block, s)
self._sel["phase"] = "end"
self.notify("Ende wählen (h/l/j/k), Enter = fertig.", timeout=4)
self._selection_moved()
return
(sb, sc), (eb, ec) = self._selection_range()
if eb - sb > MAX_MARK_BLOCKS:
self.notify(f"Zu lang zum Markieren (mehr als {MAX_MARK_BLOCKS} Absätze).", severity="warning")
return
anchor = self._marks.anchor((sb, sc), (eb, ec))
if anchor is None:
self.notify("Diese Stelle lässt sich nicht genau zuordnen (Fußnotenmarker mit Leerraum).", severity="warning")
return
names = [annotations.COLOR_NAMES[c] for c in annotations.COLORS] + ["Gelb mit Notiz …"]
colors = list(annotations.COLORS)
def picked(i: int | None) -> None:
if i is None:
return # the selection stays: pick again or Esc
if i < len(colors):
self._add_highlight(anchor, colors[i], "")
else:
self.app.push_screen(NoteScreen("Notiz zur Markierung"), lambda text: self._add_highlight(anchor, "yellow", text) if text is not None else None)
from .library_screen import ChoiceScreen
self.app.push_screen(ChoiceScreen("Markieren als", names, 0), picked)
def _add_highlight(self, anchor: dict, color: str, note: str) -> None:
self._ann.add(self._key, "highlights", annotations.new_highlight(anchor, color, note))
self._sel = None
self._annotations_changed()
def _selection_note(self) -> None:
"""n while marking: a freeform note at the cursor's word (phase 1) or on the selection."""
if self._sel["phase"] == "start":
block, s, e = self._cursor_word()
start, end = (block, s), (block, e)
else:
start, end = self._selection_range()
anchor = self._marks.anchor(start, end)
if anchor is None:
self.notify("Diese Stelle lässt sich nicht genau zuordnen.", severity="warning")
return
def noted(text: str | None) -> None:
if text:
self._ann.add(self._key, "highlights", annotations.new_note(anchor, text))
self._sel = None
self._annotations_changed()
self.app.push_screen(NoteScreen("Notiz"), noted)

193
tui/diora_tui/remote.py Normal file
View file

@ -0,0 +1,193 @@
"""Talk to the diora server: pull the book list and reading state, push queued positions,
download a book when it is opened.
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 annotations, api, crypto, library, library_meta, progress
from .config import RemoteConfig
from .epub import server_key
class SyncError(Exception):
pass
@dataclass
class SyncResult:
books: int = 0
skipped_pdf: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
pushed: int = 0
new_titles: list[str] = field(default_factory=list)
def _sanitize_filename(name: str) -> str:
name = re.sub(r"[^\w\s.-]", "_", name).strip()
return (name or "buch")[:80]
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 pull_annotations(cfg: RemoteConfig, server_id: int, key: str) -> bool:
"""The server's highlights and bookmarks of the book, laid under our queued edits. False when
offline or the key doesn't open them (what is local stays)."""
store = annotations.default()
try:
for kind in annotations.KINDS:
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, server_id, kind))
store.take_remote(key, kind, theirs)
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
return False
return True
def push_annotations(cfg: RemoteConfig) -> int:
"""Write every queued list: merged with the server's current one first (annotations.py).
Stops quietly at network trouble; the rest stays queued."""
store = annotations.default()
written = 0
for key in store.dirty_keys():
if not (key.startswith("s") and key[1:].isdigit()):
continue
book_id = int(key[1:])
for kind in annotations.KINDS:
if not store.get(key).dirty[kind]:
continue
try:
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, book_id, kind))
merged, snapshot = store.for_push(key, kind, theirs)
blob = annotations.encode(cfg.enc_key_b64, merged)
if len(blob["ct"]) * 3 // 4 > annotations.MAX_BYTES[kind]:
raise SyncError("Zu viele Markierungen für dieses Buch (Grenze des Servers erreicht).")
api.post_annotations(cfg.server_url, cfg.api_token, book_id, kind, blob)
except api.ApiError as e:
if "nicht gefunden" in str(e):
continue # the book is gone
raise SyncError(str(e)) from e
except (crypto.DecryptError, ValueError) as e:
raise SyncError("Die Markierungen auf dem Server lassen sich nicht entschlüsseln.") from e
except requests.RequestException:
return written
store.pushed(key, kind, merged, snapshot)
written += 1
return written
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:
result.pushed = flush_pending(cfg)
try:
push_annotations(cfg)
except SyncError:
pass # the notes stay queued; the book list is what this call is for
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
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 = 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(title)
continue
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 "",
"folders": library.folders_of_meta(meta), "pages": int(meta.get("pages") or 0),
"magazine": meta.get("magazine") if isinstance(meta.get("magazine"), bool) else None}
if key not in known:
result.new_titles.append(title)
store.apply_server(key, b)
library_meta.set_remote(entries)
result.books = len(entries)
_pull_folder_order(cfg)
return result
def _pull_folder_order(cfg: RemoteConfig) -> None:
"""The folders' order: encrypted library prefs {folder_order: [...]}. Best effort."""
try:
prefs = api.fetch_prefs(cfg.server_url, cfg.api_token)
if not prefs.get("prefs_ct"):
return
plain = json.loads(crypto.decrypt(cfg.enc_key_b64, prefs["prefs_iv"], prefs["prefs_ct"]).decode("utf-8"))
order = plain.get("folder_order")
if isinstance(order, list):
library_meta.set_extra("folder_order", [f for f in order if isinstance(f, str)])
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
pass
def download_book(library_dir: Path, cfg: RemoteConfig, server_id: int, title: str) -> Path:
existing = local_path(library_dir, server_id)
if existing is not None:
return existing
try:
data = api.fetch_book_data(cfg.server_url, cfg.api_token, server_id)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
except api.ApiError as e:
raise SyncError(str(e)) from e
except crypto.DecryptError as e:
raise SyncError("Das Buch lässt sich mit dem gespeicherten Schlüssel nicht entschlüsseln.") from e
except requests.RequestException as e:
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
library_dir.mkdir(parents=True, exist_ok=True)
dest = library_dir / f"{server_id:04d} - {_sanitize_filename(title)}.epub"
tmp = dest.with_suffix(".part")
tmp.write_bytes(raw)
os.replace(tmp, dest)
return dest

View file

@ -0,0 +1,55 @@
"""Protect the account's token and book key at rest.
Windows: DPAPI (CryptProtectData), bound to this Windows user on this machine, as
tools/diora_client.py in diora-android does. Elsewhere there is no such store in the standard
library: the blob is plain base64 in a 0600 file (the same exposure the web client has with
its key in localStorage), marked as such so a later version can move it into a keyring.
"""
from __future__ import annotations
import base64
import sys
_ENTROPY = b"diora-tui"
def _dpapi(fn_name: str, data: bytes) -> bytes:
import ctypes
import ctypes.wintypes as wt
class Blob(ctypes.Structure):
_fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
def blob(b: bytes):
buf = ctypes.create_string_buffer(b, len(b))
return Blob(len(b), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))), buf
inp, _k1 = blob(data)
ent, _k2 = blob(_ENTROPY)
out = Blob()
fn = getattr(ctypes.windll.crypt32, fn_name)
if not fn(ctypes.byref(inp), None, ctypes.byref(ent), None, None, 1, ctypes.byref(out)): # 1: no UI
raise ctypes.WinError()
try:
return ctypes.string_at(out.pbData, out.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out.pbData)
def protect(data: bytes) -> str:
if sys.platform == "win32":
return "dpapi:" + base64.b64encode(_dpapi("CryptProtectData", data)).decode()
return "plain:" + base64.b64encode(data).decode()
def unprotect(blob: str) -> bytes:
kind, _, b64 = blob.partition(":")
raw = base64.b64decode(b64)
if kind == "dpapi":
if sys.platform != "win32":
raise ValueError("Zugangsdaten sind mit Windows-DPAPI geschützt und nur dort lesbar")
return _dpapi("CryptUnprotectData", raw)
if kind == "plain":
return raw
raise ValueError("unbekanntes Format der gespeicherten Zugangsdaten")

View file

@ -0,0 +1,44 @@
"""Bottom status bar: battery level + current time, refreshed every second.
Battery is read via psutil (cross-platform); on a desktop machine with no
battery, psutil.sensors_battery() returns None and the bar just shows time.
"""
from __future__ import annotations
from datetime import datetime
from textual.widgets import Static
try:
import psutil
except ImportError:
psutil = None # battery display degrades gracefully to time-only
class StatusBar(Static):
def on_mount(self) -> None:
self._refresh()
self.set_interval(1.0, self._refresh)
def _refresh(self) -> None:
self.update(self._render_text())
def _render_text(self) -> str:
parts = []
battery = self._battery_text()
if battery:
parts.append(battery)
parts.append(datetime.now().strftime("%H:%M:%S"))
return " ".join(parts)
def _battery_text(self) -> str | None:
if psutil is None:
return None
try:
battery = psutil.sensors_battery()
except Exception:
return None
if battery is None:
return None
state = "lädt" if battery.power_plugged else "Akku"
return f"{state} {round(battery.percent)}%"

26
tui/pyproject.toml Normal file
View file

@ -0,0 +1,26 @@
[project]
name = "diora-tui"
version = "0.1.0"
description = "Terminal-EPUB-Reader für diora mit Server-Sync für Bücher und Lesefortschritt."
requires-python = ">=3.9"
dependencies = [
"textual>=0.60",
"ebooklib>=0.18",
"beautifulsoup4>=4.12",
"lxml>=5.0",
"html5lib>=1.1",
"platformdirs>=4.0",
"requests>=2.31",
"cryptography>=42.0",
"psutil>=5.9",
]
[project.scripts]
diora-tui = "diora_tui.app:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["diora_tui"]

55
tui/tests/conftest.py Normal file
View file

@ -0,0 +1,55 @@
"""Tests run against a throwaway home: progress, config and caches are module-level
paths from platformdirs, read at import time, so the environment is set before anything
from diora_tui is imported."""
import os
import tempfile
import zipfile
from pathlib import Path
_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
CONTAINER = """<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>"""
def make_epub(path: Path, chapters: list[str], title: str = "Testbuch") -> Path:
"""A minimal EPUB whose spine is `chapters` (each one the full XHTML of a document)."""
items = "".join(f'<item id="c{i}" href="c{i}.xhtml" media-type="application/xhtml+xml"/>' for i in range(len(chapters)))
spine = "".join(f'<itemref idref="c{i}"/>' for i in range(len(chapters)))
opf = f"""<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>{title}</dc:title><dc:creator>Autor</dc:creator>
<dc:identifier id="id">test-{title}</dc:identifier><dc:language>de</dc:language></metadata>
<manifest>{items}</manifest><spine>{spine}</spine></package>"""
with zipfile.ZipFile(path, "w") as z:
z.writestr("mimetype", "application/epub+zip")
z.writestr("META-INF/container.xml", CONTAINER)
z.writestr("OEBPS/content.opf", opf)
for i, body in enumerate(chapters):
z.writestr(f"OEBPS/c{i}.xhtml", f'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>x</title></head><body>{body}</body></html>')
return path
@pytest.fixture
def epub(tmp_path):
def build(chapters, title="Testbuch"):
return make_epub(tmp_path / f"{title}.epub", chapters, title)
return build
@pytest.fixture(autouse=True)
def clean_library_meta():
"""library.json is one file in the shared test home: every test starts without it."""
from diora_tui import library_meta
library_meta._META_FILE.unlink(missing_ok=True)
yield
library_meta._META_FILE.unlink(missing_ok=True)

View file

@ -0,0 +1,142 @@
"""Highlights, notes and bookmarks: the formats of the web reader and the merge the TUI adds
(the server replaces the whole list; nothing another device wrote may be lost)."""
import base64
import os
import pytest
import requests
from diora_tui import annotations as an, api, crypto, remote
from diora_tui.config import RemoteConfig
def hl(i, note="", block=1):
return {"id": i, "type": "highlight", "anchor": an.make_anchor("c.xhtml", block, 0, block, 5, "quote"), "color": "yellow", "note": note, "createdAt": "x"}
def ids(items):
return [x["id"] for x in items]
def test_merge_keeps_their_new_items_and_applies_our_changes():
base = [hl("a"), hl("b"), hl("c")]
ours = [hl("a", note="edited"), hl("c"), hl("ours")] # edited a, deleted b, added ours
theirs = [hl("a"), hl("b"), hl("c"), hl("theirs")] # added theirs
out = an.merge(base, ours, theirs)
assert ids(out) == ["a", "c", "theirs", "ours"] # b: we deleted it
assert [x for x in out if x["id"] == "a"][0]["note"] == "edited"
def test_merge_their_deletion_does_not_come_back_unless_we_changed_it():
base = [hl("a"), hl("b")]
assert ids(an.merge(base, [hl("a"), hl("b")], [hl("a")])) == ["a"] # they deleted b, we left it
assert ids(an.merge(base, [hl("a"), hl("b", note="mine")], [hl("a")])) == ["a", "b"] # we edited it: kept
def test_merge_without_a_base_deletes_nothing():
assert ids(an.merge(None, [hl("a")], [hl("a"), hl("b")])) == ["a", "b"]
def test_bookmarks_are_newest_first():
bm = lambda i: {"id": i, "label": i, "anchor": "c", "scrollFraction": 0, "createdAt": "x"} # noqa: E731
out = an.merge([bm("old")], [bm("new"), bm("old")], [bm("old"), bm("theirs")], prepend_added=True)
assert ids(out) == ["new", "old", "theirs"]
@pytest.fixture
def store(tmp_path):
return an.AnnotationStore(tmp_path / "ann")
def test_edits_are_queued_and_survive_a_restart(store, tmp_path):
h = an.new_highlight(an.make_anchor("c", 3, 0, 3, 4, "abcd"), "green", "n")
store.add("s1", "highlights", h)
store.add("s1", "bookmarks", an.new_bookmark("Kap. 1", "c", 0.1))
store.add("s1", "bookmarks", an.new_bookmark("Kap. 2", "c2", 0.2))
assert store.dirty_keys() == ["s1"]
again = an.AnnotationStore(tmp_path / "ann").get("s1")
assert ids(again.highlights) == [h["id"]] and [b["label"] for b in again.bookmarks] == ["Kap. 2", "Kap. 1"]
assert again.dirty == {"highlights": True, "bookmarks": True}
def test_note_edit_and_delete(store):
h = an.new_highlight(an.make_anchor("c", 3, 0, 3, 4, "abcd"))
store.add("s1", "highlights", h)
assert store.set_note("s1", h["id"], "text") and not store.set_note("s1", h["id"], "text")
assert store.get("s1").highlights[0]["note"] == "text"
assert store.delete("s1", "highlights", h["id"]) and not store.delete("s1", "highlights", h["id"])
def test_pull_replaces_when_clean_and_merges_when_dirty(store):
store.take_remote("s1", "highlights", [hl("a")])
assert ids(store.get("s1").highlights) == ["a"] and not store.get("s1").dirty["highlights"]
store.add("s1", "highlights", hl("mine"))
store.take_remote("s1", "highlights", [hl("a"), hl("b")])
assert ids(store.get("s1").highlights) == ["a", "b", "mine"] and store.get("s1").dirty["highlights"]
def test_push_flow_with_an_edit_during_the_request(store):
store.take_remote("s1", "highlights", [hl("a")])
store.add("s1", "highlights", hl("one"))
merged, snap = store.for_push("s1", "highlights", [hl("a"), hl("theirs")])
assert ids(merged) == ["a", "theirs", "one"]
store.add("s1", "highlights", hl("two")) # made while the request was out
store.pushed("s1", "highlights", merged, snap)
got = store.get("s1")
assert ids(got.highlights) == ["a", "theirs", "one", "two"] and got.dirty["highlights"]
assert ids(got.base["highlights"]) == ["a", "theirs", "one"]
def test_blob_roundtrip():
key = base64.b64encode(os.urandom(32)).decode()
items = [hl("ä")]
assert an.decode(key, an.encode(key, items)) == items
assert an.decode(key, {"ct": None, "iv": None}) == []
CFG = RemoteConfig("http://x", "tok", base64.b64encode(os.urandom(32)).decode())
class FakeServer:
def __init__(self):
self.blobs = {}
self.fail = None
def fetch(self, server, token, book_id, kind):
if self.fail:
raise self.fail
return self.blobs.get((book_id, kind), {"ct": None, "iv": None})
def post(self, server, token, book_id, kind, blob):
if self.fail:
raise self.fail
self.blobs[(book_id, kind)] = blob
@pytest.fixture
def server(store, monkeypatch):
s = FakeServer()
monkeypatch.setattr(an, "_default", store)
monkeypatch.setattr(api, "fetch_annotations", s.fetch)
monkeypatch.setattr(api, "post_annotations", s.post)
return s
def test_push_merges_with_what_another_device_wrote(server, store):
server.blobs[(7, "highlights")] = an.encode(CFG.enc_key_b64, [hl("theirs")])
store.add("s7", "highlights", hl("mine"))
assert remote.push_annotations(CFG) == 1
assert ids(an.decode(CFG.enc_key_b64, server.blobs[(7, "highlights")])) == ["theirs", "mine"]
assert store.dirty_keys() == [] and ids(store.get("s7").highlights) == ["theirs", "mine"]
def test_push_keeps_the_queue_offline_and_pull_survives_it(server, store):
store.add("s7", "highlights", hl("mine"))
server.fail = requests.ConnectionError("down")
assert remote.push_annotations(CFG) == 0 and store.dirty_keys() == ["s7"]
assert remote.pull_annotations(CFG, 7, "s7") is False and ids(store.get("s7").highlights) == ["mine"]
def test_local_books_are_never_pushed(server, store):
store.add("0123456789abcdef", "highlights", hl("mine"))
assert remote.push_annotations(CFG) == 0 and not server.blobs

View file

@ -0,0 +1,66 @@
"""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()
path = epub(["<h1>Anfang</h1>" + "".join(f"<p>Absatz {i} " + "wort " * 40 + "</p>" for i in range(60))])
path.rename(lib / path.name)
async def run():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
await pilot.press("enter")
for _ in range(50):
await pilot.pause(0.1)
if isinstance(app.screen, ReaderScreen):
break
assert isinstance(app.screen, ReaderScreen)
await pilot.pause(0.5)
await pilot.press("j", "j", "j", "n")
await pilot.press("escape")
await pilot.pause()
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"}})
async def run():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
text = " | ".join(str(i.render()) for i in app.screen.query_one(ListView).query(Label))
assert "☁ Cloud-Buch" 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({})

53
tui/tests/test_blocks.py Normal file
View file

@ -0,0 +1,53 @@
"""The block list must count and read exactly like the web reader (static/js/app/44-books.js,
40-epub.js sanitizeEpubHtml): anchors are block indexes shared between all clients. The
real-book comparison against the web reader's own code is tools/parity/."""
from diora_tui.blocks import anchor_is_further, format_anchor, load_flat_book, parse_anchor
def texts(book):
return [(b.tag, b.text) for b in book.blocks]
def test_block_selector_and_nesting(epub):
book = load_flat_book(epub([
"<h1>Titel</h1><p>Eins</p><blockquote><p>Zitat</p></blockquote><ul><li>a</li></ul>"
"<div>Blatt</div><div><span>kein Blatt</span></div><div><p>Behälter</p></div>"
]))
# a div counts only when it has no element children; blockquote and its p both count
assert texts(book) == [
("h1", "Titel"), ("p", "Eins"), ("blockquote", "Zitat"), ("p", "Zitat"),
("li", "a"), ("div", "Blatt"), ("p", "Behälter"),
]
def test_all_spine_documents_count_even_without_text(epub):
book = load_flat_book(epub(["<p>a</p>", "<p>b</p>"]))
assert [b.chapter_index for b in book.blocks] == [0, 1]
def test_comments_are_not_text(epub):
book = load_flat_book(epub(['<h1><!-- d="kap1" -->Titel</h1><p>x<!-- weg -->y</p>']))
assert texts(book) == [("h1", "Titel"), ("p", "xy")]
def test_whitespace_is_javascripts(epub):
# \s in JS: no-break space and BOM collapse like spaces; \x85 does not
book = load_flat_book(epub(["<p>a  bc</p><p>\x85</p>"]))
assert texts(book)[0] == ("p", "a b c")
assert texts(book)[1][1] == "\x85"
def test_footnote_marker_adds_no_space(epub):
book = load_flat_book(epub(['<p>Satz.<sup><a href="#n1">1</a></sup>Weiter</p><p id="n1">Fußnote</p>']))
assert book.blocks[0].text == "Satz.1Weiter"
assert book.blocks[0].footnotes[0].marker == "1"
assert book.footnote_targets["n1"] == 1
def test_anchor_format_and_order():
assert format_anchor(12, 0.5) == "12:0.500000"
assert parse_anchor("12:0.500000") == (12, 0.5)
assert parse_anchor("kaputt") is None
assert anchor_is_further("5:0.1", "4:0.9")
assert anchor_is_further("5:0.5", "5:0.4")
assert not anchor_is_further("4:0.9", "5:0.1")

121
tui/tests/test_library.py Normal file
View file

@ -0,0 +1,121 @@
"""The library rules of the web (docs/library.md, 53-library.js): filters, folders, search,
sort, the Weiterlesen card."""
from types import SimpleNamespace
import pytest
from diora_tui import library as lib
from diora_tui.library import Row
NOW = 1_760_000_000_000
DAY = 86_400_000
def row(title, **kw):
return Row(key=kw.pop("key", title), title=title, **kw)
@pytest.fixture
def rows():
return [
row("Das Kapital 2", author="Marx", folders=["Philosophie", "Politik"], pages=900, uploaded_at="2026-01-02", read_at=NOW - 2 * DAY, fraction=0.3),
row("Das Kapital 10", author="Marx", folders=["Politik"], pages=300, uploaded_at="2026-03-01"),
row("Ödipus", author="Sophokles", folders=[], pages=0, uploaded_at="2026-02-01", is_read=True, read_at=NOW - 9 * DAY, fraction=1.0),
row("Zauberberg", author="", folders=["Roman"], pages=700, uploaded_at="2026-04-01", read_at=NOW - 60_000, fraction=0.5),
row("Jacobin 24", folders=["Zeitschriften"], pages=80, uploaded_at="2026-05-01"),
row("Heft", folders=[], magazine=True, uploaded_at="2026-05-02"),
]
def titles(rs):
return [r.title for r in rs]
def test_sections_split_books_from_magazines(rows):
assert titles(lib.section(rows, True)) == ["Jacobin 24", "Heft"]
assert "Jacobin 24" not in titles(lib.section(rows, False))
assert lib.is_magazine(row("x", folders=["Magazine"])) and not lib.is_magazine(row("x", folders=["Zeitschriften"], magazine=False))
def test_status_counts_and_filters(rows):
books = lib.section(rows, False)
assert lib.status_counts(books) == {"all": 4, "started": 2, "unread": 1, "read": 1}
assert titles(lib.shown(books, status="started", sort="title")) == ["Das Kapital 2", "Zauberberg"]
assert titles(lib.shown(books, status="unread")) == ["Das Kapital 10"]
assert titles(lib.shown(books, status="read")) == ["Ödipus"]
def test_folders_filter_and_order(rows):
books = lib.section(rows, False)
assert titles(lib.shown(books, folder="Politik", sort="title")) == ["Das Kapital 2", "Das Kapital 10"]
assert titles(lib.shown(books, folder="")) == ["Ödipus"] # without a folder
# the user's order first, the rest by name
assert lib.folder_list(books, ["Roman", "Politik"]) == [("Roman", 1), ("Politik", 2), ("Philosophie", 1)]
assert lib.folder_list(books, None) == [("Philosophie", 1), ("Politik", 2), ("Roman", 1)]
def test_search_ignores_case_and_accents_and_reads_author(rows):
assert titles(lib.shown(rows, q="odipus")) == ["Ödipus"]
assert titles(lib.shown(rows, q="MARX", sort="title")) == ["Das Kapital 2", "Das Kapital 10"]
assert titles(lib.shown(rows, q="sophokles ödipus")) == [] # one run of text, as the web matches
def test_sorts(rows):
books = lib.section(rows, False)
assert titles(lib.shown(books, sort="recent")) == ["Zauberberg", "Das Kapital 2", "Ödipus", "Das Kapital 10"]
assert titles(lib.shown(books, sort="title")) == ["Das Kapital 2", "Das Kapital 10", "Ödipus", "Zauberberg"] # numbers by value
assert titles(lib.shown(books, sort="author")) == ["Das Kapital 2", "Das Kapital 10", "Ödipus", "Zauberberg"] # no author last
assert titles(lib.shown(books, sort="added")) == ["Zauberberg", "Das Kapital 10", "Ödipus", "Das Kapital 2"]
assert titles(lib.shown(books, sort="short")) == ["Das Kapital 10", "Zauberberg", "Das Kapital 2", "Ödipus"] # unknown length last
assert titles(lib.shown(books, sort="long")) == ["Das Kapital 2", "Zauberberg", "Das Kapital 10", "Ödipus"]
def test_continue_card_takes_the_last_read_unfinished_book(rows):
going, recent = lib.continue_rows(lib.section(rows, False))
assert going.title == "Zauberberg"
assert titles(recent) == ["Das Kapital 2", "Ödipus"] # the others read lately, finished ones too
only_done = [r for r in rows if r.is_read]
assert lib.continue_rows(only_done) == (None, [])
assert lib.continue_rows([row("neu")]) == (None, [])
def test_continue_card_looks_only_at_the_seven_read_last():
many = [row(f"b{i}", read_at=NOW - i * 1000, is_read=(i < 7)) for i in range(10)]
assert lib.continue_rows(many) == (None, []) # the 7 read last are all finished: no card
def test_length_of_a_book():
assert lib.pages_of_chars(0) == 0
assert lib.pages_of_chars(10) == 1
assert lib.pages_of_chars(1800 * 3 + 900) == 4 # rounds half up like Math.round
assert lib.pages_of_chars(1800 * 3 + 899) == 3
@pytest.mark.parametrize("ms,expected", [
(NOW - 30_000, "gerade eben"),
(NOW - 5 * 60_000, "vor 5 Minuten"),
(NOW - DAY * 3 - 3_600_000, "vor 3 Tagen"),
])
def test_ago(ms, expected):
assert lib.ago(ms, NOW) == expected
def test_percent():
assert lib.percent(row("a", fraction=0.425)) == 43
assert lib.percent(row("a", is_read=True)) == 100
def test_build_rows_joins_files_and_server_books(tmp_path):
f = tmp_path / "0001 - Eins.epub"
f.write_bytes(b"")
local = tmp_path / "lokal.epub"
local.write_bytes(b"")
remote = {"s1": {"server_id": 1, "title": "Eins", "author": "A", "is_read": False, "uploaded_at": "x", "folders": ["F"], "pages": 0},
"s2": {"server_id": 2, "title": "Zwei", "is_read": True, "pages": 50}}
states = {"s1": SimpleNamespace(read_at=5, fraction=0.25, device="Handy")}
key_of = lambda p: "s1" if p.name.startswith("0001") else "hash" # noqa: E731
rs = {r.key: r for r in lib.build_rows([f, local], remote, states, {"s1": 12}, key_of)}
assert set(rs) == {"s1", "s2", "hash"}
assert (rs["s1"].title, rs["s1"].path, rs["s1"].folders, rs["s1"].fraction, rs["s1"].pages) == ("Eins", f, ["F"], 0.25, 12)
assert rs["s2"].path is None and rs["s2"].is_read and rs["s2"].pages == 50
assert rs["hash"].title == "lokal" and rs["hash"].server_id is None

View file

@ -0,0 +1,148 @@
"""The library screen: chips, search, sort, folders, section, the Weiterlesen card."""
import asyncio
import pytest
from textual.widgets import Input, ListView
from diora_tui import library_meta, progress
from diora_tui.app import DioraTuiApp
from diora_tui.library_screen import ChoiceScreen, LibraryScreen
from diora_tui.reader_screen import ReaderScreen
T = 1_760_000_000_000
def entry(i, title, **kw):
return {"server_id": i, "title": title, "author": kw.pop("author", ""), "is_read": kw.pop("is_read", False),
"uploaded_at": f"2026-0{i}-01", "folders": kw.pop("folders", []), "pages": kw.pop("pages", 0), **kw}
@pytest.fixture
def world(tmp_path, monkeypatch):
store = progress.ProgressStore(tmp_path / "p.json")
monkeypatch.setattr(progress, "_default", store)
now = progress.now_ms()
library_meta.set_remote({
"s1": entry(1, "Das Kapital", author="Marx", folders=["Politik"], pages=900),
"s2": entry(2, "Zauberberg", author="Mann", folders=["Roman"], pages=700),
"s3": entry(3, "Ödipus", is_read=True, pages=90),
"s4": entry(4, "Jacobin 24", folders=["Zeitschriften"], pages=80),
})
library_meta.set_extra("folder_order", ["Roman", "Politik"])
library_meta.set_extra("sort", "recent")
store.apply_server("s2", {"position_anchor": "5:0.5", "scroll_fraction": 0.5, "read_at": now - 120_000, "device": "Handy"})
store.apply_server("s1", {"position_anchor": "5:0.5", "scroll_fraction": 0.2, "read_at": now - 3_600_000 * 30})
lib = tmp_path / "lib"
lib.mkdir()
yield lib
library_meta.set_remote({})
library_meta.set_extra("folder_order", [])
library_meta.set_extra("sort", "recent")
def run(lib, scenario):
async def go():
app = DioraTuiApp(lib)
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause(0.3)
await scenario(app, app.screen, pilot)
asyncio.run(go())
def titles(screen):
return [r.title for r in screen.shown]
def text_of(screen, selector):
return str(screen.query_one(selector).render())
def test_card_chips_and_list(world):
async def scenario(app, screen, pilot):
assert screen.going.title == "Zauberberg"
assert "Zauberberg" not in titles(screen) # once, on top
assert titles(screen) == ["Das Kapital", "Ödipus"] # read last first, the rest by upload (never opened last)
card = text_of(screen, "#lib-card")
assert "Zauberberg" in card and "50 %" in card and "Seite 350 von 700" in card and "vor 2 Minuten" in card and "Handy" in card
chips = text_of(screen, "#lib-chips")
assert "Angefangen 2" in chips and "Ungelesen 0" in chips and "Gelesen 1" in chips
assert "Roman 1" in chips and "Politik 1" in chips and chips.index("Roman") < chips.index("Politik") # the user's order
assert "Jacobin" not in " ".join(titles(screen)) # a magazine: the other section
run(world, scenario)
def test_status_search_and_reset(world):
async def scenario(app, screen, pilot):
await pilot.press("2")
assert titles(screen) == ["Zauberberg", "Das Kapital"] and screen.going is None # no card while filtered
await pilot.press("2") # the same chip again: off
assert screen.status == "all"
await pilot.press("4")
assert titles(screen) == ["Ödipus"]
await pilot.press("escape")
assert screen.status == "all"
await pilot.press("/")
assert screen.query_one(Input).has_focus
await pilot.press(*"mann") # typing digits or letters is not a shortcut while the box has focus
assert titles(screen) == ["Zauberberg"]
await pilot.press("escape")
assert screen.q == "" and not screen.query_one(Input).display and len(screen.shown) == 2
run(world, scenario)
def test_sort_and_folder_pickers(world):
async def scenario(app, screen, pilot):
await pilot.press("s")
assert isinstance(app.screen, ChoiceScreen)
await pilot.press("down", "down", "enter") # Autor
assert screen.sort == "author"
assert library_meta.get_extra("sort") == screen.sort
await pilot.press("s", "end", "enter") # Längste zuerst
assert screen.sort == "long"
assert titles(screen)[0] == "Das Kapital"
assert "· 900 S." in str(screen.query_one(ListView).query("Label").first().render())
await pilot.press("o", "down", "enter") # first folder in the user's order: Roman
assert screen.folder == "Roman" and screen.going is None
assert titles(screen) == ["Zauberberg"] # filtered: the card gives way, the book is in the list
await pilot.press("o", "home", "enter") # Alle Ordner
assert screen.folder is None
run(world, scenario)
def test_sections_and_continue_key(world, monkeypatch):
async def scenario(app, screen, pilot):
await pilot.press("m")
assert screen.magazines and titles(screen) == ["Jacobin 24"]
await pilot.press("m")
assert not screen.magazines
opened = []
monkeypatch.setattr(app, "push_screen", lambda s, *a, **k: opened.append(s))
await pilot.press("c")
assert len(opened) == 0 # the book has no file here and no login: it says so instead
assert not isinstance(app.screen, ReaderScreen)
run(world, scenario)
def test_lengths_are_worked_out_for_downloaded_books_when_sorting_by_length(world, epub):
path = epub(["<p>" + "wort " * 2000 + "</p>"], title="Lokal") # ~10000 chars: 6 pages
path.rename(world / "lokal.epub")
async def scenario(app, screen, pilot):
await pilot.press("s", "end", "enter")
for _ in range(60):
await pilot.pause(0.1)
if library_meta.local_pages():
break
await pilot.pause(0.5)
row = next(r for r in screen.rows if r.title == "lokal")
assert library_meta.local_pages() == {row.key: 6} and row.pages == 6
run(world, scenario)

79
tui/tests/test_marks.py Normal file
View file

@ -0,0 +1,79 @@
"""Anchors the way the web reader writes them: block index plus character offsets into the
block's raw text (whitespace as in the file)."""
from diora_tui import blocks, marks
from diora_tui.blocks import collapse_map, load_flat_book
BODY = (
"<h1>Titel</h1>\n"
"<p>\n Erster Satz mit\n Umbruch. <i>Kursiv</i> Ende.\n</p>\n"
"<p>Zweiter Absatz.</p>"
)
def test_collapse_map():
raw = "\n ab cd \n"
norm_of_raw, raw_of_norm, length = collapse_map(raw)
assert length == 5 and "".join(raw[i] if raw[i] != " " else " " for i in raw_of_norm) == "ab cd"
assert raw_of_norm == [3, 4, 5, 8, 9] # a, b, the first space of the run, c, d
assert norm_of_raw[3] == 0 and norm_of_raw[5] == 2 and norm_of_raw[len(raw)] == 5
def test_anchor_counts_in_raw_text_and_resolves_back(epub):
book = load_flat_book(epub([BODY]))
idx = marks.MarkIndex(book)
para = next(i for i, b in enumerate(book.blocks) if b.text.startswith("Erster"))
text = book.blocks[para].text
assert text == "Erster Satz mit Umbruch. Kursiv Ende."
s = text.index("Satz")
e = text.index("Umbruch") + len("Umbruch")
anchor = idx.anchor((para, s), (para, e))
raw = idx.maps(para).raw
assert raw[anchor["startChar"]:anchor["endChar"]] == "Satz mit\n Umbruch" # raw offsets: whitespace as in the file
assert anchor["startBlockIndex"] == anchor["endBlockIndex"] == para and anchor["type"] == "epub"
assert anchor["quote"] == "Satz mit\n Umbruch" and anchor["chapterSrc"].endswith("c0.xhtml")
spans = idx.spans([{"type": "highlight", "color": "green", "anchor": anchor}])
assert list(spans) == [para] and spans[para][0][:2] == (s, e)
def test_range_over_blocks_and_notes_and_bad_anchors(epub):
book = load_flat_book(epub([BODY]))
idx = marks.MarkIndex(book)
first = next(i for i, b in enumerate(book.blocks) if b.text.startswith("Erster"))
last = first + 1
anchor = idx.anchor((first, 7), (last, 6))
spans = idx.spans([{"type": "highlight", "anchor": anchor}])
assert spans[first][0][:2] == (7, len(book.blocks[first].text)) and spans[last][0][:2] == (0, 6)
note = {"type": "note", "anchor": idx.anchor((first, 0), (first, 6))}
assert idx.spans([note])[first][0][:2] == (0, 6)
junk = [{"anchor": {"startBlockIndex": 9999}}, {"anchor": None}, {"anchor": {"startBlockIndex": "x"}}, {}]
assert idx.spans(junk) == {}
def test_a_footnote_marker_with_spaces_cannot_be_addressed_so_the_quote_is_used(epub):
body = '<p>Vor <a href="#n"><sup> 1 </sup></a>nach dem Marker.</p><p id="n">Fuß</p>'
book = load_flat_book(epub([body]))
idx = marks.MarkIndex(book)
assert idx.maps(0) is None
assert idx.anchor((0, 0), (0, 3)) is None # refused: not exact
spans = idx.spans([{"type": "highlight", "anchor": {"startBlockIndex": 0, "endBlockIndex": 0, "startChar": 3, "endChar": 9, "quote": "nach dem"}}])
t = book.blocks[0].text
assert spans[0][0][:2] == (t.index("nach dem"), t.index("nach dem") + 8)
def test_words():
t = "ab cd e"
assert marks.word_starts(t) == [0, 4, 7]
assert marks.next_word(t, 0) == 4 and marks.next_word(t, 7) is None
assert marks.prev_word(t, 4) == 0 and marks.prev_word(t, 0) is None
assert marks.word_at(t, 2) == (4, 6) # in the gap: the next word
def test_an_old_xpath_anchor_is_found_by_its_quote(epub):
book = load_flat_book(epub([BODY]))
idx = marks.MarkIndex(book)
old = {"type": "note", "anchor": {"type": "epub", "chapterSrc": book.chapter_srcs[0], "startXpath": "/p[1]", "startOffset": 3,
"endXpath": "/p[1]", "endOffset": 8, "quote": "Zweiter Absatz"}}
spans = idx.spans([old])
(bi, [(s, e, _)]), = spans.items()
assert book.blocks[bi].text[s:e] == "Zweiter Absatz"
assert idx.spans([{"anchor": {"type": "epub", "chapterSrc": "elsewhere.xhtml", "quote": "x"}}]) == {}

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

@ -0,0 +1,142 @@
"""The reading-position rules of diora-web docs/progress-sync.md (the same cases the server's
books/tests.py, Ops.kt and ops.js hold): the place last read wins."""
import json
import pytest
from diora_tui import progress
from diora_tui.progress import ProgressStore, cmp_progress
T = 1_760_000_000_000 # a plausible ms time
@pytest.fixture
def store(tmp_path):
return ProgressStore(tmp_path / "progress.json")
def test_newer_reading_wins_even_if_behind(store):
store.write("s1", "50:0.0", 0.5, T, now=T)
store.write("s1", "10:0.0", 0.1, T + 1000, now=T + 1000) # read back earlier in the book, later in time
st = store.get("s1")
assert (st.anchor, st.read_at) == ("10:0.0", T + 1000)
assert st.furthest_anchor == "50:0.0" # furthest only grows
def test_older_write_is_queued_but_not_the_continue_point(store):
store.write("s1", "50:0.0", 0.5, T, now=T)
took = store.write("s1", "10:0.0", 0.1, T - 5000, now=T) # an offline catch-up from earlier
assert not took
assert store.get("s1").anchor == "50:0.0"
assert store.get("s1").pending["anchor"] == "50:0.0" # the later read stays the one to send
def test_clock_offset_and_nothing_from_the_future(store):
store.offset = 5000 # the server is 5 s ahead of this machine
store.write("s1", "3:0.0", 0.1, T, now=T)
assert store.get("s1").read_at == T + 5000
store.write("s1", "4:0.0", 0.2, T + 10 * 3600_000, now=T + 100) # a read_at hours ahead of the clock
assert store.get("s1").read_at == T + 100 + 5000
def test_force_takes_the_place_with_the_time_of_now(store):
store.write("s1", "9:0.0", 0.9, T, now=T)
store.write("s1", "2:0.0", 0.2, T - 1000, force=True, now=T + 50)
st = store.get("s1")
assert (st.anchor, st.read_at) == ("2:0.0", T + 50)
assert st.pending["force"] is True
def test_force_stays_forced_when_reading_goes_on(store):
store.write("s1", "2:0.0", 0.2, T, force=True, now=T)
store.write("s1", "3:0.0", 0.3, T + 1000, now=T + 1000)
assert store.get("s1").pending["force"] is True
assert store.get("s1").pending["anchor"] == "3:0.0"
def test_not_a_server_book_queues_nothing(store):
store.write("abc", "3:0.0", 0.3, T, queue=False, now=T)
assert store.get("abc").pending is None and store.get("abc").anchor == "3:0.0"
assert store.pending_keys() == []
def server_entry(anchor, read_at, device="Browser", furthest=None):
return {"position_anchor": anchor, "scroll_fraction": 0.5, "read_at": read_at, "device": device,
"furthest_anchor": furthest or anchor, "furthest_fraction": 0.5}
def test_pull_shows_the_servers_place(store):
store.apply_server("s1", server_entry("20:0.5", T, "Handy"))
st = store.get("s1")
assert (st.anchor, st.read_at, st.device) == ("20:0.5", T, "Handy")
def test_pull_applies_a_newer_pending_on_top_and_drops_an_older_one(store):
store.write("s1", "7:0.0", 0.1, T + 2000, now=T + 2000)
store.apply_server("s1", server_entry("20:0.5", T)) # the server's is older: ours shows
assert store.get("s1").anchor == "7:0.0"
store.apply_server("s1", server_entry("30:0.0", T + 9000)) # the server's is newer: it shows, ours is still sent
st = store.get("s1")
assert st.anchor == "30:0.0" and st.pending["anchor"] == "7:0.0"
def test_sent_clears_the_queue_unless_something_newer_queued(store):
store.write("s1", "7:0.0", 0.1, T, now=T)
body = dict(store.get("s1").pending)
store.write("s1", "8:0.0", 0.2, T + 1000, now=T + 1000) # read on while the request was out
store.sent("s1", body, {"stored": True, "read_at": T, "position_anchor": "7:0.0", "server_now": T})
assert store.get("s1").pending["anchor"] == "8:0.0"
body = dict(store.get("s1").pending)
store.sent("s1", body, {"stored": True, "read_at": T + 1000, "position_anchor": "8:0.0", "scroll_fraction": 0.2})
st = store.get("s1")
assert st.pending is None and st.echo_read_at == T + 1000 and st.anchor == "8:0.0"
def test_refused_write_takes_the_servers_newer_place(store):
store.write("s1", "7:0.0", 0.1, T, now=T)
body = dict(store.get("s1").pending)
store.sent("s1", body, {"stored": False, "read_at": T + 5000, "position_anchor": "40:0.0", "device": "Handy"})
st = store.get("s1")
assert (st.anchor, st.device, st.echo_read_at) == ("40:0.0", "Handy", None)
def test_server_clock_sets_the_offset(store):
store.set_server_now(T + 7000, T)
assert store.offset == 7000
store.set_server_now(None)
store.set_server_now(12, T) # not a plausible time
assert store.offset == 7000
def test_survives_a_restart(tmp_path):
a = ProgressStore(tmp_path / "p.json")
a.set_server_now(T + 100, T)
a.write("s1", "7:0.250000", 0.3, T, now=T)
b = ProgressStore(tmp_path / "p.json")
assert b.offset == 100 and b.get("s1").anchor == "7:0.250000" and b.get("s1").pending["read_at"] == T
def test_reads_the_old_furthest_wins_file(tmp_path):
(tmp_path / "p.json").write_text(json.dumps({"abc": {"anchor": "5:0.5", "updated_at": 1760000000.0}}))
st = ProgressStore(tmp_path / "p.json").get("abc")
assert st.anchor == "5:0.5" and st.read_at == 1760000000000 and st.pending is None
@pytest.mark.parametrize("a,b,sign", [
(("5:0.1", 0), ("4:0.9", 0), 1),
(("5:0.5", 0), ("5:0.4", 0), 1),
(("4:0.9", 0), ("5:0.1", 0), -1),
(("", 0.7), ("5:0.1", 0.2), 1), # no anchor on one side: the fraction decides
(("", 0.1), ("", 0.1), 0),
])
def test_cmp_progress(a, b, sign):
r = cmp_progress(a[0], a[1], b[0], b[1])
assert (r > 0) - (r < 0) == sign
def test_plausible_ms():
assert progress.plausible_ms(T) == T
assert progress.plausible_ms(5) is None
assert progress.plausible_ms(True) is None
assert progress.plausible_ms(float("nan")) is None
assert progress.plausible_ms(None) is None

View file

@ -0,0 +1,137 @@
"""Marking text, bookmarks and the list, in the reader."""
import asyncio
import pytest
from textual.widgets import Input, ListView
from diora_tui import annotations, epub as epub_mod, progress
from diora_tui.annotation_screens import AnnotationsScreen, NoteScreen
from diora_tui.app import DioraTuiApp
from diora_tui.library_screen import ChoiceScreen
from diora_tui.reader_screen import ReaderScreen
@pytest.fixture
def world(tmp_path, monkeypatch):
monkeypatch.setattr(progress, "_default", progress.ProgressStore(tmp_path / "p.json"))
store = annotations.AnnotationStore(tmp_path / "ann")
monkeypatch.setattr(annotations, "_default", store)
lib = tmp_path / "lib"
lib.mkdir()
return lib, store
def book(epub):
body = "<h1>Anfang</h1>\n" + "\n".join(f"<p>\n Absatz {i} eins zwei drei vier fünf sechs sieben.\n</p>" for i in range(30))
return epub([body], title="Marken")
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 run(lib, epub, scenario):
path = book(epub)
async def go():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
reader = await open_reader(app, pilot, path)
await scenario(app, reader, pilot, epub_mod._book_id(path))
asyncio.run(go())
def painted_with(reader, color_hex):
wanted = color_hex.lstrip("#").lower()
return any(seg.style and seg.style.bgcolor and seg.style.bgcolor.get_truecolor().hex.lstrip("#").lower() == wanted
for strip in reader.book_layout.row_strips for seg in strip)
def test_mark_text_then_list_edit_and_delete(world, epub):
lib, store = world
async def scenario(app, reader, pilot, key):
await pilot.press("v")
assert reader._sel is not None and reader._sel["phase"] == "start"
await pilot.press("l", "l") # two words on
await pilot.press("enter") # start
assert reader._sel["phase"] == "end"
await pilot.press("l", "l", "l")
await pilot.press("enter") # end: pick a colour
assert isinstance(app.screen, ChoiceScreen)
await pilot.press("down", "enter") # Grün
await pilot.pause(0.2)
assert reader._sel is None
hs = store.get(key).highlights
assert len(hs) == 1 and hs[0]["color"] == "green" and hs[0]["type"] == "highlight"
a = hs[0]["anchor"]
assert a["startBlockIndex"] == a["endBlockIndex"] and a["quote"] and a["startChar"] < a["endChar"]
assert a["quote"] == reader._marks.maps(a["startBlockIndex"]).raw[a["startChar"]:a["endChar"]]
assert painted_with(reader, "#2ecc71")
assert store.get(key).dirty["highlights"]
await pilot.press("b")
bm = store.get(key).bookmarks
assert len(bm) == 1 and bm[0]["anchor"].endswith("c0.xhtml") and bm[0]["label"] == "c0"
await pilot.press("a")
assert isinstance(app.screen, AnnotationsScreen)
lv = app.screen.query_one(ListView)
assert len(app.screen.rows) == 2 and app.screen.rows[0].kind == "bookmarks"
await pilot.press("down", "e") # edit the note of the highlight
assert isinstance(app.screen, NoteScreen)
app.screen.query_one(Input).value = "klug"
await pilot.press("enter")
await pilot.pause(0.2)
assert store.get(key).highlights[0]["note"] == "klug"
await pilot.press("d") # delete the highlight
await pilot.pause(0.2)
assert store.get(key).highlights == [] and not painted_with(reader, "#2ecc71")
await pilot.press("escape")
assert isinstance(app.screen, ReaderScreen)
run(lib, epub, scenario)
def test_note_on_a_word_and_escape_leaves_the_selection(world, epub):
lib, store = world
async def scenario(app, reader, pilot, key):
await pilot.press("v", "l")
await pilot.press("escape")
assert reader._sel is None and isinstance(app.screen, ReaderScreen)
assert store.get(key).highlights == []
await pilot.press("v", "l", "n") # n while marking: a freeform note on this word
assert isinstance(app.screen, NoteScreen)
app.screen.query_one(Input).value = "merken"
await pilot.press("enter")
await pilot.pause(0.2)
h = store.get(key).highlights
assert len(h) == 1 and h[0]["type"] == "note" and h[0]["note"] == "merken" and h[0]["color"] is None
a = h[0]["anchor"]
assert reader._marks.maps(a["startBlockIndex"]).raw[a["startChar"]:a["endChar"]] == a["quote"]
run(lib, epub, scenario)
def test_jump_from_the_list_is_not_reading(world, epub):
lib, store = world
async def scenario(app, reader, pilot, key):
store.add(key, "bookmarks", annotations.new_bookmark("c0", "OEBPS/c0.xhtml", 0.5))
await pilot.press("a", "enter")
await pilot.pause(0.3)
assert isinstance(app.screen, ReaderScreen)
reader._save_progress()
assert progress.default().get(key) is None # nothing written by the jump
run(lib, epub, scenario)

View file

@ -0,0 +1,129 @@
"""The reader saves a position only when the reader moved it, and offers — never applies — a
newer position from another device (docs/progress-sync.md)."""
import asyncio
import pytest
from diora_tui import blocks, config as config_mod, epub as epub_mod, layout, progress
from diora_tui.app import DioraTuiApp
from diora_tui.config import RemoteConfig
from diora_tui.reader_screen import JumpOfferScreen, ReaderScreen
T = 1_760_000_000_000
@pytest.fixture
def store(tmp_path, monkeypatch):
s = progress.ProgressStore(tmp_path / "p.json")
monkeypatch.setattr(progress, "_default", s)
return s
def book(epub, name="Testbuch"):
body = "<h1>Anfang</h1>" + "".join(f"<p>Absatz {i} " + "wort " * 40 + "</p>" for i in range(80))
return epub([body], title=name)
async def open_reader(app, pilot, path):
app.push_screen(ReaderScreen(path))
for _ in range(80):
await pilot.pause(0.1)
s = app.screen
if isinstance(s, ReaderScreen) and s.book is not None and s._last_anchor is not None:
await pilot.pause(0.2)
return s
raise AssertionError("reader did not load")
def test_only_the_readers_moves_are_saved(epub, store, tmp_path):
path = book(epub)
lib = tmp_path / "lib"
lib.mkdir()
async def run():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
reader = await open_reader(app, pilot, path)
key = epub_mod._book_id(path)
reader._save_progress()
assert store.get(key) is None # opened and looked at: nothing written
await pilot.press(*["j"] * 40)
reader._save_progress()
st = store.get(key)
assert st is not None and blocks.parse_anchor(st.anchor)[0] > 0
assert st.pending is None # not a server book: nothing queued
assert 0 < st.fraction < 1 and abs(st.read_at - progress.now_ms()) < 5000
first = st.read_at
await pilot.resize_terminal(70, 24) # re-wrapped by the program, not read
await pilot.pause(0.3)
reader._save_progress()
assert store.get(key).read_at == first
reader._save_progress() # unchanged: no new time either
assert store.get(key).read_at == first
asyncio.run(run())
def test_reopening_returns_to_the_saved_place_without_writing(epub, store, tmp_path):
path = book(epub)
key = epub_mod._book_id(path)
lib = tmp_path / "lib"
lib.mkdir()
async def run():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
reader = await open_reader(app, pilot, path)
await pilot.press(*["j"] * 60)
reader._save_progress()
saved = store.get(key)
app.pop_screen()
await pilot.pause()
reader = await open_reader(app, pilot, path)
assert reader._book_view.scroll_y > 30
reader._save_progress()
assert store.get(key).read_at == saved.read_at # restored, not "read" again
asyncio.run(run())
@pytest.mark.parametrize("answer,jumps", [("y", True), ("n", False)])
def test_newer_position_from_elsewhere_is_offered(epub, store, tmp_path, monkeypatch, answer, jumps):
lib = tmp_path / "lib"
lib.mkdir()
path = book(epub, "Server")
target = lib / "0042 - Server.epub"
path.rename(target)
monkeypatch.setattr(config_mod, "load", lambda: RemoteConfig("http://x", "t", "k"))
store.apply_server("s42", {"position_anchor": "5:0.0", "scroll_fraction": 0.05, "read_at": T, "device": "Handy"})
async def run():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
reader = await open_reader(app, pilot, target)
y0 = reader._book_view.scroll_y
# another device read further on, later
store.apply_server("s42", {"position_anchor": "60:0.0", "scroll_fraction": 0.8, "read_at": T + 60_000, "device": "Handy"})
reader._offer_newer()
await pilot.pause()
assert isinstance(app.screen, JumpOfferScreen)
assert reader._book_view.scroll_y == y0 # asked, not moved
await pilot.press(answer)
await pilot.pause(0.3)
assert isinstance(app.screen, ReaderScreen)
moved = reader._book_view.scroll_y > y0 + 20
assert moved == jumps
reader._offer_newer() # the same state is not offered twice
await pilot.pause()
assert isinstance(app.screen, ReaderScreen)
reader._save_progress() # jumping isn't reading: still the server's time
assert store.get("s42").read_at == T + 60_000
asyncio.run(run())

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

@ -0,0 +1,72 @@
"""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
def test_encrypt_roundtrip_matches_decrypt():
import base64
import os
from diora_tui import crypto
key = base64.b64encode(os.urandom(32)).decode()
iv, ct = crypto.encrypt(key, "Zäune".encode())
assert len(bytes.fromhex(iv)) == 12 and crypto.decrypt(key, iv, ct).decode() == "Zäune"
with pytest.raises(crypto.DecryptError):
crypto.decrypt(base64.b64encode(os.urandom(32)).decode(), iv, ct)

View file

@ -0,0 +1,38 @@
"""Compare the TUI's block list with the web reader's (web_blocks.cjs output).
python compare_blocks.py web.json [name-part ...]
What must match is the number and tag of the blocks (they make the position anchors
shared by all clients). Text differences are reported too: they are cosmetic, but they
show where the TUI's HTML parser (lxml) reads a book differently from Chrome's.
Exit status 1 when a book's block list differs in length or tags.
"""
import json
import os
import sys
from pathlib import Path
from diora_tui.blocks import load_flat_book
def main(ref_file, only):
ref = json.load(open(ref_file, encoding="utf-8"))
failed = False
for file, web in ref.items():
if only and not any(o in file for o in only):
continue
book = load_flat_book(Path(file))
wb, tb = web["blocks"], book.blocks
structure = len(wb) == len(tb) and all(w["tag"] == t.tag for w, t in zip(wb, tb))
texts = [i for i, (w, t) in enumerate(zip(wb, tb)) if w["text"] != t.text]
chars = [i for i, (w, t) in enumerate(zip(wb, tb)) if (w["start"], w["len"]) != (t.char_start, t.char_len)]
total_ok = web["total"] == book.total_chars
failed |= not structure
print(f"{'OK ' if structure else 'FAIL'} {os.path.basename(file)[:50]}: web {len(wb)} / tui {len(tb)} blocks, "
f"{len(texts)} with other text" + (f" (first: {texts[0]})" if texts else "") +
f"; chars {'equal' if not chars and total_ok else f'{len(chars)} blocks differ, total web {web['total']} / tui {book.total_chars}'}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2:]))

View file

@ -0,0 +1,42 @@
// Does the web reader find a highlight made in the TUI where the TUI put it? Runs the web's own
// resolveAnchorRange (72-highlights.js) on the book rendered like the reader renders it, and
// returns the text of the range it finds for each anchor.
//
// node web_anchors.cjs <diora-web checkout> <book.epub> <anchors.json> <out.json>
//
// anchors.json: a list of highlight objects ({anchor: {...}, ...}). Same setup as web_blocks.cjs.
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
(async () => {
const [web, epub, anchorsFile, out] = process.argv.slice(2);
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(() => {
window.$ = (id) => document.getElementById(id);
window.currentPdfDoc = null;
});
await page.addScriptTag({ path: path.join(web, 'static/js/jszip.min.js') });
for (const f of ['40-epub.js', '44-books.js', '72-highlights.js']) {
try { await page.addScriptTag({ path: path.join(web, 'static/js/app', f) }); }
catch (e) { console.error('loading', f, e.message); }
}
const items = JSON.parse(fs.readFileSync(anchorsFile, 'utf-8'));
const b64 = fs.readFileSync(epub).toString('base64');
const result = await page.evaluate(async ({ b64, items }) => {
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const book = await parseEpub(bin.buffer);
const el = document.createElement('div');
el.id = 'reader-content';
el.innerHTML = book.chapters.join('');
document.body.appendChild(el);
return items.map((h) => {
const r = resolveAnchorRange(h);
return r ? r.toString() : null;
});
}, { b64, items });
fs.writeFileSync(out, JSON.stringify(result));
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });

View file

@ -0,0 +1,55 @@
// The web reader's own block list for EPUBs, from the real app code (static/js/app/40-epub.js
// parseEpub + the reader's block selector) in headless Chromium. Reference for compare_blocks.py.
//
// node web_blocks.cjs <diora-web checkout> <out.json> book.epub [more.epub ...]
//
// Needs playwright-core (e.g. NODE_PATH=<diora-web>/node_modules) and a Chromium
// (PLAYWRIGHT_BROWSERS_PATH as for the e2e tests).
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
(async () => {
const [web, out, ...books] = process.argv.slice(2);
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('about:blank');
await page.addScriptTag({ path: path.join(web, 'static/js/jszip.min.js') });
await page.addScriptTag({ path: path.join(web, 'static/js/app/40-epub.js') });
const result = {};
for (const file of books) {
const b64 = fs.readFileSync(file).toString('base64');
result[file] = await page.evaluate(async (b64) => {
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const book = await parseEpub(bin.buffer);
const el = document.createElement('div');
el.innerHTML = book.chapters.join('');
document.body.appendChild(el);
const SEL = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; // EPUB_BLOCK_SELECTOR, 44-books.js
const blocks = Array.from(el.querySelectorAll(SEL));
const chapters = Array.from(el.querySelectorAll('[data-epub-src]'));
// _bookCharIndex (44-books.js): offset of each block's first text node among all text nodes
const offsets = new Map();
let pos = 0;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let n; (n = walker.nextNode());) { offsets.set(n, pos); pos += n.nodeValue.length; }
const starts = [];
blocks.forEach((b, i) => {
const first = document.createTreeWalker(b, NodeFilter.SHOW_TEXT).nextNode();
starts[i] = first ? offsets.get(first) : (i ? starts[i - 1] + blocks[i - 1].textContent.length : 0);
});
const res = blocks.map((b, i) => ({
start: starts[i],
tag: b.tagName.toLowerCase(),
text: b.textContent.replace(/\s+/g, ' ').trim(),
len: b.textContent.length,
ch: chapters.findIndex((c) => c.contains(b)),
}));
el.remove();
return { title: book.title, chapters: chapters.length, total: pos || 1, blocks: res };
}, b64);
console.error(path.basename(file), result[file].blocks.length, 'blocks');
}
fs.writeFileSync(out, JSON.stringify(result));
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });

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

@ -0,0 +1,137 @@
"""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)
# folders, length and the folder order come from the encrypted meta and prefs
import json # noqa: E402
import requests # noqa: E402
H = {"Authorization": f"Bearer {token}"}
raw_books = {b["id"]: b for b in api.fetch_books(server, token)[0]}
b0 = raw_books[info["server_id"]]
meta = json.loads(crypto.decrypt(key_b64, b0["meta_iv"], b0["meta_ct"]))
meta.update(folders=["Smoke B", "Smoke A"], pages=123)
iv, ct = crypto.encrypt(key_b64, json.dumps(meta).encode())
check("meta written", requests.post(f"{server}/books/{info['server_id']}/meta/", json={"meta_ct": ct, "meta_iv": iv}, headers=H).ok)
iv, ct = crypto.encrypt(key_b64, json.dumps({"folder_order": ["Smoke B", "Smoke A"]}).encode())
check("prefs written", requests.post(f"{server}/books/prefs/", json={"prefs_ct": ct, "prefs_iv": iv}, headers=H).ok)
remote.sync_library(lib, cfg)
from diora_tui import library, library_meta # noqa: E402
e = library_meta.remote_entries()[skey]
check("folders and length pulled from the meta", e["folders"] == ["Smoke B", "Smoke A"] and e["pages"] == 123, str(e))
check("folder order pulled from the prefs", library_meta.get_extra("folder_order") == ["Smoke B", "Smoke A"])
rows = library.build_rows([path], library_meta.remote_entries(), {}, {}, epub._book_id)
check("a row carries them", [r for r in rows if r.key == skey][0].folders == ["Smoke B", "Smoke A"])
# 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"])
# highlights and bookmarks: ours go up merged with another device's, in the web's format
from diora_tui import annotations as an # noqa: E402
astore = an.default()
sid = info["server_id"]
theirs = [{"id": "web-1", "type": "highlight", "anchor": an.make_anchor("c", 3, 0, 3, 4, "abcd"), "color": "red", "note": "", "createdAt": "2026-10-26T10:00:00.000Z"}]
iv, ct = crypto.encrypt(key_b64, json.dumps(theirs).encode())
check("another device wrote a highlight", requests.post(f"{server}/books/{sid}/highlights/", json={"ct": ct, "iv": iv}, headers=H).ok)
remote.pull_annotations(cfg, sid, skey)
check("pulled", [h["id"] for h in astore.get(skey).highlights] == ["web-1"])
mine = an.new_highlight(an.make_anchor("c", 5, 2, 5, 9, "efghijk"), "green", "meine Notiz")
astore.add(skey, "highlights", mine)
astore.add(skey, "bookmarks", an.new_bookmark("c", "c", 0.5))
# meanwhile the other device adds one more
theirs.append({**theirs[0], "id": "web-2", "color": "blue"})
iv, ct = crypto.encrypt(key_b64, json.dumps(theirs).encode())
requests.post(f"{server}/books/{sid}/highlights/", json={"ct": ct, "iv": iv}, headers=H)
check("pushed", remote.push_annotations(cfg) == 2 and astore.dirty_keys() == [])
got = an.decode(key_b64, requests.get(f"{server}/books/{sid}/highlights/", headers=H).json())
check("the server holds both devices' highlights", [h["id"] for h in got] == ["web-1", "web-2", mine["id"]] and got[2]["note"] == "meine Notiz")
bms = an.decode(key_b64, requests.get(f"{server}/books/{sid}/bookmarks/", headers=H).json())
check("and the bookmark", len(bms) == 1 and bms[0]["label"] == "c")
astore.delete(skey, "highlights", "web-1")
remote.push_annotations(cfg)
got = an.decode(key_b64, requests.get(f"{server}/books/{sid}/highlights/", headers=H).json())
check("a deletion goes up too", [h["id"] for h in got] == ["web-2", mine["id"]])
print("all good")