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