diora-web/tui/diora_tui/app.py

236 lines
8.4 KiB
Python
Raw Normal View History

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