Compare commits
8 commits
master
...
worktree-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9683fbdbd | ||
|
|
20d04361bc | ||
|
|
b1a04d2a65 | ||
|
|
db3f520632 | ||
|
|
50f711831c | ||
|
|
a4b10ae265 | ||
|
|
3fcb74631c | ||
|
|
f63bd1f879 |
19 changed files with 1545 additions and 0 deletions
126
tui/README.md
Normal file
126
tui/README.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# diora-tui
|
||||
|
||||
EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
|
||||
|
||||
Zeigt ein Buch als **eine durchgehende Ansicht** über alle Kapitel hinweg (wie diora's
|
||||
Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
|
||||
`"blockIndex:innerFraction"`-Positionsangabe geführt wie der Web-Reader (`books/models.py`,
|
||||
`EBookProgress`) — `blockIndex` zählt dabei exakt wie `static/js/app.js`s
|
||||
`EPUB_BLOCK_SELECTOR` (`p, h1-h6, li, blockquote, dt, dd, figcaption` + textbasierte
|
||||
`div`s ohne Element-Kinder), fortlaufend über das ganze Buch. Dadurch ist die Position
|
||||
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
|
||||
beide Richtungen synchronisieren (siehe unten).
|
||||
|
||||
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben
|
||||
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via
|
||||
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene
|
||||
Position zurücksetzt.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tui
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Nutzung
|
||||
|
||||
```bash
|
||||
diora-tui --library ~/Books # Standard: ~/Books
|
||||
```
|
||||
|
||||
Tastenkürzel:
|
||||
|
||||
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
|
||||
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel
|
||||
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q`
|
||||
zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
|
||||
`_looksLikeFootnoteLink` (Link in/um `<sup>`, 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).
|
||||
|
||||
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
|
||||
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter,
|
||||
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als
|
||||
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an
|
||||
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden
|
||||
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot
|
||||
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch).
|
||||
|
||||
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens-
|
||||
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server
|
||||
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter
|
||||
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein
|
||||
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff.
|
||||
|
||||
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
|
||||
`~/.config/diora-tui/config.json`, `chmod 600`):
|
||||
|
||||
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
|
||||
- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token".
|
||||
- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig
|
||||
verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege:
|
||||
1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's
|
||||
"Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000
|
||||
Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`).
|
||||
Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt
|
||||
hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese
|
||||
Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als
|
||||
Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis.
|
||||
2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der
|
||||
Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in
|
||||
`app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in
|
||||
der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die
|
||||
lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins
|
||||
Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem
|
||||
`NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage`
|
||||
lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`.
|
||||
|
||||
Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der
|
||||
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei.
|
||||
|
||||
## Performance bei großen Büchern
|
||||
|
||||
Sehr große Bücher (mehrstellige Tausend Absätze — z.B. Sammelbände) können beim
|
||||
*ersten* Öffnen mehrere Sekunden bis niedrige zweistellige Sekunden brauchen (Parsing +
|
||||
Zeilenumbruch-Berechnung für die durchgehende Ansicht). Ein zweites Öffnen desselben
|
||||
Buchs bei gleicher Terminalbreite ist dank Cache (`~/.cache/diora-tui/layout_cache/`)
|
||||
deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße — nur die
|
||||
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
|
||||
auf einmal.
|
||||
|
||||
## Grenzen der aktuellen Version
|
||||
|
||||
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,
|
||||
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
|
||||
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
|
||||
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
|
||||
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung
|
||||
zählt primär `blockIndex`, der exakt mit dem Web-Reader übereinstimmt; `innerFraction`
|
||||
ist nur ein Tiebreaker innerhalb desselben Blocks.
|
||||
1
tui/diora_tui/__init__.py
Normal file
1
tui/diora_tui/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""
|
||||
4
tui/diora_tui/__main__.py
Normal file
4
tui/diora_tui/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from diora_tui.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
48
tui/diora_tui/api.py
Normal file
48
tui/diora_tui/api.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for
|
||||
local clients"). Status: that API is provisional — expect endpoint/field
|
||||
changes as the server-side implementation settles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def fetch_sync_snapshot(server_url: str, token: str) -> dict:
|
||||
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
|
||||
if resp.status_code == 401:
|
||||
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
|
||||
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=120)
|
||||
if resp.status_code == 401:
|
||||
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||
if resp.status_code == 404:
|
||||
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def post_progress(
|
||||
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False
|
||||
) -> None:
|
||||
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force}
|
||||
resp = requests.post(
|
||||
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||
if resp.status_code == 404:
|
||||
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
||||
resp.raise_for_status()
|
||||
231
tui/diora_tui/app.py
Normal file
231
tui/diora_tui/app.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""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, library_meta, progress, remote
|
||||
from .reader_screen import ReaderScreen
|
||||
from .statusbar import StatusBar
|
||||
|
||||
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"),
|
||||
]
|
||||
|
||||
def __init__(self, library_dir: Path) -> None:
|
||||
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()
|
||||
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)
|
||||
|
||||
@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
|
||||
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.visible_paths or list_view.index is None:
|
||||
return
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
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()
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
enc_key = _prompt_enc_key()
|
||||
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}")
|
||||
if result.progress_pulled:
|
||||
print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.")
|
||||
|
||||
|
||||
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})",
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
library_dir = args.library.expanduser()
|
||||
|
||||
if args.command == "sync":
|
||||
run_sync(library_dir, args.server, save=args.save)
|
||||
return
|
||||
|
||||
DioraTuiApp(library_dir).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
49
tui/diora_tui/app.tcss
Normal file
49
tui/diora_tui/app.tcss
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
#library-list {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
StatusBar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
background: $panel;
|
||||
color: $text-muted;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#reader-scroll {
|
||||
height: 1fr;
|
||||
padding: 1 2;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
#reader-loading {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
FootnoteScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#footnote-body {
|
||||
width: 80%;
|
||||
max-width: 100;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
background: $panel;
|
||||
border: round $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
.footnote-note {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#footnote-hint {
|
||||
dock: bottom;
|
||||
width: 80%;
|
||||
max-width: 100;
|
||||
background: $panel;
|
||||
color: $text-muted;
|
||||
padding: 0 2;
|
||||
}
|
||||
214
tui/diora_tui/blocks.py
Normal file
214
tui/diora_tui/blocks.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Whole-book flat block extraction, matching static/js/app.js's EPUB_BLOCK_SELECTOR
|
||||
and getPositionAnchor()/restoreFromAnchor() exactly in *structure* (blockIndex is
|
||||
purely DOM order, no rendering needed) so position anchors are comparable across
|
||||
the web reader and this TUI. See CLAUDE.md's `tui/` section for the full picture.
|
||||
|
||||
innerFraction on the web is defined via getBoundingClientRect() pixel geometry,
|
||||
which has no TUI equivalent — a terminal has no font metrics/reflow the way a
|
||||
browser does. We approximate it with row-based geometry from Rich's own text
|
||||
wrapping (see reader.py), which is close enough because the server's "furthest
|
||||
wins" comparison (_progress_is_further) only falls back to comparing fractions
|
||||
when two anchors share the exact same block index — block index is the primary,
|
||||
exactly-reproducible signal.
|
||||
|
||||
Footnote references are detected with the same heuristic as app.js's
|
||||
_looksLikeFootnoteLink: wrapped in/wrapping a <sup>, a class name containing
|
||||
note/footnote/fn, or an epub:type="noteref" attribute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import ebooklib
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
|
||||
from ebooklib import epub
|
||||
|
||||
# EPUB content documents are XHTML; treating them as HTML (matching app.js's
|
||||
# `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638)
|
||||
# is intentional, not a mistake — silence bs4's XML-vs-HTML nudge for it.
|
||||
# lxml's HTML parser is ~3x faster than bs4's built-in html.parser, which
|
||||
# matters here: some real-world EPUBs run to tens of thousands of blocks.
|
||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||
_PARSER = "lxml"
|
||||
|
||||
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
|
||||
# blockquote, dt, dd, figcaption, div:not(:has(*))'
|
||||
_BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt", "dd", "figcaption"}
|
||||
# Matches app.js's sanitizeEpubHtml() strip list (script/style are also stripped
|
||||
# via regex before DOMParser even runs there; decomposing here is equivalent).
|
||||
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
|
||||
# Matches app.js's _looksLikeFootnoteLink's class-name check.
|
||||
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
|
||||
|
||||
# Anchor format the server accepts (books/views.py:save_progress); anything else
|
||||
# is silently discarded back to ''.
|
||||
_ANCHOR_RE = re.compile(r"\d{1,7}:\d(\.\d{1,6})?")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FootnoteRef:
|
||||
marker: str # visible text of the reference link, e.g. "1"
|
||||
offset: int # character offset into the block's text where the marker sits
|
||||
target_id: str # fragment id to resolve against block ids
|
||||
|
||||
|
||||
@dataclass
|
||||
class Block:
|
||||
text: str
|
||||
tag: str
|
||||
chapter_index: int
|
||||
id: str | None = None
|
||||
footnotes: list[FootnoteRef] = field(default_factory=list)
|
||||
# Every id found anywhere in this block's subtree, not just on the block
|
||||
# element itself — footnote *targets* are frequently an <a id="..."> or
|
||||
# similar nested a level or two inside the actual containing paragraph
|
||||
# (see showFootnotePopover's `.closest('.footnote') || .parentElement`
|
||||
# walk-up in app.js), so a target id resolves to "the block containing
|
||||
# it" rather than requiring the id to sit on the block tag itself.
|
||||
ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlatBook:
|
||||
id: str
|
||||
title: str
|
||||
author: str
|
||||
path: Path
|
||||
blocks: list[Block]
|
||||
chapter_titles: list[str]
|
||||
chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block
|
||||
footnote_targets: dict[str, int] # fragment id -> block index of its content
|
||||
|
||||
|
||||
def _is_leaf_div(tag: Tag) -> bool:
|
||||
return tag.name == "div" and tag.find(True) is None
|
||||
|
||||
|
||||
def _looks_like_footnote_link(el: Tag, href: str) -> bool:
|
||||
if "#" not in href:
|
||||
return False
|
||||
cls = " ".join(el.get("class") or []).lower()
|
||||
if _FOOTNOTE_CLASS_RE.search(cls):
|
||||
return True
|
||||
epub_type = (el.get("epub:type") or "").lower()
|
||||
if "noteref" in epub_type:
|
||||
return True
|
||||
return el.find_parent("sup") is not None or el.find("sup") is not None
|
||||
|
||||
|
||||
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
|
||||
for child in el.children:
|
||||
if isinstance(child, NavigableString):
|
||||
out.append(str(child))
|
||||
elif isinstance(child, Tag):
|
||||
if child.name == "a":
|
||||
href = child.get("href") or ""
|
||||
if _looks_like_footnote_link(child, href):
|
||||
marker = child.get_text(" ", strip=True)
|
||||
offset = len("".join(out))
|
||||
target_id = href.split("#", 1)[1] if "#" in href else ""
|
||||
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
|
||||
out.append(marker)
|
||||
continue
|
||||
_walk_text(child, footnotes, out)
|
||||
|
||||
|
||||
def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]:
|
||||
soup = BeautifulSoup(html, _PARSER)
|
||||
for name in _STRIP_TAGS:
|
||||
for el in soup.find_all(name):
|
||||
el.decompose()
|
||||
|
||||
blocks: list[Block] = []
|
||||
for el in soup.find_all(True):
|
||||
if el.name in _BLOCK_TAGS or _is_leaf_div(el):
|
||||
footnotes: list[FootnoteRef] = []
|
||||
parts: list[str] = []
|
||||
_walk_text(el, footnotes, parts)
|
||||
text = re.sub(r"\s+", " ", "".join(parts)).strip()
|
||||
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id]
|
||||
blocks.append(
|
||||
Block(
|
||||
text=text,
|
||||
tag=el.name,
|
||||
chapter_index=chapter_index,
|
||||
id=el.get("id"),
|
||||
footnotes=footnotes,
|
||||
ids=ids,
|
||||
)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def load_flat_book(path: Path) -> FlatBook:
|
||||
from .epub import _book_id # reuse the same path-hash id scheme
|
||||
|
||||
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
|
||||
|
||||
title_meta = raw.get_metadata("DC", "title")
|
||||
title = title_meta[0][0] if title_meta else path.stem
|
||||
author_meta = raw.get_metadata("DC", "creator")
|
||||
author = author_meta[0][0] if author_meta else "Unbekannt"
|
||||
|
||||
blocks: list[Block] = []
|
||||
chapter_titles: list[str] = []
|
||||
chapter_start_block: list[int] = []
|
||||
|
||||
# app.js's parseEpub() includes every spine itemref unconditionally — no
|
||||
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
|
||||
# targets are commonly parked in a linear="no" document. Skipping it here
|
||||
# would both break footnote-target resolution and shift blockIndex
|
||||
# numbering out of sync with the web reader for every block after it.
|
||||
for idref, _linear in raw.spine:
|
||||
item = raw.get_item_with_id(idref)
|
||||
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
||||
continue
|
||||
chapter_index = len(chapter_titles)
|
||||
chapter_blocks = _extract_blocks(item.get_content(), chapter_index)
|
||||
chapter_start_block.append(len(blocks))
|
||||
first_text = next((b.text for b in chapter_blocks if b.text), None)
|
||||
chapter_titles.append((first_text or item.get_name())[:60])
|
||||
blocks.extend(chapter_blocks)
|
||||
|
||||
footnote_targets: dict[str, int] = {}
|
||||
for idx, b in enumerate(blocks):
|
||||
for tag_id in b.ids:
|
||||
footnote_targets.setdefault(tag_id, idx)
|
||||
|
||||
return FlatBook(
|
||||
id=_book_id(path),
|
||||
title=title,
|
||||
author=author,
|
||||
path=path,
|
||||
blocks=blocks,
|
||||
chapter_titles=chapter_titles,
|
||||
chapter_start_block=chapter_start_block,
|
||||
footnote_targets=footnote_targets,
|
||||
)
|
||||
|
||||
|
||||
def format_anchor(block_index: int, inner_fraction: float) -> str:
|
||||
inner_fraction = max(0.0, min(1.0, inner_fraction))
|
||||
return f"{block_index}:{inner_fraction:.6f}"
|
||||
|
||||
|
||||
def parse_anchor(anchor: str) -> tuple[int, float] | None:
|
||||
if not anchor or not _ANCHOR_RE.fullmatch(anchor):
|
||||
return None
|
||||
block_str, _, frac_str = anchor.partition(":")
|
||||
return int(block_str), float(frac_str)
|
||||
|
||||
|
||||
def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
|
||||
"""Mirrors _progress_is_further / _cmpProgress (books/views.py, app.js)."""
|
||||
new_parts = parse_anchor(new_anchor)
|
||||
old_parts = parse_anchor(old_anchor)
|
||||
if new_parts is None or old_parts is None:
|
||||
return bool(new_anchor) and not old_anchor
|
||||
nb, ni = new_parts
|
||||
ob, oi = old_parts
|
||||
return ni >= oi if nb == ob else nb >= ob
|
||||
42
tui/diora_tui/book_view.py
Normal file
42
tui/diora_tui/book_view.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Line-API scroll view for the continuous reader — renders only the rows
|
||||
actually visible on screen (via Widget.render_line), reading from the flat
|
||||
Strip list diora_tui.layout.build_layout() precomputed once. This is what
|
||||
keeps very large books responsive: nothing here scales with book size at
|
||||
paint time, only with viewport height.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.geometry import Size
|
||||
from textual.scroll_view import ScrollView
|
||||
from textual.strip import Strip
|
||||
|
||||
from .layout import BookLayout
|
||||
|
||||
|
||||
class ContinuousBookView(ScrollView):
|
||||
# We always wrap text to fit exactly, so a horizontal scrollbar should
|
||||
# never be needed — and "scroll" (not "auto") for the vertical one keeps
|
||||
# its gutter reserved from the very first (still-empty) layout pass, so
|
||||
# the width we wrap text at later never has to guess whether a scrollbar
|
||||
# will appear and steal columns out from under already-wrapped lines.
|
||||
DEFAULT_CSS = """
|
||||
ContinuousBookView {
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, book_layout: BookLayout) -> None:
|
||||
super().__init__()
|
||||
self.book_layout = book_layout
|
||||
self.virtual_size = Size(book_layout.width, book_layout.total_rows)
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
_scroll_x, scroll_y = self.scroll_offset
|
||||
row = scroll_y + y
|
||||
strips = self.book_layout.row_strips
|
||||
width = self.scrollable_content_region.width
|
||||
if row < 0 or row >= len(strips):
|
||||
return Strip.blank(width, self.rich_style)
|
||||
return strips[row].crop_extend(0, width, self.rich_style)
|
||||
52
tui/diora_tui/cache.py
Normal file
52
tui/diora_tui/cache.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""On-disk cache for the (FlatBook, BookLayout) pair a book open computes —
|
||||
extraction + row-layout for very large books (tens of thousands of blocks)
|
||||
can take several seconds each; reopening the same book at the same terminal
|
||||
width should be near-instant instead of paying that cost again every time.
|
||||
|
||||
Not a correctness-critical cache: any miss (new book, different width, edited
|
||||
file) just falls back to recomputing from scratch, so a stale/corrupt cache
|
||||
entry is handled by overwriting it, never by crashing the reader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
from .blocks import FlatBook
|
||||
from .layout import BookLayout
|
||||
|
||||
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache"
|
||||
|
||||
|
||||
def _cache_key(path: Path, width: int) -> str:
|
||||
stat = path.stat()
|
||||
raw = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def load(path: Path, width: int) -> tuple[FlatBook, BookLayout] | None:
|
||||
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
try:
|
||||
with cache_file.open("rb") as f:
|
||||
book, book_layout = pickle.load(f)
|
||||
if not isinstance(book, FlatBook) or not isinstance(book_layout, BookLayout):
|
||||
return None
|
||||
return book, book_layout
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save(path: Path, width: int, book: FlatBook, book_layout: BookLayout) -> None:
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
|
||||
try:
|
||||
with cache_file.open("wb") as f:
|
||||
pickle.dump((book, book_layout), f, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
except Exception:
|
||||
pass # best-effort — a failed cache write shouldn't break reading
|
||||
42
tui/diora_tui/config.py
Normal file
42
tui/diora_tui/config.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Local storage for diora server credentials used by `diora-tui sync`.
|
||||
|
||||
Mirrors the trust model of the web client's key storage (static/js/app.js,
|
||||
getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept
|
||||
in plaintext on disk, scoped to this machine/user (0600), with no additional
|
||||
at-rest encryption — same exposure as the browser's localStorage already has.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora"))
|
||||
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteConfig:
|
||||
server_url: str
|
||||
api_token: str
|
||||
enc_key_b64: str
|
||||
|
||||
|
||||
def load() -> RemoteConfig | None:
|
||||
if not _CONFIG_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(_CONFIG_FILE.read_text())
|
||||
return RemoteConfig(**data)
|
||||
except (json.JSONDecodeError, OSError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def save(cfg: RemoteConfig) -> None:
|
||||
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2))
|
||||
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
51
tui/diora_tui/crypto.py
Normal file
51
tui/diora_tui/crypto.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes
|
||||
(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled
|
||||
as raw bytes) — diora's books are end-to-end encrypted client-side, so the
|
||||
server (and this module) only ever sees ciphertext plus the key the user
|
||||
supplies out-of-band.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
_PBKDF2_ITERATIONS = 200_000
|
||||
_KEY_LENGTH_BYTES = 32
|
||||
|
||||
|
||||
class DecryptError(Exception):
|
||||
"""Ciphertext could not be decrypted with the given key (wrong key, or corrupt data)."""
|
||||
|
||||
|
||||
def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
|
||||
try:
|
||||
key = base64.b64decode(key_b64)
|
||||
iv = bytes.fromhex(iv_hex)
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
return AESGCM(key).decrypt(iv, ct, None)
|
||||
except (InvalidTag, ValueError) as e:
|
||||
raise DecryptError(str(e)) from e
|
||||
|
||||
|
||||
def derive_key_b64(username: str, password: str) -> str:
|
||||
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
|
||||
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only
|
||||
yields the key that actually decrypts a user's books if that account's
|
||||
key was ever set up via diora's "unlock with password" flow — a browser
|
||||
that only ever auto-generated a random key (the default) has a key this
|
||||
can't reproduce.
|
||||
"""
|
||||
salt = f"diora:{username}".encode("utf-8")
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=_KEY_LENGTH_BYTES,
|
||||
salt=salt,
|
||||
iterations=_PBKDF2_ITERATIONS,
|
||||
)
|
||||
raw = kdf.derive(password.encode("utf-8"))
|
||||
return base64.b64encode(raw).decode()
|
||||
19
tui/diora_tui/epub.py
Normal file
19
tui/diora_tui/epub.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Library scanning and the stable per-book id shared across progress.py,
|
||||
blocks.py, and remote.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _book_id(path: Path) -> str:
|
||||
# Identifies a book by its resolved path for now. Once the sync API exists,
|
||||
# this should switch to whatever stable id diora assigns server-side.
|
||||
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def scan_library(library_dir: Path) -> list[Path]:
|
||||
if not library_dir.exists():
|
||||
return []
|
||||
return sorted(library_dir.rglob("*.epub"))
|
||||
90
tui/diora_tui/layout.py
Normal file
90
tui/diora_tui/layout.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Row-based layout for the continuous reader view — the TUI's terminal-grid
|
||||
analogue of the browser's pixel-based getBoundingClientRect() geometry (see
|
||||
blocks.py's module docstring for why blockIndex is exact but innerFraction is
|
||||
only an approximation here).
|
||||
|
||||
Each block is wrapped independently at a known width, once, into a flat list
|
||||
of pre-rendered Strip objects (one per terminal row) that book_view.py's
|
||||
Line-API widget indexes directly in render_line() — this is what makes very
|
||||
large books (tens of thousands of blocks) open in seconds rather than
|
||||
minutes: Textual only ever renders the rows actually on screen, instead of
|
||||
laying out the whole book up front the way a single giant Static would.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
from textual.strip import Strip
|
||||
|
||||
_SEPARATOR_ROWS = 1 # one blank row between blocks, matching the old "\n\n".join style
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookLayout:
|
||||
starts: list[int] # row where block i begins
|
||||
heights: list[int] # rendered row count for block i
|
||||
row_strips: list[Strip] # one Strip per absolute row, len == total_rows
|
||||
total_rows: int
|
||||
width: int
|
||||
|
||||
|
||||
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
|
||||
width = max(1, width)
|
||||
starts: list[int] = []
|
||||
heights: list[int] = []
|
||||
row_strips: list[Strip] = []
|
||||
blank = Strip.blank(width)
|
||||
row = 0
|
||||
for text in block_texts:
|
||||
starts.append(row)
|
||||
wrapped_lines = list(Text(text).wrap(console, width)) if text else []
|
||||
if not wrapped_lines:
|
||||
wrapped_lines = [Text("")]
|
||||
heights.append(len(wrapped_lines))
|
||||
for line in wrapped_lines:
|
||||
strip = Strip(line.render(console), None).adjust_cell_length(width)
|
||||
row_strips.append(strip)
|
||||
row_strips.append(blank)
|
||||
row += len(wrapped_lines) + _SEPARATOR_ROWS
|
||||
|
||||
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
|
||||
|
||||
|
||||
def get_position_anchor(layout: BookLayout, scroll_y: int) -> tuple[int, float]:
|
||||
"""Mirrors app.js's getPositionAnchor(): the last block whose top row is at
|
||||
or above scroll_y, or the first block if none has scrolled that far yet."""
|
||||
n = len(layout.heights)
|
||||
if n == 0:
|
||||
return 0, 0.0
|
||||
|
||||
best_index = 0
|
||||
found = False
|
||||
for i in range(n):
|
||||
if layout.heights[i] < 1:
|
||||
continue
|
||||
if layout.starts[i] > scroll_y:
|
||||
break
|
||||
best_index = i
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
best_index = next((i for i in range(n) if layout.heights[i] >= 1), 0)
|
||||
|
||||
top = layout.starts[best_index]
|
||||
height = max(1, layout.heights[best_index])
|
||||
inner_fraction = max(0.0, min(1.0, (scroll_y - top) / height))
|
||||
return best_index, inner_fraction
|
||||
|
||||
|
||||
def scroll_y_for_anchor(layout: BookLayout, block_index: int, inner_fraction: float) -> int:
|
||||
"""Mirrors app.js's restoreFromAnchor()."""
|
||||
n = len(layout.heights)
|
||||
if n == 0:
|
||||
return 0
|
||||
idx = max(0, min(block_index, n - 1))
|
||||
top = layout.starts[idx]
|
||||
height = layout.heights[idx]
|
||||
return top + round(inner_fraction * height)
|
||||
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)
|
||||
68
tui/diora_tui/progress.py
Normal file
68
tui/diora_tui/progress.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Local reading-progress storage.
|
||||
|
||||
Positions are stored as the same "blockIndex:innerFraction" anchor string the
|
||||
server uses (books/models.py, EBookProgress.save_progress) — see
|
||||
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
|
||||
exactly, and anchor_is_further() for the identical "furthest wins" comparison
|
||||
used server-side (_progress_is_further in books/views.py). A saved position
|
||||
only ever advances (unless forced), so an older/offline run can't regress a
|
||||
further-along read position — matching that same rule locally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_data_dir
|
||||
|
||||
from .blocks import anchor_is_further
|
||||
|
||||
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||
_PROGRESS_FILE = _DATA_DIR / "progress.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
|
||||
updated_at: float = 0.0
|
||||
|
||||
|
||||
def _load_all() -> dict[str, dict]:
|
||||
if not _PROGRESS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(_PROGRESS_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_all(data: dict[str, dict]) -> None:
|
||||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def load(book_id: str) -> Position | None:
|
||||
entry = _load_all().get(book_id)
|
||||
if not isinstance(entry, dict) or not entry.get("anchor"):
|
||||
return None
|
||||
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
|
||||
|
||||
|
||||
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
|
||||
"""Returns True if the position was actually written (i.e. it was further
|
||||
along, or forced) — callers that push to the server only need to do so
|
||||
when this returns True."""
|
||||
data = _load_all()
|
||||
existing = data.get(book_id)
|
||||
if existing is not None and not force:
|
||||
old_anchor = existing.get("anchor", "")
|
||||
if not anchor_is_further(position.anchor, old_anchor):
|
||||
return False
|
||||
if not position.updated_at:
|
||||
position.updated_at = time.time()
|
||||
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
|
||||
_save_all(data)
|
||||
return True
|
||||
286
tui/diora_tui/reader_screen.py
Normal file
286
tui/diora_tui/reader_screen.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""Continuous whole-book reader screen — mirrors the web reader's single
|
||||
scrollable view (see blocks.py's module docstring) instead of the old
|
||||
per-chapter pagination, so reading position is expressed in the same
|
||||
"blockIndex:innerFraction" anchor space as the server and the web client.
|
||||
|
||||
Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the
|
||||
whole book into one Static: for large books (tens of thousands of blocks) a
|
||||
single giant renderable made Textual's layout/paint pass take upwards of a
|
||||
minute, whereas the Line API only ever renders the rows on screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from textual import work
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, VerticalScroll
|
||||
from textual.geometry import Size
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
|
||||
|
||||
from . import api, blocks, cache, config as config_mod, layout, progress
|
||||
from .book_view import ContinuousBookView
|
||||
from .statusbar import StatusBar
|
||||
|
||||
MAX_LINE_WIDTH = 120
|
||||
AUTOSAVE_INTERVAL = 5.0
|
||||
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
|
||||
# so this stays constant — measuring the container's width and subtracting
|
||||
# this fixed amount avoids a measure-after-constrain race against book_view's
|
||||
# own (possibly already-constrained-from-a-previous-layout) width.
|
||||
SCROLLBAR_GUTTER = 2
|
||||
|
||||
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
|
||||
|
||||
# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see
|
||||
# remote.py) — reused here to find the server book id for progress push.
|
||||
_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ")
|
||||
|
||||
|
||||
def _server_book_id(path: Path) -> int | None:
|
||||
m = _SERVER_ID_RE.match(path.name)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
class FootnoteScreen(ModalScreen[None]):
|
||||
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
|
||||
|
||||
def __init__(self, notes: list[str]) -> None:
|
||||
super().__init__()
|
||||
self._notes = notes
|
||||
|
||||
def compose(self):
|
||||
with VerticalScroll(id="footnote-body"):
|
||||
for note in self._notes:
|
||||
yield Static(note, classes="footnote-note")
|
||||
yield Label("Escape/f zum Schließen", id="footnote-hint")
|
||||
|
||||
def action_dismiss_self(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
class ReaderScreen(Screen):
|
||||
BINDINGS = [
|
||||
Binding("j,down", "scroll_down_line", "Runter", show=False),
|
||||
Binding("k,up", "scroll_up_line", "Hoch", show=False),
|
||||
Binding("n", "next_chapter", "Nächstes Kapitel"),
|
||||
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
|
||||
Binding("f", "peek_footnote", "Fußnote"),
|
||||
Binding("q,escape", "back", "Zurück zur Bibliothek"),
|
||||
]
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.book: blocks.FlatBook | None = None
|
||||
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
|
||||
self._server_id = _server_book_id(path)
|
||||
self._remote_cfg = config_mod.load() if self._server_id is not None else None
|
||||
self._last_pushed_anchor = ""
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
yield LoadingIndicator(id="reader-loading")
|
||||
with Container(id="reader-scroll") as scroll_container:
|
||||
self._scroll_container = scroll_container
|
||||
# Stays visible (empty) from the start rather than toggling display
|
||||
# on once loaded — a widget that's just been switched from hidden
|
||||
# to visible hasn't been through a layout pass yet, so its `.size`
|
||||
# is still (0, 0) and an immediate scroll_to() right after has
|
||||
# nothing to clamp against and silently resets to 0.
|
||||
self._book_view = ContinuousBookView(_EMPTY_LAYOUT)
|
||||
yield self._book_view
|
||||
yield StatusBar()
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# Deferred rather than read synchronously here: right after mount the
|
||||
# container hasn't been through a layout pass yet, so its size isn't
|
||||
# reliable — same lesson as the scroll-restore race below.
|
||||
self.call_after_refresh(self._start_loading)
|
||||
|
||||
def _start_loading(self) -> None:
|
||||
if self._scroll_container.size.width == 0:
|
||||
# Not sized yet after all — keep deferring instead of guessing a
|
||||
# fixed number of refresh cycles (mirrors _restore_position below).
|
||||
self.call_after_refresh(self._start_loading)
|
||||
return
|
||||
self._load_book(self._content_width())
|
||||
|
||||
@work(thread=True)
|
||||
def _load_book(self, width: int) -> None:
|
||||
cached = cache.load(self.path, width)
|
||||
if cached is not None:
|
||||
book, book_layout = cached
|
||||
else:
|
||||
book = blocks.load_flat_book(self.path)
|
||||
console = Console(width=width)
|
||||
book_layout = layout.build_layout([b.text for b in book.blocks], width, console)
|
||||
cache.save(self.path, width, book, book_layout)
|
||||
self.app.call_from_thread(self._on_book_loaded, book, book_layout, width)
|
||||
|
||||
def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None:
|
||||
self.book = book
|
||||
self.sub_title = book.title
|
||||
self._apply_layout(book_layout, width)
|
||||
self.query_one("#reader-loading", LoadingIndicator).display = False
|
||||
self.call_after_refresh(self._restore_position)
|
||||
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
|
||||
|
||||
def _content_width(self) -> int:
|
||||
# Measuring the *container* rather than book_view's own size avoids a
|
||||
# measure-after-constrain race: once a layout has been applied,
|
||||
# book_view's width is pinned to a previous value via styles.width
|
||||
# (below), so re-measuring book_view itself on a later resize would
|
||||
# just read that stale pinned width back instead of the new
|
||||
# available space. The container's width is unaffected by that.
|
||||
available = self._scroll_container.size.width - SCROLLBAR_GUTTER
|
||||
return max(20, min(MAX_LINE_WIDTH, available))
|
||||
|
||||
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
|
||||
self.book_layout = book_layout
|
||||
# Pin book_view's outer width to content-width-plus-scrollbar so its
|
||||
# *inner* content region (what render_line actually draws into) ends
|
||||
# up exactly `width` — matching what block texts were wrapped at.
|
||||
self._book_view.styles.width = width + SCROLLBAR_GUTTER
|
||||
self._book_view.book_layout = book_layout
|
||||
self._book_view.virtual_size = Size(width, book_layout.total_rows)
|
||||
self._book_view.refresh()
|
||||
|
||||
def _build_layout(self) -> None:
|
||||
assert self.book is not None
|
||||
width = self._content_width()
|
||||
console = Console(width=width)
|
||||
block_texts = [b.text for b in self.book.blocks]
|
||||
new_layout = layout.build_layout(block_texts, width, console)
|
||||
cache.save(self.path, width, self.book, new_layout)
|
||||
self._apply_layout(new_layout, width)
|
||||
|
||||
def on_resize(self) -> None:
|
||||
if self.book is None:
|
||||
return
|
||||
old_y = self._book_view.scroll_y
|
||||
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
|
||||
self._build_layout()
|
||||
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
|
||||
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
|
||||
|
||||
def _restore_position(self, attempt: int = 0) -> None:
|
||||
if self.book is None:
|
||||
return
|
||||
saved = progress.load(self.book.id)
|
||||
if saved is None:
|
||||
return
|
||||
parsed = blocks.parse_anchor(saved.anchor)
|
||||
if parsed is None:
|
||||
return
|
||||
block_index, inner_fraction = parsed
|
||||
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
|
||||
if y <= 0:
|
||||
return
|
||||
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||
# A widget that's only just become part of the layout doesn't always
|
||||
# honor an immediate scroll on the first attempt (its own size/scroll
|
||||
# bounds can still be mid-update) — verify it actually landed and
|
||||
# retry a bounded number of times rather than guessing a fixed delay.
|
||||
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
|
||||
self.call_after_refresh(lambda: self._restore_position(attempt + 1))
|
||||
|
||||
def _current_anchor_str(self) -> str:
|
||||
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||
return blocks.format_anchor(block_index, inner_fraction)
|
||||
|
||||
def _autosave(self) -> None:
|
||||
self._save_progress()
|
||||
|
||||
def _save_progress(self) -> None:
|
||||
if self.book is None or not self.book_layout.heights:
|
||||
return
|
||||
anchor = self._current_anchor_str()
|
||||
advanced = progress.save(self.book.id, progress.Position(anchor=anchor))
|
||||
if advanced and self._remote_cfg is not None and self._server_id is not None:
|
||||
self._push_remote_progress(anchor)
|
||||
|
||||
@work(thread=True, exclusive=True, group="progress-push")
|
||||
def _push_remote_progress(self, anchor: str) -> None:
|
||||
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
|
||||
return
|
||||
try:
|
||||
api.post_progress(
|
||||
self._remote_cfg.server_url,
|
||||
self._remote_cfg.api_token,
|
||||
self._server_id,
|
||||
scroll_fraction=0.0,
|
||||
position_anchor=anchor,
|
||||
force=False,
|
||||
)
|
||||
self._last_pushed_anchor = anchor
|
||||
except Exception:
|
||||
pass # best-effort — local progress is already saved regardless
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._save_progress()
|
||||
|
||||
def action_scroll_down_line(self) -> None:
|
||||
self._book_view.scroll_relative(y=1, animate=False)
|
||||
|
||||
def action_scroll_up_line(self) -> None:
|
||||
self._book_view.scroll_relative(y=-1, animate=False)
|
||||
|
||||
def _current_chapter_index(self) -> int:
|
||||
if self.book is None:
|
||||
return 0
|
||||
block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||
return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1
|
||||
|
||||
def _jump_to_block(self, block_index: int) -> None:
|
||||
y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0)
|
||||
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||
|
||||
def action_next_chapter(self) -> None:
|
||||
if self.book is None:
|
||||
return
|
||||
chapter = self._current_chapter_index()
|
||||
if chapter + 1 < len(self.book.chapter_start_block):
|
||||
self._jump_to_block(self.book.chapter_start_block[chapter + 1])
|
||||
self._save_progress()
|
||||
|
||||
def action_prev_chapter(self) -> None:
|
||||
if self.book is None:
|
||||
return
|
||||
chapter = self._current_chapter_index()
|
||||
if chapter > 0:
|
||||
self._jump_to_block(self.book.chapter_start_block[chapter - 1])
|
||||
self._save_progress()
|
||||
|
||||
def action_peek_footnote(self) -> None:
|
||||
if self.book is None or not self.book_layout.heights:
|
||||
return
|
||||
current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||
|
||||
forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes]
|
||||
backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes]
|
||||
candidate = forward[0] if forward else (backward[-1] if backward else None)
|
||||
if candidate is None:
|
||||
self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3)
|
||||
return
|
||||
|
||||
_, block = candidate
|
||||
notes: list[str] = []
|
||||
for ref in block.footnotes:
|
||||
target_idx = self.book.footnote_targets.get(ref.target_id)
|
||||
if target_idx is None:
|
||||
continue
|
||||
notes.append(self.book.blocks[target_idx].text)
|
||||
if not notes:
|
||||
self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3)
|
||||
return
|
||||
self.app.push_screen(FootnoteScreen(notes))
|
||||
|
||||
def action_back(self) -> None:
|
||||
self.app.pop_screen()
|
||||
110
tui/diora_tui/remote.py
Normal file
110
tui/diora_tui/remote.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Fetch + decrypt books from a diora server into the local TUI library, and
|
||||
pull reading progress for them into the local progress store.
|
||||
|
||||
Progress is pull-only here (server -> local); the reader pushes local ->
|
||||
server itself while a book is open (see reader_screen.py). Both directions
|
||||
use the same "blockIndex:innerFraction" anchor format as the web reader
|
||||
(diora_tui/blocks.py) and the same furthest-wins merge rule
|
||||
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
|
||||
only ever advance local progress, never regress it, unless forced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from . import api, crypto, library_meta
|
||||
from .config import RemoteConfig
|
||||
from .epub import _book_id
|
||||
from .progress import Position, save as save_progress
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
downloaded: list[str] = field(default_factory=list)
|
||||
unchanged: int = 0
|
||||
skipped_pdf: list[str] = field(default_factory=list)
|
||||
failed: list[str] = field(default_factory=list)
|
||||
progress_pulled: int = 0
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
name = re.sub(r"[^\w\s.-]", "_", name).strip()
|
||||
return (name or "buch")[:80]
|
||||
|
||||
|
||||
def _parse_meta(raw: bytes) -> dict:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
|
||||
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
||||
try:
|
||||
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
|
||||
except api.ApiError as e:
|
||||
raise SyncError(str(e)) from e
|
||||
except requests.RequestException as e:
|
||||
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
|
||||
|
||||
library_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = SyncResult()
|
||||
local_path_by_server_id: dict[int, Path] = {}
|
||||
|
||||
for book in snapshot.get("books", []):
|
||||
book_id = book["id"]
|
||||
try:
|
||||
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"]))
|
||||
except crypto.DecryptError:
|
||||
result.failed.append(f"#{book_id}")
|
||||
continue
|
||||
|
||||
if meta.get("type") == "pdf":
|
||||
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
|
||||
continue
|
||||
|
||||
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
|
||||
if existing is not None:
|
||||
result.unchanged += 1
|
||||
local_path_by_server_id[book_id] = existing
|
||||
continue
|
||||
|
||||
try:
|
||||
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id)
|
||||
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
|
||||
except (api.ApiError, crypto.DecryptError, requests.RequestException):
|
||||
result.failed.append(meta.get("title") or f"#{book_id}")
|
||||
continue
|
||||
|
||||
title = meta.get("title") or f"book-{book_id}"
|
||||
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
|
||||
dest.write_bytes(raw)
|
||||
result.downloaded.append(dest.name)
|
||||
local_path_by_server_id[book_id] = dest
|
||||
|
||||
for entry in snapshot.get("book_progress", []):
|
||||
anchor = entry.get("position_anchor") or ""
|
||||
if not anchor:
|
||||
continue # PDF-only scroll_fraction progress — this TUI is EPUB-only
|
||||
dest = local_path_by_server_id.get(entry.get("book_id"))
|
||||
if dest is None:
|
||||
continue
|
||||
local_id = _book_id(dest)
|
||||
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
|
||||
44
tui/diora_tui/statusbar.py
Normal file
44
tui/diora_tui/statusbar.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Bottom status bar: battery level + current time, refreshed every second.
|
||||
Battery is read via psutil (cross-platform); on a desktop machine with no
|
||||
battery, psutil.sensors_battery() returns None and the bar just shows time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from textual.widgets import Static
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
psutil = None # battery display degrades gracefully to time-only
|
||||
|
||||
|
||||
class StatusBar(Static):
|
||||
def on_mount(self) -> None:
|
||||
self._refresh()
|
||||
self.set_interval(1.0, self._refresh)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
self.update(self._render_text())
|
||||
|
||||
def _render_text(self) -> str:
|
||||
parts = []
|
||||
battery = self._battery_text()
|
||||
if battery:
|
||||
parts.append(battery)
|
||||
parts.append(datetime.now().strftime("%H:%M:%S"))
|
||||
return " ".join(parts)
|
||||
|
||||
def _battery_text(self) -> str | None:
|
||||
if psutil is None:
|
||||
return None
|
||||
try:
|
||||
battery = psutil.sensors_battery()
|
||||
except Exception:
|
||||
return None
|
||||
if battery is None:
|
||||
return None
|
||||
state = "lädt" if battery.power_plugged else "Akku"
|
||||
return f"{state} {round(battery.percent)}%"
|
||||
25
tui/pyproject.toml
Normal file
25
tui/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[project]
|
||||
name = "diora-tui"
|
||||
version = "0.1.0"
|
||||
description = "Terminal-EPUB-Reader für diora mit Server-Sync für Bücher und Lesefortschritt."
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"textual>=0.60",
|
||||
"ebooklib>=0.18",
|
||||
"beautifulsoup4>=4.12",
|
||||
"lxml>=5.0",
|
||||
"platformdirs>=4.0",
|
||||
"requests>=2.31",
|
||||
"cryptography>=42.0",
|
||||
"psutil>=5.9",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
diora-tui = "diora_tui.app:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["diora_tui"]
|
||||
Loading…
Add table
Reference in a new issue