"""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()