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>
243 lines
9.2 KiB
Python
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()
|