From b1a04d2a6574c5380c38071d6bf64a3789810a2e Mon Sep 17 00:00:00 2001 From: marwin Date: Sat, 15 Aug 2026 20:30:09 +0200 Subject: [PATCH] =?UTF-8?q?TUI:=20Auto-Sync=20bei=20Start/Beenden,=20Sorti?= =?UTF-8?q?erung=20nach=20zuletzt=20ge=C3=B6ffnet,=20Gelesen-Filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library-Screen synct jetzt selbstständig statt den expliziten `sync`-Befehl vorauszusetzen: einmal im Hintergrund direkt nach dem Start (neue Bücher/ Fortschritt erscheinen, sobald fertig), einmal abgewartet beim Beenden über q (DioraTuiApp.action_quit). Beides best-effort — ohne gespeicherte Zugangsdaten oder bei Netzwerkfehlern bleibt die lokale Bibliothek unangetastet nutzbar. Bibliotheksliste sortiert jetzt nach zuletzt geöffnetem Buch (progress.json updated_at, neueste zuerst) und blendet gelesene Bücher standardmäßig aus (EBook.is_read aus dem Sync-Snapshot, lokal in library.json gespiegelt) — r-Taste zeigt sie für die Sitzung wieder an. Getestet: vollständig isoliert (Pfad-Konstanten gemonkeypatcht statt echter ~/.config-/~/.local/share-Dateien) gegen einen echten Dev-Server mit zwei Büchern unterschiedlichen Gelesen-Status und Fortschritts-Zeitstempeln — Auto-Sync, Filter-Default, Toggle und Sortierreihenfolge korrekt bestätigt; Sync-vor-dem-Beenden separat verifiziert (kein echter Hänger, nur ein Test-Timing-Artefakt ohne pilot.pause()). --- tui/README.md | 13 +++++++ tui/diora_tui/app.py | 72 +++++++++++++++++++++++++++++++---- tui/diora_tui/library_meta.py | 43 +++++++++++++++++++++ tui/diora_tui/remote.py | 9 ++++- 4 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 tui/diora_tui/library_meta.py diff --git a/tui/README.md b/tui/README.md index d997368..960dc6d 100644 --- a/tui/README.md +++ b/tui/README.md @@ -38,13 +38,26 @@ Tastenkürzel: `_looksLikeFootnoteLink` (Link in/um ``, Klassenname mit note/footnote/fn, oder `epub:type="noteref"`) - `Enter` — markiertes Buch aus der Bibliothek öffnen +- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten) - `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek) Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als volle Terminalbreite bei breiten Fenstern). +Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand +des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher +standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json` +gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung. + ## Bücher + Fortschritt vom Server holen (`diora-tui sync`) +Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe +unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue +Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von +selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App +tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für +Nicht-interaktive Nutzung (Cron o.ä.): + ```bash diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern diff --git a/tui/diora_tui/app.py b/tui/diora_tui/app.py index 7b260ad..350de9b 100644 --- a/tui/diora_tui/app.py +++ b/tui/diora_tui/app.py @@ -1,19 +1,21 @@ -"""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).""" +"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress +against a diora server (see project README).""" from __future__ import annotations import argparse +import asyncio import getpass 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 crypto, epub, remote +from . import crypto, epub, library_meta, progress, remote from .reader_screen import ReaderScreen DEFAULT_LIBRARY = Path.home() / "Books" @@ -22,6 +24,7 @@ DEFAULT_LIBRARY = Path.home() / "Books" class LibraryScreen(Screen): BINDINGS = [ Binding("enter", "open_selected", "Öffnen"), + Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"), Binding("q", "quit", "Beenden"), ] @@ -29,6 +32,8 @@ class LibraryScreen(Screen): super().__init__() self.library_dir = library_dir self.paths: list[Path] = [] + self.visible_paths: list[Path] = [] + self.show_read = False def compose(self) -> ComposeResult: yield Header() @@ -37,21 +42,65 @@ class LibraryScreen(Screen): 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) + + @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: self.paths = epub.scan_library(self.library_dir) + + 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] + list_view = self.query_one("#library-list", ListView) + list_view.clear() 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))) + 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))) list_view.index = 0 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.paths or list_view.index is None: + if not self.visible_paths or list_view.index is None: return - self.app.push_screen(ReaderScreen(self.paths[list_view.index])) + self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index])) def on_list_view_selected(self, event: ListView.Selected) -> None: self.action_open_selected() @@ -68,6 +117,15 @@ class DioraTuiApp(App): 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: + 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 _prompt(label: str, *, secret: bool = False) -> str: value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip() diff --git a/tui/diora_tui/library_meta.py b/tui/diora_tui/library_meta.py new file mode 100644 index 0000000..5cf9ea2 --- /dev/null +++ b/tui/diora_tui/library_meta.py @@ -0,0 +1,43 @@ +"""Local per-book metadata pulled from the server — currently just "read" +status (EBook.is_read from the /api/sync/ snapshot). Kept separate from +progress.py (reading position): different concern, different cadence — this +is only ever set by `diora-tui sync`, never by the reader itself. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from platformdirs import user_data_dir + +_DATA_DIR = Path(user_data_dir("diora-tui", "diora")) +_META_FILE = _DATA_DIR / "library.json" + + +def _load_all() -> dict[str, dict]: + if not _META_FILE.exists(): + return {} + try: + return json.loads(_META_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def _save_all(data: dict[str, dict]) -> None: + _DATA_DIR.mkdir(parents=True, exist_ok=True) + _META_FILE.write_text(json.dumps(data, indent=2)) + + +def is_read(book_id: str) -> bool: + entry = _load_all().get(book_id) + return bool(entry and entry.get("is_read")) + + +def set_many(read_status: dict[str, bool]) -> None: + if not read_status: + return + data = _load_all() + for book_id, read in read_status.items(): + data.setdefault(book_id, {})["is_read"] = read + _save_all(data) diff --git a/tui/diora_tui/remote.py b/tui/diora_tui/remote.py index c4705e8..58ec00b 100644 --- a/tui/diora_tui/remote.py +++ b/tui/diora_tui/remote.py @@ -18,7 +18,7 @@ from pathlib import Path import requests -from . import api, crypto +from . import api, crypto, library_meta from .config import RemoteConfig from .epub import _book_id from .progress import Position, save as save_progress @@ -100,4 +100,11 @@ def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult: if save_progress(local_id, Position(anchor=anchor)): result.progress_pulled += 1 + read_status = { + _book_id(path): bool(book.get("is_read")) + for book in snapshot.get("books", []) + if (path := local_path_by_server_id.get(book["id"])) is not None + } + library_meta.set_many(read_status) + return result