TUI: lokaler EPUB-Reader als erste Stufe eines diora-TUI-Clients

Python + Textual, unter tui/. Liest EPUBs aus einem Bibliotheksordner,
zeigt Kapitel als Fließtext, j/k/Pfeiltasten zum Scrollen, n/p für
Kapitelwechsel. Fortschritt wird lokal (progress.json via platformdirs)
nach demselben "furthest wins"-Prinzip wie diora's Web-Reader gespeichert,
damit ein Sync-Layer gegen die künftige diora-API später ohne Rewrite
andocken kann. Vorerst rein lokal, kein Server-Kontakt, keine
Verschlüsselung.
This commit is contained in:
marwin 2026-08-15 16:04:18 +02:00
parent bb143a249b
commit f63bd1f879
8 changed files with 390 additions and 0 deletions

40
tui/README.md Normal file
View file

@ -0,0 +1,40 @@
# diora-tui
Lokaler 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
den Lesefortschritt pro Buch (`progress.json` im plattformüblichen Datenverzeichnis,
z.B. `~/.local/share/diora-tui/` unter Linux, via `platformdirs`). Der Fortschritt wird
— genau wie im Web-Reader von diora (siehe `books/models.py`, `save_progress`) — nur
vorwärts überschrieben ("furthest wins"), damit ein älterer/gestaffelter Lauf nie eine
bereits weiter gelesene Position zurücksetzt.
Sobald die diora-Sync-API für E-Book-Fortschritt steht, bekommt dieses Tool einen
Sync-Layer, der `progress.json` gegen den Server abgleicht. Bis dahin ist alles rein
lokal — keine Verschlüsselung, kein Server-Kontakt, keine Kenntnis von diora-Accounts.
## 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
- `Enter` — markiertes Buch aus der Bibliothek öffnen
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek)
## Grenzen der aktuellen Version
- Nur EPUB, kein PDF (diora selbst unterstützt beides).
- Text wird pro Kapitel als Fließtext ohne Bild-/Layout-Rendering dargestellt.
- Fortschritt ist rein lokal; es gibt noch keinen Login/Sync gegen eine diora-Instanz.

View file

@ -0,0 +1 @@
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""

View file

@ -0,0 +1,4 @@
from diora_tui.app import main
if __name__ == "__main__":
main()

168
tui/diora_tui/app.py Normal file
View file

@ -0,0 +1,168 @@
"""diora-tui: local EPUB reader, precursor to a TUI client that syncs against
diora's e-book progress API once it exists (see project README)."""
from __future__ import annotations
import argparse
from pathlib import Path
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import VerticalScroll
from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView, Static
from . import epub, progress
DEFAULT_LIBRARY = Path.home() / "Books"
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
Binding("q", "quit", "Beenden"),
]
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.paths: list[Path] = []
def compose(self) -> ComposeResult:
yield Header()
yield ListView(id="library-list")
yield Footer()
def on_mount(self) -> None:
self.sub_title = str(self.library_dir)
self.paths = epub.scan_library(self.library_dir)
list_view = self.query_one("#library-list", ListView)
if not self.paths:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return
for path in self.paths:
list_view.append(ListItem(Label(path.stem)))
list_view.index = 0
list_view.focus()
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.paths or list_view.index is None:
return
self.app.push_screen(ReaderScreen(self.paths[list_view.index]))
def on_list_view_selected(self, event: ListView.Selected) -> None:
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):
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))
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})",
)
args = parser.parse_args()
DioraTuiApp(args.library.expanduser()).run()
if __name__ == "__main__":
main()

12
tui/diora_tui/app.tcss Normal file
View file

@ -0,0 +1,12 @@
#library-list {
height: 1fr;
}
#reader-scroll {
height: 1fr;
padding: 1 2;
}
#chapter-text {
width: 100%;
}

81
tui/diora_tui/epub.py Normal file
View file

@ -0,0 +1,81 @@
"""EPUB parsing: turn a .epub file into a spine-ordered list of chapters."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
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:
# 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 _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]:
if not library_dir.exists():
return []
return sorted(library_dir.rglob("*.epub"))

63
tui/diora_tui/progress.py Normal file
View file

@ -0,0 +1,63 @@
"""Local reading-progress storage.
Mirrors diora's web-reader "furthest wins" semantics (see books/models.py,
EBookProgress.save_progress): a saved position only ever advances, so an
older or offline run can't regress a further-along read position. The
position is kept anchor-shaped (chapter index + fraction within it) so a
future sync layer against diora's API can adopt it without a rewrite.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from platformdirs import user_data_dir
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_PROGRESS_FILE = _DATA_DIR / "progress.json"
@dataclass
class Position:
chapter_index: int
scroll_fraction: float # 0..1 within the chapter's paragraph list
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 entry is None:
return None
return Position(chapter_index=entry["chapter_index"], scroll_fraction=entry["scroll_fraction"])
def _is_further(new: Position, old: Position) -> bool:
if new.chapter_index != old.chapter_index:
return new.chapter_index > old.chapter_index
return new.scroll_fraction > old.scroll_fraction
def save(book_id: str, position: Position, *, force: bool = False) -> None:
data = _load_all()
existing = data.get(book_id)
if existing is not None and not force:
old = Position(chapter_index=existing["chapter_index"], scroll_fraction=existing["scroll_fraction"])
if not _is_further(position, old):
return
data[book_id] = asdict(position)
_save_all(data)

21
tui/pyproject.toml Normal file
View file

@ -0,0 +1,21 @@
[project]
name = "diora-tui"
version = "0.1.0"
description = "Lokaler EPUB-Reader als Vorstufe eines diora-TUI-Clients; synct später Fortschritt über die diora-API."
requires-python = ">=3.9"
dependencies = [
"textual>=0.60",
"ebooklib>=0.18",
"beautifulsoup4>=4.12",
"platformdirs>=4.0",
]
[project.scripts]
diora-tui = "diora_tui.app:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["diora_tui"]