Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6047ae44c4 | |||
| e246a32212 | |||
| 695037fb9c | |||
| 51b7316398 | |||
| edc9c6d4c3 | |||
| 7095e176b7 | |||
| 58f59476d8 | |||
|
|
e9683fbdbd | ||
|
|
20d04361bc | ||
|
|
b1a04d2a65 | ||
|
|
db3f520632 | ||
|
|
50f711831c | ||
|
|
a4b10ae265 | ||
|
|
3fcb74631c | ||
|
|
f63bd1f879 |
43 changed files with 5396 additions and 0 deletions
191
tui/README.md
Normal file
191
tui/README.md
Normal 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
36
tui/TODO.md
Normal 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.
|
||||
1
tui/diora_tui/__init__.py
Normal file
1
tui/diora_tui/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""
|
||||
4
tui/diora_tui/__main__.py
Normal file
4
tui/diora_tui/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from diora_tui.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
179
tui/diora_tui/annotation_screens.py
Normal file
179
tui/diora_tui/annotation_screens.py
Normal 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)
|
||||
258
tui/diora_tui/annotations.py
Normal file
258
tui/diora_tui/annotations.py
Normal 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
84
tui/diora_tui/api.py
Normal 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
103
tui/diora_tui/app.py
Normal 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
161
tui/diora_tui/app.tcss
Normal 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
354
tui/diora_tui/blocks.py
Normal 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
|
||||
63
tui/diora_tui/book_view.py
Normal file
63
tui/diora_tui/book_view.py
Normal 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
52
tui/diora_tui/cache.py
Normal 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
64
tui/diora_tui/config.py
Normal 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
59
tui/diora_tui/crypto.py
Normal 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
35
tui/diora_tui/epub.py
Normal 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
108
tui/diora_tui/layout.py
Normal 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
210
tui/diora_tui/library.py
Normal 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
|
||||
75
tui/diora_tui/library_meta.py
Normal file
75
tui/diora_tui/library_meta.py
Normal 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)
|
||||
388
tui/diora_tui/library_screen.py
Normal file
388
tui/diora_tui/library_screen.py
Normal 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
109
tui/diora_tui/login.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""`diora-tui login`: sign in once like the apps do and keep what a sync needs.
|
||||
|
||||
The password goes through the web login form (CSRF + session) only to read the account's
|
||||
existing API token off the settings page, and to derive the book key (PBKDF2, as the web
|
||||
client's "unlock with password"). The password is not stored; the session is logged out again.
|
||||
A new token is never created here: that would sign out the apps using the current one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import getpass
|
||||
import html
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from . import api, crypto
|
||||
from .config import RemoteConfig
|
||||
|
||||
DEFAULT_SERVER = "https://diora.creamfresh.xyz"
|
||||
|
||||
_CSRF = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"')
|
||||
_TOKEN = re.compile(r'<code style="user-select:all[^"]*">\s*([A-Za-z0-9_-]+)\s*</code>')
|
||||
_USERNAME = re.compile(r'<meta name="username" content="([^"]*)">')
|
||||
|
||||
|
||||
class LoginError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def web_login(server: str, username: str, password: str) -> tuple[str, str]:
|
||||
"""(API token, canonical user name) via the web login."""
|
||||
s = requests.Session()
|
||||
s.headers["User-Agent"] = api.USER_AGENT
|
||||
|
||||
def get(path):
|
||||
return s.get(server + path, headers={"Referer": server + path}, allow_redirects=False, timeout=30)
|
||||
|
||||
def post(path, form):
|
||||
return s.post(server + path, data=form, headers={"Referer": server + path, "Origin": server},
|
||||
allow_redirects=False, timeout=30)
|
||||
|
||||
try:
|
||||
page = get("/accounts/login/")
|
||||
m = _CSRF.search(page.text)
|
||||
if page.status_code != 200 or not m:
|
||||
raise LoginError(f"Login-Seite nicht lesbar ({page.status_code}).")
|
||||
r = post("/accounts/login/", {"csrfmiddlewaretoken": m.group(1), "username": username, "password": password})
|
||||
if r.status_code == 200:
|
||||
raise LoginError("Benutzername oder Passwort falsch.")
|
||||
if r.status_code != 302:
|
||||
raise LoginError(f"Anmeldung fehlgeschlagen ({r.status_code}).")
|
||||
try:
|
||||
t = _TOKEN.search(get("/accounts/settings/").text)
|
||||
if not t:
|
||||
raise LoginError("Kein API-Token gefunden. Melde dich einmal in einer diora-App an, dann noch mal.")
|
||||
name = _USERNAME.search(get("/").text)
|
||||
return t.group(1), html.unescape(name.group(1)) if name else username
|
||||
finally:
|
||||
c = _CSRF.search(get("/").text)
|
||||
if c:
|
||||
post("/accounts/logout/", {"csrfmiddlewaretoken": c.group(1)})
|
||||
except requests.RequestException as e:
|
||||
raise LoginError(f"Verbindung zu {server} fehlgeschlagen: {e}") from e
|
||||
|
||||
|
||||
def _books_open(server: str, token: str, key_b64: str) -> tuple[int, int]:
|
||||
"""(books, books the key opens)."""
|
||||
books, _, _ = api.fetch_books(server, token)
|
||||
ok = 0
|
||||
for b in books:
|
||||
try:
|
||||
crypto.decrypt(key_b64, b["meta_iv"], b["meta_ct"])
|
||||
ok += 1
|
||||
except crypto.DecryptError:
|
||||
pass
|
||||
return len(books), ok
|
||||
|
||||
|
||||
def login(server: str | None = None, *, paste_key: bool = False) -> RemoteConfig:
|
||||
server = (server or DEFAULT_SERVER).rstrip("/")
|
||||
username = input("diora-Benutzername: ").strip()
|
||||
password = getpass.getpass("Passwort (wird nicht gespeichert, nicht angezeigt): ")
|
||||
if not username or not password:
|
||||
raise LoginError("Abgebrochen.")
|
||||
token, username = web_login(server, username, password)
|
||||
key = "" if paste_key else crypto.derive_key_b64(username, password)
|
||||
del password
|
||||
|
||||
if key:
|
||||
total, ok = _books_open(server, token, key)
|
||||
if total and not ok:
|
||||
print("Der Schlüssel aus dem Passwort öffnet die Bücher nicht (Passwort seit dem Hochladen geändert?).")
|
||||
key = ""
|
||||
if not key:
|
||||
key = getpass.getpass("Schlüssel einfügen (diora: Einstellungen → Schlüssel kopieren, Base64; leer = abbrechen): ").strip()
|
||||
if not key:
|
||||
raise LoginError("Abgebrochen, nichts gespeichert.")
|
||||
try:
|
||||
if len(base64.b64decode(key)) != 32:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise LoginError("Das ist kein gültiger Schlüssel (32 Byte, Base64).") from None
|
||||
total, ok = _books_open(server, token, key)
|
||||
if total and not ok:
|
||||
raise LoginError("Auch dieser Schlüssel passt nicht. Nichts gespeichert.")
|
||||
print(f"Angemeldet als {username} ({total} Bücher).")
|
||||
return RemoteConfig(server_url=server, api_token=token, enc_key_b64=key, username=username)
|
||||
164
tui/diora_tui/marks.py
Normal file
164
tui/diora_tui/marks.py
Normal 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
30
tui/diora_tui/paths.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Where the TUI keeps its files. `DIORA_TUI_HOME` moves all of it into one folder (tests, the
|
||||
smoke script and anyone who wants a second account side by side use it); platformdirs' own
|
||||
answer on Windows doesn't follow the LOCALAPPDATA/APPDATA environment variables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_cache_dir, user_config_dir, user_data_dir
|
||||
|
||||
|
||||
def _home() -> Path | None:
|
||||
h = os.environ.get("DIORA_TUI_HOME")
|
||||
return Path(h) if h else None
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
h = _home()
|
||||
return h / "data" if h else Path(user_data_dir("diora-tui", "diora"))
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
h = _home()
|
||||
return h / "config" if h else Path(user_config_dir("diora-tui", "diora"))
|
||||
|
||||
|
||||
def cache_dir() -> Path:
|
||||
h = _home()
|
||||
return h / "cache" if h else Path(user_cache_dir("diora-tui", "diora"))
|
||||
231
tui/diora_tui/progress.py
Normal file
231
tui/diora_tui/progress.py
Normal 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
|
||||
703
tui/diora_tui/reader_screen.py
Normal file
703
tui/diora_tui/reader_screen.py
Normal 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
193
tui/diora_tui/remote.py
Normal 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
|
||||
55
tui/diora_tui/secretstore.py
Normal file
55
tui/diora_tui/secretstore.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Protect the account's token and book key at rest.
|
||||
|
||||
Windows: DPAPI (CryptProtectData), bound to this Windows user on this machine, as
|
||||
tools/diora_client.py in diora-android does. Elsewhere there is no such store in the standard
|
||||
library: the blob is plain base64 in a 0600 file (the same exposure the web client has with
|
||||
its key in localStorage), marked as such so a later version can move it into a keyring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import sys
|
||||
|
||||
_ENTROPY = b"diora-tui"
|
||||
|
||||
|
||||
def _dpapi(fn_name: str, data: bytes) -> bytes:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
|
||||
class Blob(ctypes.Structure):
|
||||
_fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
|
||||
|
||||
def blob(b: bytes):
|
||||
buf = ctypes.create_string_buffer(b, len(b))
|
||||
return Blob(len(b), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))), buf
|
||||
|
||||
inp, _k1 = blob(data)
|
||||
ent, _k2 = blob(_ENTROPY)
|
||||
out = Blob()
|
||||
fn = getattr(ctypes.windll.crypt32, fn_name)
|
||||
if not fn(ctypes.byref(inp), None, ctypes.byref(ent), None, None, 1, ctypes.byref(out)): # 1: no UI
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
return ctypes.string_at(out.pbData, out.cbData)
|
||||
finally:
|
||||
ctypes.windll.kernel32.LocalFree(out.pbData)
|
||||
|
||||
|
||||
def protect(data: bytes) -> str:
|
||||
if sys.platform == "win32":
|
||||
return "dpapi:" + base64.b64encode(_dpapi("CryptProtectData", data)).decode()
|
||||
return "plain:" + base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def unprotect(blob: str) -> bytes:
|
||||
kind, _, b64 = blob.partition(":")
|
||||
raw = base64.b64decode(b64)
|
||||
if kind == "dpapi":
|
||||
if sys.platform != "win32":
|
||||
raise ValueError("Zugangsdaten sind mit Windows-DPAPI geschützt und nur dort lesbar")
|
||||
return _dpapi("CryptUnprotectData", raw)
|
||||
if kind == "plain":
|
||||
return raw
|
||||
raise ValueError("unbekanntes Format der gespeicherten Zugangsdaten")
|
||||
44
tui/diora_tui/statusbar.py
Normal file
44
tui/diora_tui/statusbar.py
Normal 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
26
tui/pyproject.toml
Normal 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
55
tui/tests/conftest.py
Normal 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)
|
||||
142
tui/tests/test_annotations.py
Normal file
142
tui/tests/test_annotations.py
Normal 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
|
||||
66
tui/tests/test_app_smoke.py
Normal file
66
tui/tests/test_app_smoke.py
Normal 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
53
tui/tests/test_blocks.py
Normal 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
121
tui/tests/test_library.py
Normal 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
|
||||
148
tui/tests/test_library_screen.py
Normal file
148
tui/tests/test_library_screen.py
Normal 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
79
tui/tests/test_marks.py
Normal 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
142
tui/tests/test_progress.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""The reading-position rules of diora-web docs/progress-sync.md (the same cases the server's
|
||||
books/tests.py, Ops.kt and ops.js hold): the place last read wins."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from diora_tui import progress
|
||||
from diora_tui.progress import ProgressStore, cmp_progress
|
||||
|
||||
T = 1_760_000_000_000 # a plausible ms time
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return ProgressStore(tmp_path / "progress.json")
|
||||
|
||||
|
||||
def test_newer_reading_wins_even_if_behind(store):
|
||||
store.write("s1", "50:0.0", 0.5, T, now=T)
|
||||
store.write("s1", "10:0.0", 0.1, T + 1000, now=T + 1000) # read back earlier in the book, later in time
|
||||
st = store.get("s1")
|
||||
assert (st.anchor, st.read_at) == ("10:0.0", T + 1000)
|
||||
assert st.furthest_anchor == "50:0.0" # furthest only grows
|
||||
|
||||
|
||||
def test_older_write_is_queued_but_not_the_continue_point(store):
|
||||
store.write("s1", "50:0.0", 0.5, T, now=T)
|
||||
took = store.write("s1", "10:0.0", 0.1, T - 5000, now=T) # an offline catch-up from earlier
|
||||
assert not took
|
||||
assert store.get("s1").anchor == "50:0.0"
|
||||
assert store.get("s1").pending["anchor"] == "50:0.0" # the later read stays the one to send
|
||||
|
||||
|
||||
def test_clock_offset_and_nothing_from_the_future(store):
|
||||
store.offset = 5000 # the server is 5 s ahead of this machine
|
||||
store.write("s1", "3:0.0", 0.1, T, now=T)
|
||||
assert store.get("s1").read_at == T + 5000
|
||||
store.write("s1", "4:0.0", 0.2, T + 10 * 3600_000, now=T + 100) # a read_at hours ahead of the clock
|
||||
assert store.get("s1").read_at == T + 100 + 5000
|
||||
|
||||
|
||||
def test_force_takes_the_place_with_the_time_of_now(store):
|
||||
store.write("s1", "9:0.0", 0.9, T, now=T)
|
||||
store.write("s1", "2:0.0", 0.2, T - 1000, force=True, now=T + 50)
|
||||
st = store.get("s1")
|
||||
assert (st.anchor, st.read_at) == ("2:0.0", T + 50)
|
||||
assert st.pending["force"] is True
|
||||
|
||||
|
||||
def test_force_stays_forced_when_reading_goes_on(store):
|
||||
store.write("s1", "2:0.0", 0.2, T, force=True, now=T)
|
||||
store.write("s1", "3:0.0", 0.3, T + 1000, now=T + 1000)
|
||||
assert store.get("s1").pending["force"] is True
|
||||
assert store.get("s1").pending["anchor"] == "3:0.0"
|
||||
|
||||
|
||||
def test_not_a_server_book_queues_nothing(store):
|
||||
store.write("abc", "3:0.0", 0.3, T, queue=False, now=T)
|
||||
assert store.get("abc").pending is None and store.get("abc").anchor == "3:0.0"
|
||||
assert store.pending_keys() == []
|
||||
|
||||
|
||||
def server_entry(anchor, read_at, device="Browser", furthest=None):
|
||||
return {"position_anchor": anchor, "scroll_fraction": 0.5, "read_at": read_at, "device": device,
|
||||
"furthest_anchor": furthest or anchor, "furthest_fraction": 0.5}
|
||||
|
||||
|
||||
def test_pull_shows_the_servers_place(store):
|
||||
store.apply_server("s1", server_entry("20:0.5", T, "Handy"))
|
||||
st = store.get("s1")
|
||||
assert (st.anchor, st.read_at, st.device) == ("20:0.5", T, "Handy")
|
||||
|
||||
|
||||
def test_pull_applies_a_newer_pending_on_top_and_drops_an_older_one(store):
|
||||
store.write("s1", "7:0.0", 0.1, T + 2000, now=T + 2000)
|
||||
store.apply_server("s1", server_entry("20:0.5", T)) # the server's is older: ours shows
|
||||
assert store.get("s1").anchor == "7:0.0"
|
||||
store.apply_server("s1", server_entry("30:0.0", T + 9000)) # the server's is newer: it shows, ours is still sent
|
||||
st = store.get("s1")
|
||||
assert st.anchor == "30:0.0" and st.pending["anchor"] == "7:0.0"
|
||||
|
||||
|
||||
def test_sent_clears_the_queue_unless_something_newer_queued(store):
|
||||
store.write("s1", "7:0.0", 0.1, T, now=T)
|
||||
body = dict(store.get("s1").pending)
|
||||
store.write("s1", "8:0.0", 0.2, T + 1000, now=T + 1000) # read on while the request was out
|
||||
store.sent("s1", body, {"stored": True, "read_at": T, "position_anchor": "7:0.0", "server_now": T})
|
||||
assert store.get("s1").pending["anchor"] == "8:0.0"
|
||||
body = dict(store.get("s1").pending)
|
||||
store.sent("s1", body, {"stored": True, "read_at": T + 1000, "position_anchor": "8:0.0", "scroll_fraction": 0.2})
|
||||
st = store.get("s1")
|
||||
assert st.pending is None and st.echo_read_at == T + 1000 and st.anchor == "8:0.0"
|
||||
|
||||
|
||||
def test_refused_write_takes_the_servers_newer_place(store):
|
||||
store.write("s1", "7:0.0", 0.1, T, now=T)
|
||||
body = dict(store.get("s1").pending)
|
||||
store.sent("s1", body, {"stored": False, "read_at": T + 5000, "position_anchor": "40:0.0", "device": "Handy"})
|
||||
st = store.get("s1")
|
||||
assert (st.anchor, st.device, st.echo_read_at) == ("40:0.0", "Handy", None)
|
||||
|
||||
|
||||
def test_server_clock_sets_the_offset(store):
|
||||
store.set_server_now(T + 7000, T)
|
||||
assert store.offset == 7000
|
||||
store.set_server_now(None)
|
||||
store.set_server_now(12, T) # not a plausible time
|
||||
assert store.offset == 7000
|
||||
|
||||
|
||||
def test_survives_a_restart(tmp_path):
|
||||
a = ProgressStore(tmp_path / "p.json")
|
||||
a.set_server_now(T + 100, T)
|
||||
a.write("s1", "7:0.250000", 0.3, T, now=T)
|
||||
b = ProgressStore(tmp_path / "p.json")
|
||||
assert b.offset == 100 and b.get("s1").anchor == "7:0.250000" and b.get("s1").pending["read_at"] == T
|
||||
|
||||
|
||||
def test_reads_the_old_furthest_wins_file(tmp_path):
|
||||
(tmp_path / "p.json").write_text(json.dumps({"abc": {"anchor": "5:0.5", "updated_at": 1760000000.0}}))
|
||||
st = ProgressStore(tmp_path / "p.json").get("abc")
|
||||
assert st.anchor == "5:0.5" and st.read_at == 1760000000000 and st.pending is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a,b,sign", [
|
||||
(("5:0.1", 0), ("4:0.9", 0), 1),
|
||||
(("5:0.5", 0), ("5:0.4", 0), 1),
|
||||
(("4:0.9", 0), ("5:0.1", 0), -1),
|
||||
(("", 0.7), ("5:0.1", 0.2), 1), # no anchor on one side: the fraction decides
|
||||
(("", 0.1), ("", 0.1), 0),
|
||||
])
|
||||
def test_cmp_progress(a, b, sign):
|
||||
r = cmp_progress(a[0], a[1], b[0], b[1])
|
||||
assert (r > 0) - (r < 0) == sign
|
||||
|
||||
|
||||
def test_plausible_ms():
|
||||
assert progress.plausible_ms(T) == T
|
||||
assert progress.plausible_ms(5) is None
|
||||
assert progress.plausible_ms(True) is None
|
||||
assert progress.plausible_ms(float("nan")) is None
|
||||
assert progress.plausible_ms(None) is None
|
||||
137
tui/tests/test_reader_annotations.py
Normal file
137
tui/tests/test_reader_annotations.py
Normal 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)
|
||||
129
tui/tests/test_reader_progress.py
Normal file
129
tui/tests/test_reader_progress.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""The reader saves a position only when the reader moved it, and offers — never applies — a
|
||||
newer position from another device (docs/progress-sync.md)."""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from diora_tui import blocks, config as config_mod, epub as epub_mod, layout, progress
|
||||
from diora_tui.app import DioraTuiApp
|
||||
from diora_tui.config import RemoteConfig
|
||||
from diora_tui.reader_screen import JumpOfferScreen, ReaderScreen
|
||||
|
||||
T = 1_760_000_000_000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
s = progress.ProgressStore(tmp_path / "p.json")
|
||||
monkeypatch.setattr(progress, "_default", s)
|
||||
return s
|
||||
|
||||
|
||||
def book(epub, name="Testbuch"):
|
||||
body = "<h1>Anfang</h1>" + "".join(f"<p>Absatz {i} " + "wort " * 40 + "</p>" for i in range(80))
|
||||
return epub([body], title=name)
|
||||
|
||||
|
||||
async def open_reader(app, pilot, path):
|
||||
app.push_screen(ReaderScreen(path))
|
||||
for _ in range(80):
|
||||
await pilot.pause(0.1)
|
||||
s = app.screen
|
||||
if isinstance(s, ReaderScreen) and s.book is not None and s._last_anchor is not None:
|
||||
await pilot.pause(0.2)
|
||||
return s
|
||||
raise AssertionError("reader did not load")
|
||||
|
||||
|
||||
def test_only_the_readers_moves_are_saved(epub, store, tmp_path):
|
||||
path = book(epub)
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir()
|
||||
|
||||
async def run():
|
||||
app = DioraTuiApp(lib)
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
reader = await open_reader(app, pilot, path)
|
||||
key = epub_mod._book_id(path)
|
||||
reader._save_progress()
|
||||
assert store.get(key) is None # opened and looked at: nothing written
|
||||
|
||||
await pilot.press(*["j"] * 40)
|
||||
reader._save_progress()
|
||||
st = store.get(key)
|
||||
assert st is not None and blocks.parse_anchor(st.anchor)[0] > 0
|
||||
assert st.pending is None # not a server book: nothing queued
|
||||
assert 0 < st.fraction < 1 and abs(st.read_at - progress.now_ms()) < 5000
|
||||
first = st.read_at
|
||||
|
||||
await pilot.resize_terminal(70, 24) # re-wrapped by the program, not read
|
||||
await pilot.pause(0.3)
|
||||
reader._save_progress()
|
||||
assert store.get(key).read_at == first
|
||||
|
||||
reader._save_progress() # unchanged: no new time either
|
||||
assert store.get(key).read_at == first
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_reopening_returns_to_the_saved_place_without_writing(epub, store, tmp_path):
|
||||
path = book(epub)
|
||||
key = epub_mod._book_id(path)
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir()
|
||||
|
||||
async def run():
|
||||
app = DioraTuiApp(lib)
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
reader = await open_reader(app, pilot, path)
|
||||
await pilot.press(*["j"] * 60)
|
||||
reader._save_progress()
|
||||
saved = store.get(key)
|
||||
app.pop_screen()
|
||||
await pilot.pause()
|
||||
|
||||
reader = await open_reader(app, pilot, path)
|
||||
assert reader._book_view.scroll_y > 30
|
||||
reader._save_progress()
|
||||
assert store.get(key).read_at == saved.read_at # restored, not "read" again
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("answer,jumps", [("y", True), ("n", False)])
|
||||
def test_newer_position_from_elsewhere_is_offered(epub, store, tmp_path, monkeypatch, answer, jumps):
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir()
|
||||
path = book(epub, "Server")
|
||||
target = lib / "0042 - Server.epub"
|
||||
path.rename(target)
|
||||
monkeypatch.setattr(config_mod, "load", lambda: RemoteConfig("http://x", "t", "k"))
|
||||
store.apply_server("s42", {"position_anchor": "5:0.0", "scroll_fraction": 0.05, "read_at": T, "device": "Handy"})
|
||||
|
||||
async def run():
|
||||
app = DioraTuiApp(lib)
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
reader = await open_reader(app, pilot, target)
|
||||
y0 = reader._book_view.scroll_y
|
||||
|
||||
# another device read further on, later
|
||||
store.apply_server("s42", {"position_anchor": "60:0.0", "scroll_fraction": 0.8, "read_at": T + 60_000, "device": "Handy"})
|
||||
reader._offer_newer()
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, JumpOfferScreen)
|
||||
assert reader._book_view.scroll_y == y0 # asked, not moved
|
||||
await pilot.press(answer)
|
||||
await pilot.pause(0.3)
|
||||
assert isinstance(app.screen, ReaderScreen)
|
||||
moved = reader._book_view.scroll_y > y0 + 20
|
||||
assert moved == jumps
|
||||
|
||||
reader._offer_newer() # the same state is not offered twice
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, ReaderScreen)
|
||||
|
||||
reader._save_progress() # jumping isn't reading: still the server's time
|
||||
assert store.get("s42").read_at == T + 60_000
|
||||
|
||||
asyncio.run(run())
|
||||
72
tui/tests/test_remote.py
Normal file
72
tui/tests/test_remote.py
Normal 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)
|
||||
38
tui/tools/parity/compare_blocks.py
Normal file
38
tui/tools/parity/compare_blocks.py
Normal 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:]))
|
||||
42
tui/tools/parity/web_anchors.cjs
Normal file
42
tui/tools/parity/web_anchors.cjs
Normal 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); });
|
||||
55
tui/tools/parity/web_blocks.cjs
Normal file
55
tui/tools/parity/web_blocks.cjs
Normal 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
137
tui/tools/smoke_server.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue