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:
parent
bb143a249b
commit
f63bd1f879
8 changed files with 390 additions and 0 deletions
40
tui/README.md
Normal file
40
tui/README.md
Normal 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.
|
||||||
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()
|
||||||
168
tui/diora_tui/app.py
Normal file
168
tui/diora_tui/app.py
Normal 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
12
tui/diora_tui/app.tcss
Normal 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
81
tui/diora_tui/epub.py
Normal 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
63
tui/diora_tui/progress.py
Normal 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
21
tui/pyproject.toml
Normal 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"]
|
||||||
Loading…
Add table
Reference in a new issue