diora-web/tui/diora_tui/app.py
mrwnslz 51b7316398 TUI: Lesestand-Vertrag (zuletzt gelesen gewinnt), Login wie diora_client, Bücher beim Öffnen laden
progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.

Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:08:59 +01:00

243 lines
9.2 KiB
Python

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