TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync
Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py, reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung (Terminal hat keine Pixel-Geometrie), was funktioniert, weil der Furthest-Wins-Vergleich primär nach blockIndex sortiert. Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt eines einzelnen riesigen Static — bei großen Büchern (mehrere reale heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und zentriert. Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js' _looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele häufig auf einem inneren <a> statt dem umschließenden <p> sitzen. Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei offenen server-verknüpften Büchern Updates zurück (force: false, im Hintergrund-Worker). Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback localStorage.getItem(...) falls die Clipboard-API in der Konsole verweigert wird. Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a. 3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll, Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s, warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine Race-Condition beim ersten Post-Load-Scroll, und Progress-Push End-to-End gegen einen echten Dev-Server verifiziert.
This commit is contained in:
parent
50f711831c
commit
db3f520632
13 changed files with 794 additions and 201 deletions
|
|
@ -1,16 +1,20 @@
|
||||||
# diora-tui
|
# diora-tui
|
||||||
|
|
||||||
Lokaler EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
|
EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
|
||||||
|
|
||||||
Läuft komplett offline gegen eine lokale Bibliothek aus `.epub`-Dateien und merkt sich
|
Zeigt ein Buch als **eine durchgehende Ansicht** über alle Kapitel hinweg (wie diora's
|
||||||
den Lesefortschritt pro Buch (`progress.json` im plattformüblichen Datenverzeichnis,
|
Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
|
||||||
z.B. `~/.local/share/diora-tui/` unter Linux, via `platformdirs`). Der Fortschritt wird
|
`"blockIndex:innerFraction"`-Positionsangabe geführt wie der Web-Reader (`books/models.py`,
|
||||||
— genau wie im Web-Reader von diora (siehe `books/models.py`, `save_progress`) — nur
|
`EBookProgress`) — `blockIndex` zählt dabei exakt wie `static/js/app.js`s
|
||||||
vorwärts überschrieben ("furthest wins"), damit ein älterer/gestaffelter Lauf nie eine
|
`EPUB_BLOCK_SELECTOR` (`p, h1-h6, li, blockquote, dt, dd, figcaption` + textbasierte
|
||||||
bereits weiter gelesene Position zurücksetzt.
|
`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).
|
||||||
|
|
||||||
`diora-tui sync` kann Bücher jetzt vom diora-Server holen und lokal entschlüsseln (siehe
|
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben
|
||||||
unten) — Lesefortschritt bleibt aber weiterhin rein lokal, siehe **Grenzen** unten.
|
("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
|
## Setup
|
||||||
|
|
||||||
|
|
@ -28,11 +32,18 @@ diora-tui --library ~/Books # Standard: ~/Books
|
||||||
Tastenkürzel:
|
Tastenkürzel:
|
||||||
|
|
||||||
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
|
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
|
||||||
- `n` / `]` — nächstes Kapitel, `p` / `[` — vorheriges Kapitel
|
- `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
|
- `Enter` — markiertes Buch aus der Bibliothek öffnen
|
||||||
- `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)
|
||||||
|
|
||||||
## Bücher vom Server holen (`diora-tui sync`)
|
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
|
||||||
|
volle Terminalbreite bei breiten Fenstern).
|
||||||
|
|
||||||
|
## Bücher + Fortschritt vom Server holen (`diora-tui sync`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
|
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
|
||||||
|
|
@ -43,7 +54,14 @@ Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` h
|
||||||
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als
|
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
|
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
|
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden
|
||||||
beim nächsten Lauf übersprungen (kein erneuter Download).
|
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
|
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
|
||||||
`~/.config/diora-tui/config.json`, `chmod 600`):
|
`~/.config/diora-tui/config.json`, `chmod 600`):
|
||||||
|
|
@ -64,18 +82,29 @@ Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unt
|
||||||
`app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, 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
|
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
|
lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins
|
||||||
Clipboard, von dort ins `sync`-Prompt einfügen.
|
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
|
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.
|
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
|
## Grenzen der aktuellen Version
|
||||||
|
|
||||||
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,
|
- 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.
|
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
|
||||||
- Text wird pro Kapitel als Fließtext ohne Bild-/Layout-Rendering dargestellt.
|
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
|
||||||
- Lesefortschritt bleibt rein lokal — `sync` holt nur Bücher, keinen Fortschritt. Der
|
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
|
||||||
Web-Reader verankert Position als `"blockIndex:innerFraction"` in seiner eigenen, über
|
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung
|
||||||
das ganze Buch laufenden Absatz-Nummerierung; diese TUI zählt Position dagegen pro
|
zählt primär `blockIndex`, der exakt mit dem Web-Reader übereinstimmt; `innerFraction`
|
||||||
Kapitel. Ohne eine echte Übersetzung zwischen beiden Schemata würde ein naiver Abgleich
|
ist nur ein Tiebreaker innerhalb desselben Blocks.
|
||||||
falsche Positionen liefern — deshalb bewusst (noch) nicht gebaut.
|
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,17 @@ def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
|
||||||
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ from pathlib import Path
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import VerticalScroll
|
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import Footer, Header, Label, ListItem, ListView, Static
|
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, progress, remote
|
from . import crypto, epub, remote
|
||||||
|
from .reader_screen import ReaderScreen
|
||||||
|
|
||||||
DEFAULT_LIBRARY = Path.home() / "Books"
|
DEFAULT_LIBRARY = Path.home() / "Books"
|
||||||
|
|
||||||
|
|
@ -57,89 +57,6 @@ class LibraryScreen(Screen):
|
||||||
self.action_open_selected()
|
self.action_open_selected()
|
||||||
|
|
||||||
|
|
||||||
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("q,escape", "back", "Zurück zur Bibliothek"),
|
|
||||||
]
|
|
||||||
|
|
||||||
AUTOSAVE_INTERVAL = 5.0
|
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.book = epub.load_epub(path)
|
|
||||||
self.chapter_index = 0
|
|
||||||
saved = progress.load(self.book.id)
|
|
||||||
if saved is not None and 0 <= saved.chapter_index < len(self.book.chapters):
|
|
||||||
self.chapter_index = saved.chapter_index
|
|
||||||
self._pending_scroll_fraction = saved.scroll_fraction if saved else 0.0
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
self._text = Static(id="chapter-text")
|
|
||||||
yield Header()
|
|
||||||
with VerticalScroll(id="reader-scroll") as scroll:
|
|
||||||
self._scroll = scroll
|
|
||||||
yield self._text
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._render_chapter(restore_scroll=True)
|
|
||||||
self.set_interval(self.AUTOSAVE_INTERVAL, self._save_progress)
|
|
||||||
|
|
||||||
def on_unmount(self) -> None:
|
|
||||||
self._save_progress()
|
|
||||||
|
|
||||||
def _render_chapter(self, *, restore_scroll: bool = False) -> None:
|
|
||||||
chapter = self.book.chapters[self.chapter_index]
|
|
||||||
text = "\n\n".join(chapter.paragraphs)
|
|
||||||
self._text.update(text)
|
|
||||||
self.sub_title = f"{self.book.title} — Kapitel {self.chapter_index + 1}/{len(self.book.chapters)}"
|
|
||||||
|
|
||||||
self._scroll.scroll_home(animate=False)
|
|
||||||
if restore_scroll and self._pending_scroll_fraction:
|
|
||||||
fraction = self._pending_scroll_fraction
|
|
||||||
self.call_after_refresh(lambda: self._scroll_to_fraction(fraction))
|
|
||||||
|
|
||||||
def _scroll_to_fraction(self, fraction: float) -> None:
|
|
||||||
max_y = max(0, self._scroll.virtual_size.height - self._scroll.size.height)
|
|
||||||
self._scroll.scroll_to(y=round(max_y * fraction), animate=False)
|
|
||||||
|
|
||||||
def _current_fraction(self) -> float:
|
|
||||||
max_y = max(1, self._scroll.virtual_size.height - self._scroll.size.height)
|
|
||||||
return max(0.0, min(1.0, self._scroll.scroll_y / max_y))
|
|
||||||
|
|
||||||
def _save_progress(self) -> None:
|
|
||||||
progress.save(
|
|
||||||
self.book.id,
|
|
||||||
progress.Position(chapter_index=self.chapter_index, scroll_fraction=self._current_fraction()),
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_scroll_down_line(self) -> None:
|
|
||||||
self._scroll.scroll_relative(y=1, animate=False)
|
|
||||||
|
|
||||||
def action_scroll_up_line(self) -> None:
|
|
||||||
self._scroll.scroll_relative(y=-1, animate=False)
|
|
||||||
|
|
||||||
def action_next_chapter(self) -> None:
|
|
||||||
if self.chapter_index + 1 < len(self.book.chapters):
|
|
||||||
self.chapter_index += 1
|
|
||||||
self._pending_scroll_fraction = 0.0
|
|
||||||
self._render_chapter()
|
|
||||||
self._save_progress()
|
|
||||||
|
|
||||||
def action_prev_chapter(self) -> None:
|
|
||||||
if self.chapter_index > 0:
|
|
||||||
self.chapter_index -= 1
|
|
||||||
self._pending_scroll_fraction = 0.0
|
|
||||||
self._render_chapter()
|
|
||||||
|
|
||||||
def action_back(self) -> None:
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
|
|
||||||
class DioraTuiApp(App):
|
class DioraTuiApp(App):
|
||||||
CSS_PATH = "app.tcss"
|
CSS_PATH = "app.tcss"
|
||||||
TITLE = "diora-tui"
|
TITLE = "diora-tui"
|
||||||
|
|
@ -219,6 +136,8 @@ def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> N
|
||||||
if result.failed:
|
if result.failed:
|
||||||
titles = ", ".join(result.failed)
|
titles = ", ".join(result.failed)
|
||||||
print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}")
|
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:
|
def main() -> None:
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,36 @@
|
||||||
#reader-scroll {
|
#reader-scroll {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
|
align-horizontal: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chapter-text {
|
#reader-loading {
|
||||||
width: 100%;
|
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
|
||||||
29
tui/diora_tui/book_view.py
Normal file
29
tui/diora_tui/book_view.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""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):
|
||||||
|
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
|
||||||
|
if row < 0 or row >= len(strips):
|
||||||
|
return Strip.blank(self.size.width, self.rich_style)
|
||||||
|
return strips[row].crop_extend(0, self.size.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
|
||||||
|
|
@ -1,30 +1,11 @@
|
||||||
"""EPUB parsing: turn a .epub file into a spine-ordered list of chapters."""
|
"""Library scanning and the stable per-book id shared across progress.py,
|
||||||
|
blocks.py, and remote.py."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ebooklib
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from ebooklib import epub
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Chapter:
|
|
||||||
title: str
|
|
||||||
paragraphs: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Book:
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
author: str
|
|
||||||
path: Path
|
|
||||||
chapters: list[Chapter]
|
|
||||||
|
|
||||||
|
|
||||||
def _book_id(path: Path) -> str:
|
def _book_id(path: Path) -> str:
|
||||||
# Identifies a book by its resolved path for now. Once the sync API exists,
|
# Identifies a book by its resolved path for now. Once the sync API exists,
|
||||||
|
|
@ -32,49 +13,6 @@ def _book_id(path: Path) -> str:
|
||||||
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
|
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
def _extract_paragraphs(html: bytes) -> list[str]:
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
for tag in soup(["script", "style"]):
|
|
||||||
tag.decompose()
|
|
||||||
|
|
||||||
paragraphs = []
|
|
||||||
for el in soup.find_all(["p", "h1", "h2", "h3", "h4", "li", "blockquote"]):
|
|
||||||
text = el.get_text(" ", strip=True)
|
|
||||||
if text:
|
|
||||||
paragraphs.append(text)
|
|
||||||
|
|
||||||
if not paragraphs:
|
|
||||||
text = soup.get_text(" ", strip=True)
|
|
||||||
if text:
|
|
||||||
paragraphs.append(text)
|
|
||||||
|
|
||||||
return paragraphs
|
|
||||||
|
|
||||||
|
|
||||||
def load_epub(path: Path) -> Book:
|
|
||||||
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"
|
|
||||||
|
|
||||||
chapters: list[Chapter] = []
|
|
||||||
for idref, linear in raw.spine:
|
|
||||||
if linear == "no":
|
|
||||||
continue
|
|
||||||
item = raw.get_item_with_id(idref)
|
|
||||||
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
|
||||||
continue
|
|
||||||
paragraphs = _extract_paragraphs(item.get_content())
|
|
||||||
if not paragraphs:
|
|
||||||
continue
|
|
||||||
chapters.append(Chapter(title=paragraphs[0][:60], paragraphs=paragraphs))
|
|
||||||
|
|
||||||
return Book(id=_book_id(path), title=title, author=author, path=path, chapters=chapters)
|
|
||||||
|
|
||||||
|
|
||||||
def scan_library(library_dir: Path) -> list[Path]:
|
def scan_library(library_dir: Path) -> list[Path]:
|
||||||
if not library_dir.exists():
|
if not library_dir.exists():
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
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)
|
||||||
|
|
@ -1,28 +1,33 @@
|
||||||
"""Local reading-progress storage.
|
"""Local reading-progress storage.
|
||||||
|
|
||||||
Mirrors diora's web-reader "furthest wins" semantics (see books/models.py,
|
Positions are stored as the same "blockIndex:innerFraction" anchor string the
|
||||||
EBookProgress.save_progress): a saved position only ever advances, so an
|
server uses (books/models.py, EBookProgress.save_progress) — see
|
||||||
older or offline run can't regress a further-along read position. The
|
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
|
||||||
position is kept anchor-shaped (chapter index + fraction within it) so a
|
exactly, and anchor_is_further() for the identical "furthest wins" comparison
|
||||||
future sync layer against diora's API can adopt it without a rewrite.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import asdict, dataclass
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from platformdirs import user_data_dir
|
from platformdirs import user_data_dir
|
||||||
|
|
||||||
|
from .blocks import anchor_is_further
|
||||||
|
|
||||||
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||||
_PROGRESS_FILE = _DATA_DIR / "progress.json"
|
_PROGRESS_FILE = _DATA_DIR / "progress.json"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Position:
|
class Position:
|
||||||
chapter_index: int
|
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
|
||||||
scroll_fraction: float # 0..1 within the chapter's paragraph list
|
updated_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
def _load_all() -> dict[str, dict]:
|
def _load_all() -> dict[str, dict]:
|
||||||
|
|
@ -41,23 +46,23 @@ def _save_all(data: dict[str, dict]) -> None:
|
||||||
|
|
||||||
def load(book_id: str) -> Position | None:
|
def load(book_id: str) -> Position | None:
|
||||||
entry = _load_all().get(book_id)
|
entry = _load_all().get(book_id)
|
||||||
if entry is None:
|
if not isinstance(entry, dict) or not entry.get("anchor"):
|
||||||
return None
|
return None
|
||||||
return Position(chapter_index=entry["chapter_index"], scroll_fraction=entry["scroll_fraction"])
|
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
|
||||||
|
|
||||||
|
|
||||||
def _is_further(new: Position, old: Position) -> bool:
|
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
|
||||||
if new.chapter_index != old.chapter_index:
|
"""Returns True if the position was actually written (i.e. it was further
|
||||||
return new.chapter_index > old.chapter_index
|
along, or forced) — callers that push to the server only need to do so
|
||||||
return new.scroll_fraction > old.scroll_fraction
|
when this returns True."""
|
||||||
|
|
||||||
|
|
||||||
def save(book_id: str, position: Position, *, force: bool = False) -> None:
|
|
||||||
data = _load_all()
|
data = _load_all()
|
||||||
existing = data.get(book_id)
|
existing = data.get(book_id)
|
||||||
if existing is not None and not force:
|
if existing is not None and not force:
|
||||||
old = Position(chapter_index=existing["chapter_index"], scroll_fraction=existing["scroll_fraction"])
|
old_anchor = existing.get("anchor", "")
|
||||||
if not _is_further(position, old):
|
if not anchor_is_further(position.anchor, old_anchor):
|
||||||
return
|
return False
|
||||||
data[book_id] = asdict(position)
|
if not position.updated_at:
|
||||||
|
position.updated_at = time.time()
|
||||||
|
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
|
||||||
_save_all(data)
|
_save_all(data)
|
||||||
|
return True
|
||||||
|
|
|
||||||
257
tui/diora_tui/reader_screen.py
Normal file
257
tui/diora_tui/reader_screen.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
MAX_LINE_WIDTH = 120
|
||||||
|
AUTOSAVE_INTERVAL = 5.0
|
||||||
|
|
||||||
|
_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"):
|
||||||
|
# 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 Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
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:
|
||||||
|
return max(20, min(MAX_LINE_WIDTH, self.size.width - 4))
|
||||||
|
|
||||||
|
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
|
||||||
|
self.book_layout = book_layout
|
||||||
|
self._book_view.styles.width = width
|
||||||
|
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()
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""Fetch + decrypt books from a diora server into the local TUI library.
|
"""Fetch + decrypt books from a diora server into the local TUI library, and
|
||||||
|
pull reading progress for them into the local progress store.
|
||||||
|
|
||||||
Reading progress is intentionally NOT synced yet: the server tracks EPUB
|
Progress is pull-only here (server -> local); the reader pushes local ->
|
||||||
position as a "blockIndex:innerFraction" anchor into the *web* reader's flat
|
server itself while a book is open (see reader_screen.py). Both directions
|
||||||
paragraph numbering (see books/models.py, EBookProgress), which doesn't line
|
use the same "blockIndex:innerFraction" anchor format as the web reader
|
||||||
up with this TUI's per-chapter scroll_fraction (diora_tui/progress.py)
|
(diora_tui/blocks.py) and the same furthest-wins merge rule
|
||||||
without an actual translation layer between the two block-numbering
|
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
|
||||||
schemes. Downloaded books simply start fresh in the local progress store;
|
only ever advance local progress, never regress it, unless forced.
|
||||||
wiring up that translation is future work.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -20,6 +20,8 @@ import requests
|
||||||
|
|
||||||
from . import api, crypto
|
from . import api, crypto
|
||||||
from .config import RemoteConfig
|
from .config import RemoteConfig
|
||||||
|
from .epub import _book_id
|
||||||
|
from .progress import Position, save as save_progress
|
||||||
|
|
||||||
|
|
||||||
class SyncError(Exception):
|
class SyncError(Exception):
|
||||||
|
|
@ -32,6 +34,7 @@ class SyncResult:
|
||||||
unchanged: int = 0
|
unchanged: int = 0
|
||||||
skipped_pdf: list[str] = field(default_factory=list)
|
skipped_pdf: list[str] = field(default_factory=list)
|
||||||
failed: list[str] = field(default_factory=list)
|
failed: list[str] = field(default_factory=list)
|
||||||
|
progress_pulled: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_filename(name: str) -> str:
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
|
@ -53,6 +56,7 @@ def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
||||||
|
|
||||||
library_dir.mkdir(parents=True, exist_ok=True)
|
library_dir.mkdir(parents=True, exist_ok=True)
|
||||||
result = SyncResult()
|
result = SyncResult()
|
||||||
|
local_path_by_server_id: dict[int, Path] = {}
|
||||||
|
|
||||||
for book in snapshot.get("books", []):
|
for book in snapshot.get("books", []):
|
||||||
book_id = book["id"]
|
book_id = book["id"]
|
||||||
|
|
@ -69,6 +73,7 @@ def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
||||||
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
|
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
result.unchanged += 1
|
result.unchanged += 1
|
||||||
|
local_path_by_server_id[book_id] = existing
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -82,5 +87,17 @@ def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
||||||
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
|
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
|
||||||
dest.write_bytes(raw)
|
dest.write_bytes(raw)
|
||||||
result.downloaded.append(dest.name)
|
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
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
[project]
|
[project]
|
||||||
name = "diora-tui"
|
name = "diora-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Lokaler EPUB-Reader als Vorstufe eines diora-TUI-Clients; synct später Fortschritt über die diora-API."
|
description = "Terminal-EPUB-Reader für diora mit Server-Sync für Bücher und Lesefortschritt."
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"textual>=0.60",
|
"textual>=0.60",
|
||||||
"ebooklib>=0.18",
|
"ebooklib>=0.18",
|
||||||
"beautifulsoup4>=4.12",
|
"beautifulsoup4>=4.12",
|
||||||
|
"lxml>=5.0",
|
||||||
"platformdirs>=4.0",
|
"platformdirs>=4.0",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
"cryptography>=42.0",
|
"cryptography>=42.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue