diora-web/tui/diora_tui/app.py
marwin a4b10ae265 TUI: Bücher per diora-tui sync vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.

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

Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00

235 lines
8.4 KiB
Python

"""diora-tui: local EPUB reader, precursor to a TUI client that syncs against
diora's e-book progress API once it exists (see project README)."""
from __future__ import annotations
import argparse
import getpass
from pathlib import Path
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import VerticalScroll
from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView, Static
from . import config as config_mod
from . import epub, progress, remote
DEFAULT_LIBRARY = Path.home() / "Books"
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
Binding("q", "quit", "Beenden"),
]
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.paths: list[Path] = []
def compose(self) -> ComposeResult:
yield Header()
yield ListView(id="library-list")
yield Footer()
def on_mount(self) -> None:
self.sub_title = str(self.library_dir)
self.paths = epub.scan_library(self.library_dir)
list_view = self.query_one("#library-list", ListView)
if not self.paths:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return
for path in self.paths:
list_view.append(ListItem(Label(path.stem)))
list_view.index = 0
list_view.focus()
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.paths or list_view.index is None:
return
self.app.push_screen(ReaderScreen(self.paths[list_view.index]))
def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected()
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("q,escape", "back", "Zurück zur Bibliothek"),
]
AUTOSAVE_INTERVAL = 5.0
def __init__(self, path: Path) -> None:
super().__init__()
self.book = epub.load_epub(path)
self.chapter_index = 0
saved = progress.load(self.book.id)
if saved is not None and 0 <= saved.chapter_index < len(self.book.chapters):
self.chapter_index = saved.chapter_index
self._pending_scroll_fraction = saved.scroll_fraction if saved else 0.0
def compose(self) -> ComposeResult:
self._text = Static(id="chapter-text")
yield Header()
with VerticalScroll(id="reader-scroll") as scroll:
self._scroll = scroll
yield self._text
yield Footer()
def on_mount(self) -> None:
self._render_chapter(restore_scroll=True)
self.set_interval(self.AUTOSAVE_INTERVAL, self._save_progress)
def on_unmount(self) -> None:
self._save_progress()
def _render_chapter(self, *, restore_scroll: bool = False) -> None:
chapter = self.book.chapters[self.chapter_index]
text = "\n\n".join(chapter.paragraphs)
self._text.update(text)
self.sub_title = f"{self.book.title} — Kapitel {self.chapter_index + 1}/{len(self.book.chapters)}"
self._scroll.scroll_home(animate=False)
if restore_scroll and self._pending_scroll_fraction:
fraction = self._pending_scroll_fraction
self.call_after_refresh(lambda: self._scroll_to_fraction(fraction))
def _scroll_to_fraction(self, fraction: float) -> None:
max_y = max(0, self._scroll.virtual_size.height - self._scroll.size.height)
self._scroll.scroll_to(y=round(max_y * fraction), animate=False)
def _current_fraction(self) -> float:
max_y = max(1, self._scroll.virtual_size.height - self._scroll.size.height)
return max(0.0, min(1.0, self._scroll.scroll_y / max_y))
def _save_progress(self) -> None:
progress.save(
self.book.id,
progress.Position(chapter_index=self.chapter_index, scroll_fraction=self._current_fraction()),
)
def action_scroll_down_line(self) -> None:
self._scroll.scroll_relative(y=1, animate=False)
def action_scroll_up_line(self) -> None:
self._scroll.scroll_relative(y=-1, animate=False)
def action_next_chapter(self) -> None:
if self.chapter_index + 1 < len(self.book.chapters):
self.chapter_index += 1
self._pending_scroll_fraction = 0.0
self._render_chapter()
self._save_progress()
def action_prev_chapter(self) -> None:
if self.chapter_index > 0:
self.chapter_index -= 1
self._pending_scroll_fraction = 0.0
self._render_chapter()
def action_back(self) -> None:
self.app.pop_screen()
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))
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 _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(
"Verschlüsselungs-Key (Base64; in der Browser-Konsole auf der diora-Seite: "
"await exportEncKey() kopiert ihn ins Clipboard)",
secret=True,
)
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}")
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()