TUI: Auto-Sync bei Start/Beenden, Sortierung nach zuletzt geöffnet, Gelesen-Filter

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()).
This commit is contained in:
marwin 2026-08-15 20:30:09 +02:00
parent db3f520632
commit b1a04d2a65
4 changed files with 129 additions and 8 deletions

View file

@ -38,13 +38,26 @@ Tastenkürzel:
`_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder `_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder
`epub:type="noteref"`) `epub:type="noteref"`)
- `Enter` — markiertes Buch aus der Bibliothek öffnen - `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) - `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 Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
volle Terminalbreite bei breiten Fenstern). 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`) ## 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 ```bash
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern

View file

@ -1,19 +1,21 @@
"""diora-tui: local EPUB reader, precursor to a TUI client that syncs against """diora-tui: terminal EPUB reader for diora, syncing books + reading progress
diora's e-book progress API once it exists (see project README).""" against a diora server (see project README)."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio
import getpass import getpass
from pathlib import Path from pathlib import Path
from textual import work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView from textual.widgets import Footer, Header, Label, ListItem, ListView
from . import config as config_mod from . import config as config_mod
from . import crypto, epub, remote from . import crypto, epub, library_meta, progress, remote
from .reader_screen import ReaderScreen from .reader_screen import ReaderScreen
DEFAULT_LIBRARY = Path.home() / "Books" DEFAULT_LIBRARY = Path.home() / "Books"
@ -22,6 +24,7 @@ DEFAULT_LIBRARY = Path.home() / "Books"
class LibraryScreen(Screen): class LibraryScreen(Screen):
BINDINGS = [ BINDINGS = [
Binding("enter", "open_selected", "Öffnen"), Binding("enter", "open_selected", "Öffnen"),
Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"),
Binding("q", "quit", "Beenden"), Binding("q", "quit", "Beenden"),
] ]
@ -29,6 +32,8 @@ class LibraryScreen(Screen):
super().__init__() super().__init__()
self.library_dir = library_dir self.library_dir = library_dir
self.paths: list[Path] = [] self.paths: list[Path] = []
self.visible_paths: list[Path] = []
self.show_read = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
@ -37,21 +42,65 @@ class LibraryScreen(Screen):
def on_mount(self) -> None: def on_mount(self) -> None:
self.sub_title = str(self.library_dir) 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) 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 = self.query_one("#library-list", ListView)
list_view.clear()
if not self.paths: if not self.paths:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}"))) list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return return
for path in self.paths: if not entries:
list_view.append(ListItem(Label(path.stem))) 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.index = 0
list_view.focus() 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: def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView) 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 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: def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected() self.action_open_selected()
@ -68,6 +117,15 @@ class DioraTuiApp(App):
def on_mount(self) -> None: def on_mount(self) -> None:
self.push_screen(LibraryScreen(self.library_dir)) 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: def _prompt(label: str, *, secret: bool = False) -> str:
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip() value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip()

View file

@ -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)

View file

@ -18,7 +18,7 @@ from pathlib import Path
import requests import requests
from . import api, crypto from . import api, crypto, library_meta
from .config import RemoteConfig from .config import RemoteConfig
from .epub import _book_id from .epub import _book_id
from .progress import Position, save as save_progress 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)): if save_progress(local_id, Position(anchor=anchor)):
result.progress_pulled += 1 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 return result