diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..4af7ad6 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,126 @@ +# 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). + +Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben +("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via +`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene +Position zurücksetzt. + +## 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 +- `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 ``, Klassenname mit note/footnote/fn, oder + `epub:type="noteref"`) +- `Enter` — markiertes Buch aus der Bibliothek öffnen +- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten) +- `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 Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand +des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher +standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json` +gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung. + +## Bücher + Fortschritt vom Server holen (`diora-tui sync`) + +Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe +unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue +Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von +selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App +tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für +Nicht-interaktive Nutzung (Cron o.ä.): + +```bash +diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage +diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern +``` + +Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books//data/` herunter, +entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als +normale `.epub`-Dateien in `--library` ab (Dateiname ` - .epub`) — von da an +funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden +beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot +wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch). + +Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am ` - `-Dateinamens- +Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server +(`POST /books//progress/`, `force: false` — überschreibt also nie eine weiter +gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein +lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff. + +Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter +`~/.config/diora-tui/config.json`, `chmod 600`): + +- **Server-URL** — z.B. `https://diora.creamfresh.xyz`. +- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token". +- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig + verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege: + 1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's + "Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000 + Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`). + Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt + hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese + Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als + Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis. + 2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der + Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in + `app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in + der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die + lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins + Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem + `NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage` + lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`. + +Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der +Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei. + +## 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. + +## Grenzen der aktuellen Version + +- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen, + wird gemeldet), da der Reader sie ohnehin nicht darstellen kann. +- 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. diff --git a/tui/diora_tui/__init__.py b/tui/diora_tui/__init__.py new file mode 100644 index 0000000..d6a2003 --- /dev/null +++ b/tui/diora_tui/__init__.py @@ -0,0 +1 @@ +"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients.""" diff --git a/tui/diora_tui/__main__.py b/tui/diora_tui/__main__.py new file mode 100644 index 0000000..eb5551b --- /dev/null +++ b/tui/diora_tui/__main__.py @@ -0,0 +1,4 @@ +from diora_tui.app import main + +if __name__ == "__main__": + main() diff --git a/tui/diora_tui/api.py b/tui/diora_tui/api.py new file mode 100644 index 0000000..09890c7 --- /dev/null +++ b/tui/diora_tui/api.py @@ -0,0 +1,48 @@ +"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for +local clients"). Status: that API is provisional — expect endpoint/field +changes as the server-side implementation settles. +""" + +from __future__ import annotations + +import requests + + +class ApiError(Exception): + pass + + +def _headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def fetch_sync_snapshot(server_url: str, token: str) -> dict: + resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30) + if resp.status_code == 401: + raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") + resp.raise_for_status() + 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=120) + if resp.status_code == 401: + raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") + if resp.status_code == 404: + raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)") + resp.raise_for_status() + return resp.json() + + +def post_progress( + server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False +) -> None: + body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force} + resp = requests.post( + f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30 + ) + if resp.status_code == 401: + raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") + if resp.status_code == 404: + raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)") + resp.raise_for_status() diff --git a/tui/diora_tui/app.py b/tui/diora_tui/app.py new file mode 100644 index 0000000..883df81 --- /dev/null +++ b/tui/diora_tui/app.py @@ -0,0 +1,231 @@ +"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress +against a diora server (see project README).""" + +from __future__ import annotations + +import argparse +import asyncio +import getpass +from pathlib import Path + +from textual import work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import Footer, Header, Label, ListItem, ListView + +from . import config as config_mod +from . import crypto, epub, library_meta, progress, remote +from .reader_screen import ReaderScreen +from .statusbar import StatusBar + +DEFAULT_LIBRARY = Path.home() / "Books" + + +class LibraryScreen(Screen): + BINDINGS = [ + Binding("enter", "open_selected", "Öffnen"), + Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"), + Binding("q", "quit", "Beenden"), + ] + + def __init__(self, library_dir: Path) -> None: + super().__init__() + self.library_dir = library_dir + self.paths: list[Path] = [] + self.visible_paths: list[Path] = [] + self.show_read = False + + def compose(self) -> ComposeResult: + yield Header() + yield ListView(id="library-list") + yield StatusBar() + yield Footer() + + def on_mount(self) -> None: + self.sub_title = str(self.library_dir) + self._refresh_list() + cfg = config_mod.load() + if cfg is not None: + self._auto_sync(cfg) + + @work(thread=True) + def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None: + try: + result = remote.sync_library(self.library_dir, cfg) + except remote.SyncError: + return # best-effort — e.g. offline; the local library still works + self.app.call_from_thread(self._on_auto_sync_done, result) + + def _on_auto_sync_done(self, result: remote.SyncResult) -> None: + if result.downloaded or result.progress_pulled: + self._refresh_list() + if result.downloaded: + self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3) + + def _refresh_list(self) -> None: + self.paths = epub.scan_library(self.library_dir) + + entries = [] + for path in self.paths: + book_id = epub._book_id(path) + read = library_meta.is_read(book_id) + if read and not self.show_read: + continue + saved = progress.load(book_id) + last_opened = saved.updated_at if saved else 0.0 + entries.append((last_opened, path, read)) + entries.sort(key=lambda e: e[0], reverse=True) + self.visible_paths = [path for _, path, _ in entries] + + list_view = self.query_one("#library-list", ListView) + list_view.clear() + if not self.paths: + list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}"))) + return + if not entries: + list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen"))) + return + for _, path, read in entries: + label = f"✓ {path.stem}" if read else path.stem + list_view.append(ListItem(Label(label))) + list_view.index = 0 + list_view.focus() + + def action_toggle_read_filter(self) -> None: + self.show_read = not self.show_read + self._refresh_list() + state = "eingeblendet" if self.show_read else "ausgeblendet" + self.notify(f"Gelesene Bücher {state}.", timeout=2) + + def action_open_selected(self) -> None: + list_view = self.query_one("#library-list", ListView) + if not self.visible_paths or list_view.index is None: + return + self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index])) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + self.action_open_selected() + + +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: + await asyncio.to_thread(remote.sync_library, self.library_dir, cfg) + except remote.SyncError: + pass # best-effort — don't block quitting on a sync failure + self.exit() + + +def _prompt(label: str, *, secret: bool = False) -> str: + value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip() + if not value: + raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.") + return value + + +def _ask_yes_no(question: str) -> bool: + return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja") + + +def _prompt_enc_key() -> str: + print() + print("Verschlüsselungs-Key — zwei Wege:") + print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)") + print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)") + choice = input("Wahl [1/2, Standard 1]: ").strip() or "1" + if choice == "2": + return _prompt("Verschlüsselungs-Key (Base64)", secret=True) + + print( + "Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über " + "diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. " + "War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer " + "(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne " + "etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen." + ) + username = _prompt("diora-Benutzername") + password = _prompt("diora-Passwort", secret=True) + return crypto.derive_key_b64(username, password) + + +def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig: + cfg = None if server_override else config_mod.load() + if cfg is not None: + return cfg + + print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.") + server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/") + token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True) + enc_key = _prompt_enc_key() + cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key) + if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"): + config_mod.save(cfg) + print("Gespeichert.") + return cfg + + +def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None: + cfg = _resolve_remote_config(server_override, save=save) + print(f"Verbinde zu {cfg.server_url} …") + try: + result = remote.sync_library(library_dir, cfg) + except remote.SyncError as e: + raise SystemExit(f"Fehler: {e}") + + print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.") + for name in result.downloaded: + print(f" + {name}") + if result.unchanged: + print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.") + if result.skipped_pdf: + titles = ", ".join(result.skipped_pdf) + print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}") + if result.failed: + titles = ", ".join(result.failed) + print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}") + if result.progress_pulled: + print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)" + ) + parser.add_argument( + "--library", + type=Path, + default=DEFAULT_LIBRARY, + help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})", + ) + subparsers = parser.add_subparsers(dest="command") + sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln") + sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf") + sync_parser.add_argument( + "--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen" + ) + + args = parser.parse_args() + library_dir = args.library.expanduser() + + if args.command == "sync": + run_sync(library_dir, args.server, save=args.save) + return + + DioraTuiApp(library_dir).run() + + +if __name__ == "__main__": + main() diff --git a/tui/diora_tui/app.tcss b/tui/diora_tui/app.tcss new file mode 100644 index 0000000..06172cd --- /dev/null +++ b/tui/diora_tui/app.tcss @@ -0,0 +1,49 @@ +#library-list { + height: 1fr; +} + +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; +} diff --git a/tui/diora_tui/blocks.py b/tui/diora_tui/blocks.py new file mode 100644 index 0000000..d8a3d62 --- /dev/null +++ b/tui/diora_tui/blocks.py @@ -0,0 +1,214 @@ +"""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 , 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 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. +# lxml's HTML parser is ~3x faster than bs4's built-in html.parser, which +# matters here: some real-world EPUBs run to tens of thousands of blocks. +warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) +_PARSER = "lxml" + +# 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") + +# 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 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) + + +@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 + + +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, 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 = child.get_text(" ", strip=True) + 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 _extract_blocks(html: bytes, chapter_index: int) -> list[Block]: + soup = BeautifulSoup(html, _PARSER) + for name in _STRIP_TAGS: + for el in soup.find_all(name): + el.decompose() + + blocks: list[Block] = [] + for el in soup.find_all(True): + if el.name in _BLOCK_TAGS or _is_leaf_div(el): + footnotes: list[FootnoteRef] = [] + parts: list[str] = [] + _walk_text(el, footnotes, parts) + text = re.sub(r"\s+", " ", "".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] + blocks.append( + Block( + text=text, + tag=el.name, + chapter_index=chapter_index, + id=el.get("id"), + footnotes=footnotes, + ids=ids, + ) + ) + return blocks + + +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] = [] + + # 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 = _extract_blocks(item.get_content(), chapter_index) + 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]) + 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) + + 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, + ) + + +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 diff --git a/tui/diora_tui/book_view.py b/tui/diora_tui/book_view.py new file mode 100644 index 0000000..9ccc14e --- /dev/null +++ b/tui/diora_tui/book_view.py @@ -0,0 +1,42 @@ +"""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 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) -> None: + super().__init__() + self.book_layout = book_layout + self.virtual_size = Size(book_layout.width, book_layout.total_rows) + + 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) diff --git a/tui/diora_tui/cache.py b/tui/diora_tui/cache.py new file mode 100644 index 0000000..21340b8 --- /dev/null +++ b/tui/diora_tui/cache.py @@ -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 platformdirs import user_cache_dir + +from .blocks import FlatBook +from .layout import BookLayout + +_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache" + + +def _cache_key(path: Path, width: int) -> str: + stat = path.stat() + raw = f"{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 diff --git a/tui/diora_tui/config.py b/tui/diora_tui/config.py new file mode 100644 index 0000000..bc82a5f --- /dev/null +++ b/tui/diora_tui/config.py @@ -0,0 +1,42 @@ +"""Local storage for diora server credentials used by `diora-tui sync`. + +Mirrors the trust model of the web client's key storage (static/js/app.js, +getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept +in plaintext on disk, scoped to this machine/user (0600), with no additional +at-rest encryption — same exposure as the browser's localStorage already has. +""" + +from __future__ import annotations + +import json +import stat +from dataclasses import asdict, dataclass +from pathlib import Path + +from platformdirs import user_config_dir + +_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora")) +_CONFIG_FILE = _CONFIG_DIR / "config.json" + + +@dataclass +class RemoteConfig: + server_url: str + api_token: str + enc_key_b64: str + + +def load() -> RemoteConfig | None: + if not _CONFIG_FILE.exists(): + return None + try: + data = json.loads(_CONFIG_FILE.read_text()) + return RemoteConfig(**data) + except (json.JSONDecodeError, OSError, TypeError): + return None + + +def save(cfg: RemoteConfig) -> None: + _CONFIG_DIR.mkdir(parents=True, exist_ok=True) + _CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2)) + _CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) diff --git a/tui/diora_tui/crypto.py b/tui/diora_tui/crypto.py new file mode 100644 index 0000000..75a1a7e --- /dev/null +++ b/tui/diora_tui/crypto.py @@ -0,0 +1,51 @@ +"""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 + +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 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() diff --git a/tui/diora_tui/epub.py b/tui/diora_tui/epub.py new file mode 100644 index 0000000..e5eaaa2 --- /dev/null +++ b/tui/diora_tui/epub.py @@ -0,0 +1,19 @@ +"""Library scanning and the stable per-book id shared across progress.py, +blocks.py, and remote.py.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + + +def _book_id(path: Path) -> str: + # Identifies a book by its resolved path for now. Once the sync API exists, + # this should switch to whatever stable id diora assigns server-side. + 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")) diff --git a/tui/diora_tui/layout.py b/tui/diora_tui/layout.py new file mode 100644 index 0000000..e7700f4 --- /dev/null +++ b/tui/diora_tui/layout.py @@ -0,0 +1,90 @@ +"""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 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) + wrapped_lines = list(Text(text).wrap(console, width)) if text else [] + if not wrapped_lines: + wrapped_lines = [Text("")] + heights.append(len(wrapped_lines)) + for line in wrapped_lines: + strip = Strip(line.render(console), None).adjust_cell_length(width) + row_strips.append(strip) + row_strips.append(blank) + row += len(wrapped_lines) + _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) diff --git a/tui/diora_tui/library_meta.py b/tui/diora_tui/library_meta.py new file mode 100644 index 0000000..5cf9ea2 --- /dev/null +++ b/tui/diora_tui/library_meta.py @@ -0,0 +1,43 @@ +"""Local per-book metadata pulled from the server — currently just "read" +status (EBook.is_read from the /api/sync/ snapshot). Kept separate from +progress.py (reading position): different concern, different cadence — this +is only ever set by `diora-tui sync`, never by the reader itself. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from platformdirs import user_data_dir + +_DATA_DIR = Path(user_data_dir("diora-tui", "diora")) +_META_FILE = _DATA_DIR / "library.json" + + +def _load_all() -> dict[str, dict]: + if not _META_FILE.exists(): + return {} + try: + return json.loads(_META_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def _save_all(data: dict[str, dict]) -> None: + _DATA_DIR.mkdir(parents=True, exist_ok=True) + _META_FILE.write_text(json.dumps(data, indent=2)) + + +def is_read(book_id: str) -> bool: + entry = _load_all().get(book_id) + return bool(entry and entry.get("is_read")) + + +def set_many(read_status: dict[str, bool]) -> None: + if not read_status: + return + data = _load_all() + for book_id, read in read_status.items(): + data.setdefault(book_id, {})["is_read"] = read + _save_all(data) diff --git a/tui/diora_tui/progress.py b/tui/diora_tui/progress.py new file mode 100644 index 0000000..588e24b --- /dev/null +++ b/tui/diora_tui/progress.py @@ -0,0 +1,68 @@ +"""Local reading-progress storage. + +Positions are stored as the same "blockIndex:innerFraction" anchor string the +server uses (books/models.py, EBookProgress.save_progress) — see +diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js +exactly, and anchor_is_further() for the identical "furthest wins" comparison +used server-side (_progress_is_further in books/views.py). A saved position +only ever advances (unless forced), so an older/offline run can't regress a +further-along read position — matching that same rule locally. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from pathlib import Path + +from platformdirs import user_data_dir + +from .blocks import anchor_is_further + +_DATA_DIR = Path(user_data_dir("diora-tui", "diora")) +_PROGRESS_FILE = _DATA_DIR / "progress.json" + + +@dataclass +class Position: + anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000" + updated_at: float = 0.0 + + +def _load_all() -> dict[str, dict]: + if not _PROGRESS_FILE.exists(): + return {} + try: + return json.loads(_PROGRESS_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def _save_all(data: dict[str, dict]) -> None: + _DATA_DIR.mkdir(parents=True, exist_ok=True) + _PROGRESS_FILE.write_text(json.dumps(data, indent=2)) + + +def load(book_id: str) -> Position | None: + entry = _load_all().get(book_id) + if not isinstance(entry, dict) or not entry.get("anchor"): + return None + return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0)) + + +def save(book_id: str, position: Position, *, force: bool = False) -> bool: + """Returns True if the position was actually written (i.e. it was further + along, or forced) — callers that push to the server only need to do so + when this returns True.""" + data = _load_all() + existing = data.get(book_id) + if existing is not None and not force: + old_anchor = existing.get("anchor", "") + if not anchor_is_further(position.anchor, old_anchor): + return False + if not position.updated_at: + position.updated_at = time.time() + data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at} + _save_all(data) + return True diff --git a/tui/diora_tui/reader_screen.py b/tui/diora_tui/reader_screen.py new file mode 100644 index 0000000..6d4392b --- /dev/null +++ b/tui/diora_tui/reader_screen.py @@ -0,0 +1,286 @@ +"""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. +""" + +from __future__ import annotations + +import bisect +import re +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 api, blocks, cache, config as config_mod, layout, progress +from .book_view import ContinuousBookView +from .statusbar import StatusBar + +MAX_LINE_WIDTH = 120 +AUTOSAVE_INTERVAL = 5.0 +# 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 + +_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1) + +# Downloaded-via-sync files are named " - .epub" (see +# remote.py) — reused here to find the server book id for progress push. +_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ") + + +def _server_book_id(path: Path) -> int | None: + m = _SERVER_ID_RE.match(path.name) + return int(m.group(1)) if m else None + + +class FootnoteScreen(ModalScreen[None]): + BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")] + + 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 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("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._server_id = _server_book_id(path) + self._remote_cfg = config_mod.load() if self._server_id is not None else None + self._last_pushed_anchor = "" + + 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) + 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 + self.call_after_refresh(self._restore_position) + self.set_interval(AUTOSAVE_INTERVAL, self._autosave) + + 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) + + 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) + self._book_view.scroll_to(y=new_y, animate=False, immediate=True) + + def _restore_position(self, attempt: int = 0) -> None: + if self.book is None: + return + saved = progress.load(self.book.id) + if saved is None: + return + parsed = blocks.parse_anchor(saved.anchor) + if parsed is None: + return + block_index, inner_fraction = parsed + y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction) + if y <= 0: + return + 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)) + + 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) + + def _autosave(self) -> None: + self._save_progress() + + def _save_progress(self) -> None: + if self.book is None or not self.book_layout.heights: + return + anchor = self._current_anchor_str() + advanced = progress.save(self.book.id, progress.Position(anchor=anchor)) + if advanced and self._remote_cfg is not None and self._server_id is not None: + self._push_remote_progress(anchor) + + @work(thread=True, exclusive=True, group="progress-push") + def _push_remote_progress(self, anchor: str) -> None: + if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None: + return + try: + api.post_progress( + self._remote_cfg.server_url, + self._remote_cfg.api_token, + self._server_id, + scroll_fraction=0.0, + position_anchor=anchor, + force=False, + ) + self._last_pushed_anchor = anchor + except Exception: + pass # best-effort — local progress is already saved regardless + + def on_unmount(self) -> None: + self._save_progress() + + def action_scroll_down_line(self) -> None: + self._book_view.scroll_relative(y=1, animate=False) + + def action_scroll_up_line(self) -> None: + 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.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: + self.app.pop_screen() diff --git a/tui/diora_tui/remote.py b/tui/diora_tui/remote.py new file mode 100644 index 0000000..58ec00b --- /dev/null +++ b/tui/diora_tui/remote.py @@ -0,0 +1,110 @@ +"""Fetch + decrypt books from a diora server into the local TUI library, and +pull reading progress for them into the local progress store. + +Progress is pull-only here (server -> local); the reader pushes local -> +server itself while a book is open (see reader_screen.py). Both directions +use the same "blockIndex:innerFraction" anchor format as the web reader +(diora_tui/blocks.py) and the same furthest-wins merge rule +(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can +only ever advance local progress, never regress it, unless forced. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +import requests + +from . import api, crypto, library_meta +from .config import RemoteConfig +from .epub import _book_id +from .progress import Position, save as save_progress + + +class SyncError(Exception): + pass + + +@dataclass +class SyncResult: + downloaded: list[str] = field(default_factory=list) + unchanged: int = 0 + skipped_pdf: list[str] = field(default_factory=list) + failed: list[str] = field(default_factory=list) + progress_pulled: int = 0 + + +def _sanitize_filename(name: str) -> str: + name = re.sub(r"[^\w\s.-]", "_", name).strip() + return (name or "buch")[:80] + + +def _parse_meta(raw: bytes) -> dict: + return json.loads(raw.decode("utf-8")) + + +def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult: + try: + snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token) + except api.ApiError as e: + raise SyncError(str(e)) from e + except requests.RequestException as e: + raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e + + library_dir.mkdir(parents=True, exist_ok=True) + result = SyncResult() + local_path_by_server_id: dict[int, Path] = {} + + for book in snapshot.get("books", []): + book_id = book["id"] + try: + meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"])) + except crypto.DecryptError: + result.failed.append(f"#{book_id}") + continue + + if meta.get("type") == "pdf": + result.skipped_pdf.append(meta.get("title") or f"#{book_id}") + continue + + existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None) + if existing is not None: + result.unchanged += 1 + local_path_by_server_id[book_id] = existing + continue + + try: + data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id) + raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"]) + except (api.ApiError, crypto.DecryptError, requests.RequestException): + result.failed.append(meta.get("title") or f"#{book_id}") + continue + + title = meta.get("title") or f"book-{book_id}" + dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub" + dest.write_bytes(raw) + result.downloaded.append(dest.name) + local_path_by_server_id[book_id] = dest + + for entry in snapshot.get("book_progress", []): + anchor = entry.get("position_anchor") or "" + if not anchor: + continue # PDF-only scroll_fraction progress — this TUI is EPUB-only + dest = local_path_by_server_id.get(entry.get("book_id")) + if dest is None: + continue + local_id = _book_id(dest) + if save_progress(local_id, Position(anchor=anchor)): + result.progress_pulled += 1 + + read_status = { + _book_id(path): bool(book.get("is_read")) + for book in snapshot.get("books", []) + if (path := local_path_by_server_id.get(book["id"])) is not None + } + library_meta.set_many(read_status) + + return result diff --git a/tui/diora_tui/statusbar.py b/tui/diora_tui/statusbar.py new file mode 100644 index 0000000..e38675a --- /dev/null +++ b/tui/diora_tui/statusbar.py @@ -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)}%" diff --git a/tui/pyproject.toml b/tui/pyproject.toml new file mode 100644 index 0000000..969d473 --- /dev/null +++ b/tui/pyproject.toml @@ -0,0 +1,25 @@ +[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", + "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"]