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:
parent
db3f520632
commit
b1a04d2a65
4 changed files with 129 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
43
tui/diora_tui/library_meta.py
Normal file
43
tui/diora_tui/library_meta.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue