"""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 dataclasses import dataclass 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 epub, library_meta, login as login_mod, progress, remote from .reader_screen import ReaderScreen from .statusbar import StatusBar DEFAULT_LIBRARY = Path.home() / "Books" @dataclass class Entry: """A line of the library: a file in the folder, a server book not downloaded yet, or both.""" key: str title: str path: Path | None server_id: int | None is_read: bool read_at: int uploaded_at: str fraction: float 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.all_entries: list[Entry] = [] self.shown: list[Entry] = [] 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) def on_screen_resume(self) -> None: self._refresh_list() # a book was read: the order and the percentages changed @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) def _gather(self) -> list[Entry]: store = progress.default() remote_books = library_meta.remote_entries() by_key: dict[str, Entry] = {} for path in epub.scan_library(self.library_dir): key = epub._book_id(path) info = remote_books.get(key) or {} by_key[key] = Entry(key, info.get("title") or path.stem, path, epub.server_id_of(path), bool(info.get("is_read")), 0, info.get("uploaded_at") or "", 0.0) for key, info in remote_books.items(): if key not in by_key: by_key[key] = Entry(key, info["title"], None, info["server_id"], bool(info.get("is_read")), 0, info.get("uploaded_at") or "", 0.0) for key, e in by_key.items(): st = store.get(key) if st is not None: e.read_at = st.read_at or 0 e.fraction = st.fraction # last read first; never opened ones by when they came return sorted(by_key.values(), key=lambda e: (e.read_at, e.uploaded_at), reverse=True) def _refresh_list(self) -> None: self.all_entries = self._gather() self.shown = [e for e in self.all_entries if self.show_read or not e.is_read] list_view = self.query_one("#library-list", ListView) index = list_view.index list_view.clear() if not self.all_entries: list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}"))) return if not self.shown: list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen"))) return for e in self.shown: mark = "✓ " if e.is_read else "" cloud = "☁ " if e.path is None else "" pct = f" {round(e.fraction * 100)} %" if e.fraction > 0 else "" list_view.append(ListItem(Label(f"{mark}{cloud}{e.title}{pct}"))) list_view.index = min(index or 0, len(self.shown) - 1) 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.shown or list_view.index is None or list_view.index >= len(self.shown): return entry = self.shown[list_view.index] if entry.path is not None: self.app.push_screen(ReaderScreen(entry.path)) return cfg = config_mod.load() if cfg is None or entry.server_id is None: self.notify("Nicht angemeldet — `diora-tui login`.", severity="error") return self.notify(f"Lade „{entry.title}“ …", timeout=4) self._download_and_open(cfg, entry) @work(thread=True, exclusive=True, group="download") def _download_and_open(self, cfg: config_mod.RemoteConfig, entry: Entry) -> None: try: path = remote.download_book(self.library_dir, cfg, entry.server_id, entry.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() 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()