2026-08-15 20:30:09 +02:00
|
|
|
"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress
|
|
|
|
|
against a diora server (see project README)."""
|
2026-08-15 16:04:18 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
2026-08-15 20:30:09 +02:00
|
|
|
import asyncio
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
import getpass
|
2026-08-15 16:04:18 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-08-15 20:30:09 +02:00
|
|
|
from textual import work
|
2026-08-15 16:04:18 +02:00
|
|
|
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
|
2026-08-15 16:04:18 +02:00
|
|
|
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
from . import config as config_mod
|
2026-08-15 20:30:09 +02:00
|
|
|
from . import crypto, epub, library_meta, 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
|
2026-08-15 20:36:54 +02:00
|
|
|
from .statusbar import StatusBar
|
2026-08-15 16:04:18 +02:00
|
|
|
|
|
|
|
|
DEFAULT_LIBRARY = Path.home() / "Books"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LibraryScreen(Screen):
|
|
|
|
|
BINDINGS = [
|
|
|
|
|
Binding("enter", "open_selected", "Öffnen"),
|
2026-08-15 20:30:09 +02:00
|
|
|
Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"),
|
2026-08-15 16:04:18 +02:00
|
|
|
Binding("q", "quit", "Beenden"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def __init__(self, library_dir: Path) -> None:
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.library_dir = library_dir
|
|
|
|
|
self.paths: list[Path] = []
|
2026-08-15 20:30:09 +02:00
|
|
|
self.visible_paths: list[Path] = []
|
|
|
|
|
self.show_read = False
|
2026-08-15 16:04:18 +02:00
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
yield Header()
|
|
|
|
|
yield ListView(id="library-list")
|
2026-08-15 20:36:54 +02:00
|
|
|
yield StatusBar()
|
2026-08-15 16:04:18 +02:00
|
|
|
yield Footer()
|
|
|
|
|
|
|
|
|
|
def on_mount(self) -> None:
|
|
|
|
|
self.sub_title = str(self.library_dir)
|
2026-08-15 20:30:09 +02:00
|
|
|
self._refresh_list()
|
|
|
|
|
cfg = config_mod.load()
|
|
|
|
|
if cfg is not None:
|
|
|
|
|
self._auto_sync(cfg)
|
|
|
|
|
|
|
|
|
|
@work(thread=True)
|
|
|
|
|
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
|
|
|
|
|
try:
|
|
|
|
|
result = remote.sync_library(self.library_dir, cfg)
|
|
|
|
|
except remote.SyncError:
|
|
|
|
|
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:
|
|
|
|
|
if result.downloaded or result.progress_pulled:
|
|
|
|
|
self._refresh_list()
|
|
|
|
|
if result.downloaded:
|
|
|
|
|
self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3)
|
|
|
|
|
|
|
|
|
|
def _refresh_list(self) -> None:
|
2026-08-15 16:04:18 +02:00
|
|
|
self.paths = epub.scan_library(self.library_dir)
|
2026-08-15 20:30:09 +02:00
|
|
|
|
|
|
|
|
entries = []
|
|
|
|
|
for path in self.paths:
|
|
|
|
|
book_id = epub._book_id(path)
|
|
|
|
|
read = library_meta.is_read(book_id)
|
|
|
|
|
if read and not self.show_read:
|
|
|
|
|
continue
|
|
|
|
|
saved = progress.load(book_id)
|
|
|
|
|
last_opened = saved.updated_at if saved else 0.0
|
|
|
|
|
entries.append((last_opened, path, read))
|
|
|
|
|
entries.sort(key=lambda e: e[0], reverse=True)
|
|
|
|
|
self.visible_paths = [path for _, path, _ in entries]
|
|
|
|
|
|
2026-08-15 16:04:18 +02:00
|
|
|
list_view = self.query_one("#library-list", ListView)
|
2026-08-15 20:30:09 +02:00
|
|
|
list_view.clear()
|
2026-08-15 16:04:18 +02:00
|
|
|
if not self.paths:
|
|
|
|
|
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
|
|
|
|
|
return
|
2026-08-15 20:30:09 +02:00
|
|
|
if not entries:
|
|
|
|
|
list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen")))
|
|
|
|
|
return
|
|
|
|
|
for _, path, read in entries:
|
|
|
|
|
label = f"✓ {path.stem}" if read else path.stem
|
|
|
|
|
list_view.append(ListItem(Label(label)))
|
2026-08-15 16:04:18 +02:00
|
|
|
list_view.index = 0
|
|
|
|
|
list_view.focus()
|
|
|
|
|
|
2026-08-15 20:30:09 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-15 16:04:18 +02:00
|
|
|
def action_open_selected(self) -> None:
|
|
|
|
|
list_view = self.query_one("#library-list", ListView)
|
2026-08-15 20:30:09 +02:00
|
|
|
if not self.visible_paths or list_view.index is None:
|
2026-08-15 16:04:18 +02:00
|
|
|
return
|
2026-08-15 20:30:09 +02:00
|
|
|
self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index]))
|
2026-08-15 16:04:18 +02:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-08-15 20:30:09 +02:00
|
|
|
async def action_quit(self) -> None:
|
|
|
|
|
cfg = config_mod.load()
|
|
|
|
|
if cfg is not None:
|
|
|
|
|
try:
|
|
|
|
|
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()
|
|
|
|
|
|
2026-08-15 16:04:18 +02:00
|
|
|
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 16:55:03 +02:00
|
|
|
def _prompt_enc_key() -> str:
|
|
|
|
|
print()
|
|
|
|
|
print("Verschlüsselungs-Key — zwei Wege:")
|
|
|
|
|
print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)")
|
|
|
|
|
print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)")
|
|
|
|
|
choice = input("Wahl [1/2, Standard 1]: ").strip() or "1"
|
|
|
|
|
if choice == "2":
|
|
|
|
|
return _prompt("Verschlüsselungs-Key (Base64)", secret=True)
|
|
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
"Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über "
|
|
|
|
|
"diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. "
|
|
|
|
|
"War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer "
|
|
|
|
|
"(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne "
|
|
|
|
|
"etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen."
|
|
|
|
|
)
|
|
|
|
|
username = _prompt("diora-Benutzername")
|
|
|
|
|
password = _prompt("diora-Passwort", secret=True)
|
|
|
|
|
return crypto.derive_key_b64(username, password)
|
|
|
|
|
|
|
|
|
|
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
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)
|
2026-08-15 16:55:03 +02:00
|
|
|
enc_key = _prompt_enc_key()
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
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}")
|
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
|
|
|
if result.progress_pulled:
|
|
|
|
|
print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.")
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
|
|
|
|
|
|
2026-08-15 16:04:18 +02:00
|
|
|
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})",
|
|
|
|
|
)
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-15 16:04:18 +02:00
|
|
|
args = parser.parse_args()
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
library_dir = args.library.expanduser()
|
|
|
|
|
|
|
|
|
|
if args.command == "sync":
|
|
|
|
|
run_sync(library_dir, args.server, save=args.save)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
DioraTuiApp(library_dir).run()
|
2026-08-15 16:04:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|