diora-web/tui/diora_tui/app.py

244 lines
9.2 KiB
Python
Raw Normal View History

"""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
TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py, reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung (Terminal hat keine Pixel-Geometrie), was funktioniert, weil der Furthest-Wins-Vergleich primär nach blockIndex sortiert. Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt eines einzelnen riesigen Static — bei großen Büchern (mehrere reale heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und zentriert. Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js' _looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele häufig auf einem inneren <a> statt dem umschließenden <p> sitzen. Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei offenen server-verknüpften Büchern Updates zurück (force: false, im Hintergrund-Worker). Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback localStorage.getItem(...) falls die Clipboard-API in der Konsole verweigert wird. Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a. 3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll, Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s, warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine Race-Condition beim ersten Post-Load-Scroll, und Progress-Push End-to-End gegen einen echten Dev-Server verifiziert.
2026-08-15 17:59:46 +02:00
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
TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py, reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung (Terminal hat keine Pixel-Geometrie), was funktioniert, weil der Furthest-Wins-Vergleich primär nach blockIndex sortiert. Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt eines einzelnen riesigen Static — bei großen Büchern (mehrere reale heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und zentriert. Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js' _looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele häufig auf einem inneren <a> statt dem umschließenden <p> sitzen. Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei offenen server-verknüpften Büchern Updates zurück (force: false, im Hintergrund-Worker). Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback localStorage.getItem(...) falls die Clipboard-API in der Konsole verweigert wird. Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a. 3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll, Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s, warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine Race-Condition beim ersten Post-Load-Scroll, und Progress-Push End-to-End gegen einen echten Dev-Server verifiziert.
2026-08-15 17:59:46 +02:00
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()