diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..db7531e --- /dev/null +++ b/tui/README.md @@ -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. diff --git a/tui/diora_tui/__init__.py b/tui/diora_tui/__init__.py new file mode 100644 index 0000000..d6a2003 --- /dev/null +++ b/tui/diora_tui/__init__.py @@ -0,0 +1 @@ +"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients.""" diff --git a/tui/diora_tui/__main__.py b/tui/diora_tui/__main__.py new file mode 100644 index 0000000..eb5551b --- /dev/null +++ b/tui/diora_tui/__main__.py @@ -0,0 +1,4 @@ +from diora_tui.app import main + +if __name__ == "__main__": + main() diff --git a/tui/diora_tui/app.py b/tui/diora_tui/app.py new file mode 100644 index 0000000..ffa76b3 --- /dev/null +++ b/tui/diora_tui/app.py @@ -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() diff --git a/tui/diora_tui/app.tcss b/tui/diora_tui/app.tcss new file mode 100644 index 0000000..9b0b4b4 --- /dev/null +++ b/tui/diora_tui/app.tcss @@ -0,0 +1,12 @@ +#library-list { + height: 1fr; +} + +#reader-scroll { + height: 1fr; + padding: 1 2; +} + +#chapter-text { + width: 100%; +} diff --git a/tui/diora_tui/epub.py b/tui/diora_tui/epub.py new file mode 100644 index 0000000..2ca16f9 --- /dev/null +++ b/tui/diora_tui/epub.py @@ -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")) diff --git a/tui/diora_tui/progress.py b/tui/diora_tui/progress.py new file mode 100644 index 0000000..ba10fcf --- /dev/null +++ b/tui/diora_tui/progress.py @@ -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) diff --git a/tui/pyproject.toml b/tui/pyproject.toml new file mode 100644 index 0000000..3e8fc6f --- /dev/null +++ b/tui/pyproject.toml @@ -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"]