diff --git a/tui/README.md b/tui/README.md index 85c2afb..3de1f92 100644 --- a/tui/README.md +++ b/tui/README.md @@ -37,8 +37,10 @@ Tastenkürzel: zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s `_looksLikeFootnoteLink` (Link in/um ``, Klassenname mit note/footnote/fn, oder `epub:type="noteref"`) -- `Enter` — markiertes Buch aus der Bibliothek öffnen -- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten) +- Bibliothek: `Enter` öffnet das markierte Buch, `c` das Buch der „Weiterlesen“-Karte, `1`–`4` + Status (Alle · Angefangen · Ungelesen · Gelesen), `o` Ordner, `/` Suche, `s` Sortierung, + `m` Bücher ⇄ Zeitschriften, `Escape` setzt Suche und Filter zurück. Chips und Karte lassen + sich auch anklicken. - `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek) Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als @@ -47,10 +49,30 @@ volle Terminalbreite bei breiten Fenstern). Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit, sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht. -Die Bibliotheksansicht ist nach der zuletzt gelesenen Stelle sortiert (neueste zuerst, auch -was auf einem anderen Gerät gelesen wurde), zeigt den Lesestand in % und blendet gelesene -Bücher standardmäßig aus (`r` zeigt sie, für diese Sitzung). Bücher vom Server stehen mit ☁ -in der Liste, bevor sie heruntergeladen sind. +## Die Bibliothek + +Wie die Bibliothek im Web (`docs/library.md`), nur im Terminal: + +- **Weiterlesen-Karte** oben (Titel, Autor, Balken, „Seite 132 von 186“, wann und auf welchem + Gerät zuletzt gelesen) und darunter die Bücher, die davor gelesen wurden. Das Buch der Karte + steht nicht noch einmal in der Liste. Bei Suche und Filtern entfällt die Karte. +- **Chips:** Alle · Angefangen · Ungelesen · Gelesen (mit Anzahl), dann die Ordner in der + Reihenfolge, die im Web eingestellt ist (verschlüsselte Bibliothekseinstellungen), und + „Ohne Ordner“. Ein Buch kann in mehreren Ordnern stehen. +- **Suche** in Titel und Autor, ohne Beachtung von Groß-/Kleinschreibung und Akzenten. +- **Sortierung:** Zuletzt · Titel · Autor · Neu hinzugefügt · Kürzeste/Längste zuerst (Zahlen + nach Wert: „Band 2“ vor „Band 10“). Sie bleibt gespeichert. +- **Länge:** die Seitenzahl aus den Metadaten (`pages`); fehlt sie, rechnet die TUI sie für + schon geladene Bücher einmal aus dem Text aus (1800 Zeichen je Seite, wie der Web-Reader), wenn + nach Länge sortiert wird („Länge wird ermittelt …“). Nicht geladene Bücher ohne Länge stehen am + Ende. Zurück auf den Server schreibt die TUI die Länge nicht; das macht der Web-Reader. +- **Bücher ⇄ Zeitschriften** (`m`): Zeitschriften sind, was in den Metadaten so markiert ist + oder im Ordner „Zeitschriften“/„Magazine“ liegt. Die Heft-Ansicht mit Artikeln gibt es in der + TUI nicht; ein Heft ist ein Buch. +- ☁ vor dem Titel: auf dem Server, noch nicht geladen. ✓: gelesen. + +Nicht in der TUI: Serien-Stapel, Ausgaben (mehrere Fassungen eines Werks stehen einzeln), +Cover, Ordner anlegen/zuweisen, Eingang aus der Cloud. ## Anmelden und synchronisieren diff --git a/tui/diora_tui/api.py b/tui/diora_tui/api.py index 0d9d14a..4d130ca 100644 --- a/tui/diora_tui/api.py +++ b/tui/diora_tui/api.py @@ -45,6 +45,13 @@ def fetch_books(server_url: str, token: str) -> tuple[list[dict], int | None, in return resp.json(), server_now, device_now +def fetch_prefs(server_url: str, token: str) -> dict: + """The library's encrypted settings: {prefs_ct, prefs_iv}, both '' while there are none.""" + resp = requests.get(f"{server_url}/books/prefs/", headers=_headers(token), timeout=30) + _check(resp, "Bibliothekseinstellungen") + return resp.json() + + def fetch_book_data(server_url: str, token: str, book_id: int) -> dict: resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=300) _check(resp, f"Buch {book_id}") diff --git a/tui/diora_tui/app.py b/tui/diora_tui/app.py index cbba688..88e415e 100644 --- a/tui/diora_tui/app.py +++ b/tui/diora_tui/app.py @@ -5,157 +5,17 @@ from __future__ import annotations import argparse import asyncio -from dataclasses import dataclass from pathlib import Path -from textual import work -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.screen import Screen -from textual.widgets import Footer, Header, Label, ListItem, ListView +from textual.app import App from . import config as config_mod -from . import epub, library_meta, login as login_mod, progress, remote -from .reader_screen import ReaderScreen -from .statusbar import StatusBar +from . import login as login_mod, remote +from .library_screen import LibraryScreen DEFAULT_LIBRARY = Path.home() / "Books" -@dataclass -class Entry: - """A line of the library: a file in the folder, a server book not downloaded yet, or both.""" - - key: str - title: str - path: Path | None - server_id: int | None - is_read: bool - read_at: int - uploaded_at: str - fraction: float - - -class LibraryScreen(Screen): - BINDINGS = [ - Binding("enter", "open_selected", "Öffnen"), - Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"), - Binding("q", "quit", "Beenden"), - ] - - def __init__(self, library_dir: Path) -> None: - super().__init__() - self.library_dir = library_dir - self.all_entries: list[Entry] = [] - self.shown: list[Entry] = [] - self.show_read = False - - def compose(self) -> ComposeResult: - yield Header() - yield ListView(id="library-list") - yield StatusBar() - yield Footer() - - def on_mount(self) -> None: - self.sub_title = str(self.library_dir) - self._refresh_list() - cfg = config_mod.load() - if cfg is not None: - self._auto_sync(cfg) - - def on_screen_resume(self) -> None: - self._refresh_list() # a book was read: the order and the percentages changed - - @work(thread=True) - def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None: - try: - result = remote.sync_library(self.library_dir, cfg) - except remote.SyncError as e: - self.app.call_from_thread(self.notify, str(e), severity="warning", timeout=5) - return # best-effort — e.g. offline; the local library still works - self.app.call_from_thread(self._on_auto_sync_done, result) - - def _on_auto_sync_done(self, result: remote.SyncResult) -> None: - self._refresh_list() - if result.new_titles: - self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3) - - def _gather(self) -> list[Entry]: - store = progress.default() - remote_books = library_meta.remote_entries() - by_key: dict[str, Entry] = {} - for path in epub.scan_library(self.library_dir): - key = epub._book_id(path) - info = remote_books.get(key) or {} - by_key[key] = Entry(key, info.get("title") or path.stem, path, epub.server_id_of(path), - bool(info.get("is_read")), 0, info.get("uploaded_at") or "", 0.0) - for key, info in remote_books.items(): - if key not in by_key: - by_key[key] = Entry(key, info["title"], None, info["server_id"], bool(info.get("is_read")), - 0, info.get("uploaded_at") or "", 0.0) - for key, e in by_key.items(): - st = store.get(key) - if st is not None: - e.read_at = st.read_at or 0 - e.fraction = st.fraction - # last read first; never opened ones by when they came - return sorted(by_key.values(), key=lambda e: (e.read_at, e.uploaded_at), reverse=True) - - def _refresh_list(self) -> None: - self.all_entries = self._gather() - self.shown = [e for e in self.all_entries if self.show_read or not e.is_read] - - list_view = self.query_one("#library-list", ListView) - index = list_view.index - list_view.clear() - if not self.all_entries: - list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}"))) - return - if not self.shown: - list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen"))) - return - for e in self.shown: - mark = "✓ " if e.is_read else "" - cloud = "☁ " if e.path is None else "" - pct = f" {round(e.fraction * 100)} %" if e.fraction > 0 else "" - list_view.append(ListItem(Label(f"{mark}{cloud}{e.title}{pct}"))) - list_view.index = min(index or 0, len(self.shown) - 1) - list_view.focus() - - def action_toggle_read_filter(self) -> None: - self.show_read = not self.show_read - self._refresh_list() - state = "eingeblendet" if self.show_read else "ausgeblendet" - self.notify(f"Gelesene Bücher {state}.", timeout=2) - - def action_open_selected(self) -> None: - list_view = self.query_one("#library-list", ListView) - if not self.shown or list_view.index is None or list_view.index >= len(self.shown): - return - entry = self.shown[list_view.index] - if entry.path is not None: - self.app.push_screen(ReaderScreen(entry.path)) - return - cfg = config_mod.load() - if cfg is None or entry.server_id is None: - self.notify("Nicht angemeldet — `diora-tui login`.", severity="error") - return - self.notify(f"Lade „{entry.title}“ …", timeout=4) - self._download_and_open(cfg, entry) - - @work(thread=True, exclusive=True, group="download") - def _download_and_open(self, cfg: config_mod.RemoteConfig, entry: Entry) -> None: - try: - path = remote.download_book(self.library_dir, cfg, entry.server_id, entry.title) - except remote.SyncError as e: - self.app.call_from_thread(self.notify, str(e), severity="error", timeout=6) - return - self.app.call_from_thread(self.app.push_screen, ReaderScreen(path)) - - def on_list_view_selected(self, event: ListView.Selected) -> None: - self.action_open_selected() - - class DioraTuiApp(App): CSS_PATH = "app.tcss" TITLE = "diora-tui" diff --git a/tui/diora_tui/app.tcss b/tui/diora_tui/app.tcss index a75e1ed..1335d41 100644 --- a/tui/diora_tui/app.tcss +++ b/tui/diora_tui/app.tcss @@ -2,6 +2,46 @@ height: 1fr; } +#lib-chips { + height: auto; + padding: 0 1; +} + +#lib-search { + margin: 0 1; +} + +#lib-card { + height: auto; + margin: 1 1 0 1; + padding: 0 2; + border: round $primary; +} + +#lib-note { + height: auto; + padding: 0 2; + color: $text-muted; +} + +ChoiceScreen { + align: center middle; +} + +#choice-title { + width: 50; + background: $panel; + padding: 0 2; + text-style: bold; +} + +#choice-list { + width: 50; + height: auto; + max-height: 70%; + border: round $primary; +} + StatusBar { dock: bottom; height: 1; diff --git a/tui/diora_tui/blocks.py b/tui/diora_tui/blocks.py index fc3be10..d076b4c 100644 --- a/tui/diora_tui/blocks.py +++ b/tui/diora_tui/blocks.py @@ -260,3 +260,22 @@ def anchor_is_further(new_anchor: str, old_anchor: str) -> bool: nb, ni = new_parts ob, oi = old_parts return ni >= oi if nb == ob else nb >= ob + + +def book_pages(path: Path) -> int: + """The book's length in printed pages from its text alone (bookPagesOfFile, 40-epub.js): every + spine document's body text by 1800 characters a page. Cheaper than load_flat_book: no blocks.""" + from .library import pages_of_chars + + raw = epub.read_epub(str(path), options={"ignore_ncx": True}) + chars = 0 + 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 + soup = BeautifulSoup(item.content.decode("utf-8", "replace"), _PARSER) + for name in ("script", "style"): + for el in soup.find_all(name): + el.decompose() + chars += sum(len(n) for n in _text_nodes(soup.body or soup)) + return pages_of_chars(chars) diff --git a/tui/diora_tui/crypto.py b/tui/diora_tui/crypto.py index 75a1a7e..5db63d3 100644 --- a/tui/diora_tui/crypto.py +++ b/tui/diora_tui/crypto.py @@ -8,6 +8,7 @@ supplies out-of-band. from __future__ import annotations import base64 +import os from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives import hashes @@ -32,6 +33,13 @@ def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes: raise DecryptError(str(e)) from e +def encrypt(key_b64: str, plaintext: bytes) -> tuple[str, str]: + """(iv hex, ciphertext base64): what the web client's encryptBytes sends as iv/ct.""" + iv = os.urandom(12) + ct = AESGCM(base64.b64decode(key_b64)).encrypt(iv, plaintext, None) + return iv.hex(), base64.b64encode(ct).decode() + + def derive_key_b64(username: str, password: str) -> str: """Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does: PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only diff --git a/tui/diora_tui/library.py b/tui/diora_tui/library.py new file mode 100644 index 0000000..a82ba8c --- /dev/null +++ b/tui/diora_tui/library.py @@ -0,0 +1,210 @@ +"""The library list: what the web library shows (docs/library.md, static/js/app/53-library.js) +without drawing it — rows, the status and folder chips, search, sort, the "Weiterlesen" card. + +Pure functions on plain rows, so the screen stays thin and the rules can be tested. Where the +web has a rule, this follows it (names in the comments are the web's). +""" + +from __future__ import annotations + +import re +import time +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path + +SORTS = [ + ("recent", "Zuletzt"), + ("title", "Titel"), + ("author", "Autor"), + ("added", "Neu hinzugefügt"), + ("short", "Kürzeste zuerst"), + ("long", "Längste zuerst"), +] +STATUSES = [("all", "Alle"), ("started", "Angefangen"), ("unread", "Ungelesen"), ("read", "Gelesen")] + +_MAGAZINE_FOLDER = re.compile(r"^(zeitschriften|magazine|magazines)$", re.I) # _MAGAZINE_FOLDER + +CHARS_PER_BOOK_PAGE = 1800 # the reader's own estimate (44-books.js) +RECENT_POOL = 7 # _recentlyReadBooks: the 7 read last +RECENT_ROW = 6 # _libRecentBooks: the others shown beside the large one + + +@dataclass +class Row: + key: str + title: str + author: str = "" + path: Path | None = None # the file, if it is here + server_id: int | None = None + is_read: bool = False + uploaded_at: str = "" + folders: list[str] = field(default_factory=list) + pages: int = 0 # from the encrypted meta, else worked out here + magazine: bool | None = None # meta.magazine; None: decided by the folders + read_at: int = 0 # ms, server clock; 0: never opened + fraction: float = 0.0 + device: str = "" + + +def is_magazine(r: Row) -> bool: + return r.magazine is True or (r.magazine is not False and any(_MAGAZINE_FOLDER.match(f.strip()) for f in r.folders)) + + +def folders_of_meta(meta: dict) -> list[str]: + """_foldersOfMeta: `folders`, else the older single `folder`.""" + f = meta.get("folders") + if isinstance(f, list): + return [x for x in f if isinstance(x, str) and x.strip()] + return [meta["folder"]] if meta.get("folder") else [] + + +def pages_of_chars(chars: int) -> int: + """An EPUB's length in printed pages from its text (bookPagesOfFile): 1800 characters a page.""" + return max(1, int(chars / CHARS_PER_BOOK_PAGE + 0.5)) if chars else 0 + + +def build_rows(files: list[Path], remote: dict[str, dict], states: dict, local_pages: dict[str, int], key_of) -> list[Row]: + """One row per book: each file in the folder, and each server book not downloaded yet. + `states` maps a key to its reading state (progress.BookState), `key_of(path)` is the book key.""" + rows: dict[str, Row] = {} + + def fill(row: Row, info: dict) -> Row: + row.title = info.get("title") or row.title + row.author = info.get("author") or "" + row.server_id = info.get("server_id") + row.is_read = bool(info.get("is_read")) + row.uploaded_at = info.get("uploaded_at") or "" + row.folders = list(info.get("folders") or []) + row.pages = int(info.get("pages") or 0) + row.magazine = info.get("magazine") + return row + + for path in files: + key = key_of(path) + rows[key] = fill(Row(key, path.stem, path=path), remote.get(key) or {}) + for key, info in remote.items(): + if key not in rows: + rows[key] = fill(Row(key, "?"), info) + for key, r in rows.items(): + st = states.get(key) + if st is not None: + r.read_at = st.read_at or 0 + r.fraction = st.fraction + r.device = st.device + if not r.pages: + r.pages = int(local_pages.get(key) or 0) + return list(rows.values()) + + +# ---- the filters + +def started(r: Row) -> bool: + return not r.is_read and r.fraction > 0 + + +def unread(r: Row) -> bool: + return not r.is_read and not r.fraction > 0 + + +def norm(s: str) -> str: + """_libNorm: without accents, lower case.""" + return "".join(c for c in unicodedata.normalize("NFD", s or "") if not unicodedata.combining(c)).lower() + + +def collate(s: str) -> list: + """German-ish order with numbers by value (localeCompare('de', {numeric: true})): accents + don't count at first, the plain text breaks ties.""" + parts = re.split(r"(\d+)", norm(s)) + return [(0, int(p)) if p.isdigit() else (1, p) for p in parts if p != ""] + [(2, s or "")] + + +def section(rows: list[Row], magazines: bool) -> list[Row]: + """_bookSectionFilter: Bücher or Zeitschriften.""" + return [r for r in rows if is_magazine(r) == magazines] + + +def folder_list(rows: list[Row], order: list[str] | None) -> list[tuple[str, int]]: + """_libFolderList: every folder with its book count, in the user's order, the rest by name.""" + counts: dict[str, int] = {} + for r in rows: + for f in r.folders: + counts[f] = counts.get(f, 0) + 1 + order = order or [] + return sorted(((n, c) for n, c in counts.items()), + key=lambda nc: (order.index(nc[0]) if nc[0] in order else 10**6, collate(nc[0]))) + + +def status_counts(rows: list[Row]) -> dict[str, int]: + return {"all": len(rows), "started": sum(map(started, rows)), "unread": sum(map(unread, rows)), + "read": sum(r.is_read for r in rows)} + + +def shown(rows: list[Row], *, q: str = "", status: str = "all", folder: str | None = None, sort: str = "recent") -> list[Row]: + """_libShown. `folder`: None all, '' without a folder, else a folder's name.""" + needle = norm(q).strip() + + def keep(r: Row) -> bool: + if status == "started" and not started(r): + return False + if status == "unread" and not unread(r): + return False + if status == "read" and not r.is_read: + return False + if folder is not None and (bool(r.folders) if folder == "" else folder not in r.folders): + return False + return not needle or needle in norm(r.title + " " + r.author) + + by_title = lambda r: collate(r.title) # noqa: E731 + out = [r for r in rows if keep(r)] + if sort == "title": + out.sort(key=by_title) + elif sort == "author": + out.sort(key=lambda r: (collate(r.author or "~"), by_title(r))) + elif sort == "added": + out.sort(key=lambda r: r.uploaded_at, reverse=True) + elif sort == "short": # a book whose length isn't known comes last + out.sort(key=lambda r: (r.pages or float("inf"), by_title(r))) + elif sort == "long": + out.sort(key=lambda r: (-(r.pages or 0), by_title(r))) + else: # recent: read last first, then newest upload + out.sort(key=lambda r: (r.read_at, r.uploaded_at), reverse=True) + return out + + +def continue_rows(rows: list[Row]) -> tuple[Row | None, list[Row]]: + """The "Weiterlesen" card: of the books read last, the first not finished, and the others + of those read lately beside it (_libContinueBook, _libRecentBooks).""" + pool = sorted((r for r in rows if r.read_at), key=lambda r: r.read_at, reverse=True)[:RECENT_POOL] + going = next((r for r in pool if not r.is_read), None) + if going is None: + return None, [] + return going, [r for r in pool if r is not going][:RECENT_ROW] + + +def ago(ms: int, now_ms: int | None = None) -> str: + """_libAgo: "vor 5 Minuten", "gestern", "am 4. Okt.".""" + if not ms: + return "" + now = now_ms if now_ms is not None else int(time.time() * 1000) + minutes = round((now - ms) / 60000) + if minutes < 2: + return "gerade eben" + if minutes < 60: + return f"vor {minutes} Minuten" + day = lambda t: time.strftime("%Y-%m-%d", time.localtime(t / 1000)) # noqa: E731 + hours = round(minutes / 60) + if day(ms) == day(now): + return "vor einer Stunde" if hours == 1 else f"vor {hours} Stunden" + if day(ms) == day(now - 86400000): + return "gestern" + days = round((now - ms) / 86400000) + if days < 7: + return f"vor {days} Tagen" + months = ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."] + t = time.localtime(ms / 1000) + return f"am {t.tm_mday}. {months[t.tm_mon - 1]}" + + +def percent(r: Row) -> int: + return 100 if r.is_read else int(r.fraction * 100 + 0.5) # Math.round diff --git a/tui/diora_tui/library_meta.py b/tui/diora_tui/library_meta.py index 53181f8..ff58157 100644 --- a/tui/diora_tui/library_meta.py +++ b/tui/diora_tui/library_meta.py @@ -39,6 +39,30 @@ def get(book_key: str) -> dict | None: return _load_all().get(book_key) +def get_extra(name: str, default=None): + """A setting kept beside the books (stored under '_'): the folder order, the sort.""" + entry = _load_all().get("_" + name) + return entry["value"] if isinstance(entry, dict) and "value" in entry else default + + +def set_extra(name: str, value) -> None: + data = _load_all() + data["_" + name] = {"value": value} + _save_all(data) + + +def set_local_pages(book_key: str, pages: int) -> None: + """A book's length worked out from the file here (the server's meta may not have it).""" + data = _load_all() + entry = data.setdefault("_pages", {"value": {}}) + entry.setdefault("value", {})[book_key] = int(pages) + _save_all(data) + + +def local_pages() -> dict[str, int]: + return dict(get_extra("pages", {}) or {}) + + def remote_entries() -> dict[str, dict]: """Every server book: key -> {server_id, title, author, is_read, uploaded_at}.""" return {k: v for k, v in _load_all().items() if isinstance(v, dict) and v.get("server_id") is not None} diff --git a/tui/diora_tui/library_screen.py b/tui/diora_tui/library_screen.py new file mode 100644 index 0000000..ab3845f --- /dev/null +++ b/tui/diora_tui/library_screen.py @@ -0,0 +1,388 @@ +"""The library screen: the web library (docs/library.md) in a terminal — "Weiterlesen" card, +status and folder chips, search, sort, Bücher/Zeitschriften. The rules are in library.py. + +Keys: 1–4 status (Alle/Angefangen/Ungelesen/Gelesen), o folder, / search, s sort, m Bücher or +Zeitschriften, c continue the book read last, Enter open. The chips and the card also answer +the mouse. +""" + +from __future__ import annotations + +from pathlib import Path + +from rich.text import Text +from textual import work +from textual.binding import Binding +from textual.screen import ModalScreen, Screen +from textual.markup import escape +from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, OptionList, Static + +from . import blocks, config as config_mod, epub, library, library_meta, progress, remote +from .reader_screen import ReaderScreen +from .statusbar import StatusBar + +PAGES_NOTE_EVERY = 4 # seconds between redraws while lengths are worked out + + +class ChoiceScreen(ModalScreen[int]): + """Pick one of a few options; dismisses with its index, or None on Escape.""" + + BINDINGS = [Binding("escape,q", "cancel", "Abbrechen")] + + def __init__(self, title: str, options: list[str], current: int = 0) -> None: + super().__init__() + self._title = title + self._options = options + self._current = current + + def compose(self): + yield Label(self._title, id="choice-title") + yield OptionList(*[Text(o) for o in self._options], id="choice-list") + + def on_mount(self) -> None: + ol = self.query_one(OptionList) + ol.highlighted = max(0, min(self._current, len(self._options) - 1)) + ol.focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + self.dismiss(event.option_index) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class LibraryScreen(Screen): + BINDINGS = [ + Binding("enter", "open_selected", "Öffnen"), + Binding("c", "continue_reading", "Weiterlesen"), + Binding("/", "search", "Suchen"), + Binding("s", "pick_sort", "Sortierung"), + Binding("o", "pick_folder", "Ordner"), + Binding("m", "toggle_section", "Bücher/Zeitschriften"), + Binding("1", "set_status('all')", "Alle", show=False), + Binding("2", "set_status('started')", "Angefangen", show=False), + Binding("3", "set_status('unread')", "Ungelesen", show=False), + Binding("4", "set_status('read')", "Gelesen", show=False), + Binding("escape", "reset", "Zurücksetzen", show=False), + Binding("q", "quit", "Beenden"), + ] + + def __init__(self, library_dir: Path) -> None: + super().__init__() + self.library_dir = library_dir + self.rows: list[library.Row] = [] # every book + self.section_rows: list[library.Row] = [] # of the section shown (Bücher or Zeitschriften) + self.shown: list[library.Row] = [] # the list on screen + self.going: library.Row | None = None # the card's book + self.folder_names: list[str] = [] + self.q = "" + self.status = "all" + self.folder: str | None = None # None: all, '': without folder, else a name + self.sort = library_meta.get_extra("sort", "recent") + if self.sort not in dict(library.SORTS): + self.sort = "recent" + self.magazines = False + self._lengths_running = False + self._lengths_tried: set[str] = set() + self._lengths_note = "" + + # ---- layout + + def compose(self): + yield Header() + yield Static("", id="lib-chips") + yield Input(placeholder="Titel, Autor …", id="lib-search") + yield Static("", id="lib-card") + yield Static("", id="lib-note") + yield ListView(id="library-list") + yield StatusBar() + yield Footer() + + def on_mount(self) -> None: + self.query_one("#lib-search").display = False + self.sub_title = str(self.library_dir) + self._refresh_list() + cfg = config_mod.load() + if cfg is not None: + self._auto_sync(cfg) + + def on_screen_resume(self) -> None: + self._refresh_list() # a book was read: the order and the percentages changed + + # ---- sync + + @work(thread=True) + def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None: + try: + result = remote.sync_library(self.library_dir, cfg) + except remote.SyncError as e: + self.app.call_from_thread(self.notify, str(e), severity="warning", timeout=5) + return # best-effort — e.g. offline; the local library still works + self.app.call_from_thread(self._on_auto_sync_done, result) + + def _on_auto_sync_done(self, result: remote.SyncResult) -> None: + self._refresh_list() + if result.new_titles: + self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3) + + # ---- the list + + def _gather(self) -> list[library.Row]: + store = progress.default() + remote_books = library_meta.remote_entries() + files = epub.scan_library(self.library_dir) + keys = {epub._book_id(p) for p in files} | set(remote_books) + states = {k: st for k in keys if (st := store.get(k)) is not None} + return library.build_rows(files, remote_books, states, library_meta.local_pages(), epub._book_id) + + def _refresh_list(self) -> None: + self.rows = self._gather() + self.section_rows = library.section(self.rows, self.magazines) + order = library_meta.get_extra("folder_order", []) + self.folder_names = [n for n, _ in library.folder_list(self.section_rows, order)] + if self.folder not in (None, "") and self.folder not in self.folder_names: + self.folder = None + self._draw() + self._fill_lengths() + + def _draw(self) -> None: + self._draw_chips() + list_view = self.query_one("#library-list", ListView) + selected = self.shown[list_view.index].key if self.shown and list_view.index is not None and list_view.index < len(self.shown) else None + + shown = library.shown(self.section_rows, q=self.q, status=self.status, folder=self.folder, sort=self.sort) + # The card: only on the plain list, and its book is not repeated below (once, on top). + plain = self.status == "all" and self.folder is None and not self.q + self.going, recent = library.continue_rows(self.section_rows) if plain else (None, []) + self._draw_card(recent) + if self.going is not None and self.going in shown: + shown.remove(self.going) + self.shown = shown + + list_view.clear() + if not self.rows: + list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}"))) + elif not self.section_rows: + list_view.append(ListItem(Label("Noch keine Zeitschriften — m wechselt zu den Büchern." if self.magazines + else "Alle Bücher stehen bei den Zeitschriften — m wechselt dorthin."))) + elif not self.shown and self.going is None: + list_view.append(ListItem(Label("Nichts gefunden."))) + by_length = self.sort in ("short", "long") + for r in self.shown: + list_view.append(ListItem(Label(Text(self._label(r, by_length))))) + if self.shown: + idx = next((i for i, r in enumerate(self.shown) if r.key == selected), 0) + list_view.index = idx + if not self.query_one("#lib-search", Input).display: # not while a search is open + list_view.focus() + self._draw_note() + + @staticmethod + def _label(r: library.Row, by_length: bool) -> str: + mark = "✓ " if r.is_read else "" + cloud = "☁ " if r.path is None else "" + author = f" — {r.author}" if r.author else "" + pct = f" {library.percent(r)} %" if (r.fraction > 0 and not r.is_read) else "" + length = f" · {r.pages} S." if by_length and r.pages else "" + return f"{mark}{cloud}{r.title}{author}{pct}{length}" + + def _draw_chips(self) -> None: + counts = library.status_counts(self.section_rows) + parts = [] + for i, (sid, label) in enumerate(library.STATUSES): + text = label if sid == "all" else f"{label} {counts[sid]}" + parts.append(self._chip(f"{i + 1} {text}", f"screen.set_status('{sid}')", self.status == sid)) + folders = library.folder_list(self.section_rows, library_meta.get_extra("folder_order", [])) + if folders: + parts.append("[dim]│[/]") + parts.append(self._chip("Alle Ordner", "screen.set_folder('all')", self.folder is None)) + for i, (name, count) in enumerate(folders): + parts.append(self._chip(f"{name} {count}", f"screen.set_folder('{i}')", self.folder == name)) + none = sum(1 for r in self.section_rows if not r.folders) + if none: + parts.append(self._chip(f"Ohne Ordner {none}", "screen.set_folder('none')", self.folder == "")) + section = "Zeitschriften" if self.magazines else "Bücher" + sort = dict(library.SORTS)[self.sort] + head = f"[b]{section}[/] [dim]Sortierung:[/] {escape(sort)} " + self.query_one("#lib-chips", Static).update(head + " ".join(parts)) + + @staticmethod + def _chip(label: str, action: str, on: bool) -> str: + label = escape(label) + return f"[@click={action}]" + (f"[b reverse] {label} [/]" if on else f" [underline]{label}[/] ") + "[/]" + + def _draw_card(self, recent: list[library.Row]) -> None: + card = self.query_one("#lib-card", Static) + b = self.going + if b is None: + card.display = False + return + card.display = True + ago = library.ago(b.read_at) + pct = library.percent(b) + bar = "█" * (pct // 5) + "░" * (20 - pct // 5) + pages = f" · Seite {max(1, int(b.pages * b.fraction + 0.5))} von {b.pages}" if b.pages else "" + where = f" · zuletzt auf {escape(b.device)}" if b.device and b.device != progress.DEVICE_NAME else "" + lines = [ + f"[@click=screen.continue_reading][dim]Weiterlesen{' · ' + ago if ago else ''}{where}[/]", + f"[b]{escape(b.title)}[/]", + escape(b.author) if b.author else "", + f"{bar} {pct} %{pages} [dim](c)[/][/]", + ] + if recent: + lines.append("[dim]Zuletzt gelesen:[/] " + " · ".join( + f"{escape(r.title)}{' ✓' if r.is_read else ''}" for r in recent)) + card.update("\n".join(l for l in lines if l)) + + def _draw_note(self) -> None: + note = self.query_one("#lib-note", Static) + text = self._lengths_note + if not text and self.sort in ("short", "long"): + unknown = sum(1 for r in self.section_rows if not r.pages) + if unknown: + text = f"{unknown} Bücher ohne bekannte Länge (noch nicht geladen) stehen am Ende." + note.update(escape(text)) + note.display = bool(text) + + # ---- lengths (the web works them out once a length sort is chosen: the file is needed) + + def _fill_lengths(self) -> None: + if self._lengths_running or self.sort not in ("short", "long"): + return + todo = [r for r in self.rows if not r.pages and r.path is not None and r.key not in self._lengths_tried] + if not todo: + return + self._lengths_running = True + self._work_lengths([(r.key, r.path) for r in todo]) + + @work(thread=True, exclusive=True, group="lengths") + def _work_lengths(self, todo: list[tuple[str, Path]]) -> None: + import time + + shown_at = time.monotonic() + try: + for done, (key, path) in enumerate(todo, 1): + self._lengths_tried.add(key) + try: + pages = blocks.book_pages(path) + except Exception: + pages = 0 # stays at the end + if pages: + library_meta.set_local_pages(key, pages) + self._lengths_note = f"Länge wird ermittelt … {done} von {len(todo)} Büchern" + if time.monotonic() - shown_at > PAGES_NOTE_EVERY: + shown_at = time.monotonic() + self.app.call_from_thread(self._refresh_list_keep_note) + finally: + self._lengths_note = "" + self._lengths_running = False + self.app.call_from_thread(self._refresh_list) + + def _refresh_list_keep_note(self) -> None: + self.rows = self._gather() + self.section_rows = library.section(self.rows, self.magazines) + self._draw() + + # ---- actions + + def action_set_status(self, status: str) -> None: + self.status = "all" if self.status == status else status + self._draw() + + def action_set_folder(self, which: str) -> None: + """`which`: 'all', 'none' (without a folder) or the index of a folder chip.""" + if which == "all": + self.folder = None + elif which == "none": + self.folder = "" + elif which.isdigit() and int(which) < len(self.folder_names): + name = self.folder_names[int(which)] + self.folder = None if self.folder == name else name + self._draw() + + def action_pick_folder(self) -> None: + values: list[str | None] = [None] + self.folder_names + ([""] if any(not r.folders for r in self.section_rows) else []) + if len(values) == 1: + self.notify("Keine Ordner angelegt.", timeout=2) + return + names = ["Alle Ordner" if v is None else "Ohne Ordner" if v == "" else v for v in values] + + def picked(i: int | None) -> None: + if i is not None: + self.folder = values[i] + self._draw() + + self.app.push_screen(ChoiceScreen("Ordner", names, values.index(self.folder)), picked) + + def action_pick_sort(self) -> None: + ids = [s for s, _ in library.SORTS] + + def picked(i: int | None) -> None: + if i is None: + return + self.sort = ids[i] + library_meta.set_extra("sort", self.sort) + self._draw() + self._fill_lengths() + + self.app.push_screen(ChoiceScreen("Sortieren nach", [n for _, n in library.SORTS], ids.index(self.sort)), picked) + + def action_toggle_section(self) -> None: + self.magazines = not self.magazines + self.folder = None + self._refresh_list() + + def action_search(self) -> None: + box = self.query_one("#lib-search", Input) + box.display = True + box.focus() + + def on_input_changed(self, event: Input.Changed) -> None: + self.q = event.value + self._draw() + + def on_input_submitted(self, event: Input.Submitted) -> None: + self.query_one("#library-list", ListView).focus() + + def action_reset(self) -> None: + box = self.query_one("#lib-search", Input) + if box.display or self.q or self.status != "all" or self.folder is not None: + box.value = "" + box.display = False + self.q, self.status, self.folder = "", "all", None + self._draw() + + def action_continue_reading(self) -> None: + if self.going is not None: + self._open(self.going) + else: + self.notify("Nichts zum Weiterlesen — ein Buch öffnen.", timeout=3) + + def action_open_selected(self) -> None: + list_view = self.query_one("#library-list", ListView) + if not self.shown or list_view.index is None or list_view.index >= len(self.shown): + return + self._open(self.shown[list_view.index]) + + def _open(self, row: library.Row) -> None: + if row.path is not None: + self.app.push_screen(ReaderScreen(row.path)) + return + cfg = config_mod.load() + if cfg is None or row.server_id is None: + self.notify("Nicht angemeldet — `diora-tui login`.", severity="error") + return + self.notify(f"Lade „{row.title}“ …", timeout=4) + self._download_and_open(cfg, row) + + @work(thread=True, exclusive=True, group="download") + def _download_and_open(self, cfg: config_mod.RemoteConfig, row: library.Row) -> None: + try: + path = remote.download_book(self.library_dir, cfg, row.server_id, row.title) + except remote.SyncError as e: + self.app.call_from_thread(self.notify, str(e), severity="error", timeout=6) + return + self.app.call_from_thread(self.app.push_screen, ReaderScreen(path)) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + self.action_open_selected() diff --git a/tui/diora_tui/reader_screen.py b/tui/diora_tui/reader_screen.py index f6a8048..e0ae7be 100644 --- a/tui/diora_tui/reader_screen.py +++ b/tui/diora_tui/reader_screen.py @@ -29,7 +29,7 @@ from textual.geometry import Size from textual.screen import ModalScreen, Screen from textual.widgets import Footer, Header, Label, LoadingIndicator, Static -from . import blocks, cache, config as config_mod, epub, layout, library_meta, progress, remote +from . import blocks, cache, config as config_mod, epub, layout, library, library_meta, progress, remote from .book_view import ContinuousBookView from .statusbar import StatusBar @@ -163,6 +163,7 @@ class ReaderScreen(Screen): self.sub_title = book.title self._apply_layout(book_layout, width) self.query_one("#reader-loading", LoadingIndicator).display = False + library_meta.set_local_pages(self._key, library.pages_of_chars(book.total_chars)) st = self._store.get(self._key) self._held_read_at = st.read_at if st else None self.call_after_refresh(self._restore_position) diff --git a/tui/diora_tui/remote.py b/tui/diora_tui/remote.py index 3238ef2..ff2ed9a 100644 --- a/tui/diora_tui/remote.py +++ b/tui/diora_tui/remote.py @@ -16,7 +16,7 @@ from pathlib import Path import requests -from . import api, crypto, library_meta, progress +from . import api, crypto, library, library_meta, progress from .config import RemoteConfig from .epub import server_key @@ -97,15 +97,32 @@ def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult: continue key = server_key(b["id"]) entries[key] = {"server_id": b["id"], "title": title, "author": meta.get("author") or "", - "is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or ""} + "is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or "", + "folders": library.folders_of_meta(meta), "pages": int(meta.get("pages") or 0), + "magazine": meta.get("magazine") if isinstance(meta.get("magazine"), bool) else None} if key not in known: result.new_titles.append(title) store.apply_server(key, b) library_meta.set_remote(entries) result.books = len(entries) + _pull_folder_order(cfg) return result +def _pull_folder_order(cfg: RemoteConfig) -> None: + """The folders' order: encrypted library prefs {folder_order: [...]}. Best effort.""" + try: + prefs = api.fetch_prefs(cfg.server_url, cfg.api_token) + if not prefs.get("prefs_ct"): + return + plain = json.loads(crypto.decrypt(cfg.enc_key_b64, prefs["prefs_iv"], prefs["prefs_ct"]).decode("utf-8")) + order = plain.get("folder_order") + if isinstance(order, list): + library_meta.set_extra("folder_order", [f for f in order if isinstance(f, str)]) + except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException): + pass + + def download_book(library_dir: Path, cfg: RemoteConfig, server_id: int, title: str) -> Path: existing = local_path(library_dir, server_id) if existing is not None: diff --git a/tui/tests/conftest.py b/tui/tests/conftest.py index c47e587..f5565d5 100644 --- a/tui/tests/conftest.py +++ b/tui/tests/conftest.py @@ -43,3 +43,13 @@ def epub(tmp_path): def build(chapters, title="Testbuch"): return make_epub(tmp_path / f"{title}.epub", chapters, title) return build + + +@pytest.fixture(autouse=True) +def clean_library_meta(): + """library.json is one file in the shared test home: every test starts without it.""" + from diora_tui import library_meta + + library_meta._META_FILE.unlink(missing_ok=True) + yield + library_meta._META_FILE.unlink(missing_ok=True) diff --git a/tui/tests/test_app_smoke.py b/tui/tests/test_app_smoke.py index 264d8a1..d772ee6 100644 --- a/tui/tests/test_app_smoke.py +++ b/tui/tests/test_app_smoke.py @@ -49,15 +49,13 @@ def test_server_books_are_listed_before_they_are_downloaded(tmp_path, store_fres lib = tmp_path / "lib" lib.mkdir() library_meta.set_remote({"s9": {"server_id": 9, "title": "Cloud-Buch", "author": "", "is_read": False, "uploaded_at": "2026-10-01"}}) - store_fresh.apply_server("s9", {"position_anchor": "5:0.5", "scroll_fraction": 0.4, "read_at": 1_760_000_000_000}) async def run(): app = DioraTuiApp(lib) async with app.run_test(size=(100, 30)) as pilot: await pilot.pause() - items = app.screen.query_one(ListView).query(Label) - text = " | ".join(str(i.render()) for i in items) - assert "☁ Cloud-Buch" in text and "40 %" in text + text = " | ".join(str(i.render()) for i in app.screen.query_one(ListView).query(Label)) + assert "☁ Cloud-Buch" in text await pilot.press("enter") # not signed in here: says so instead of failing await pilot.pause() assert not isinstance(app.screen, ReaderScreen) diff --git a/tui/tests/test_library.py b/tui/tests/test_library.py new file mode 100644 index 0000000..28e376a --- /dev/null +++ b/tui/tests/test_library.py @@ -0,0 +1,121 @@ +"""The library rules of the web (docs/library.md, 53-library.js): filters, folders, search, +sort, the Weiterlesen card.""" +from types import SimpleNamespace + +import pytest + +from diora_tui import library as lib +from diora_tui.library import Row + +NOW = 1_760_000_000_000 +DAY = 86_400_000 + + +def row(title, **kw): + return Row(key=kw.pop("key", title), title=title, **kw) + + +@pytest.fixture +def rows(): + return [ + row("Das Kapital 2", author="Marx", folders=["Philosophie", "Politik"], pages=900, uploaded_at="2026-01-02", read_at=NOW - 2 * DAY, fraction=0.3), + row("Das Kapital 10", author="Marx", folders=["Politik"], pages=300, uploaded_at="2026-03-01"), + row("Ödipus", author="Sophokles", folders=[], pages=0, uploaded_at="2026-02-01", is_read=True, read_at=NOW - 9 * DAY, fraction=1.0), + row("Zauberberg", author="", folders=["Roman"], pages=700, uploaded_at="2026-04-01", read_at=NOW - 60_000, fraction=0.5), + row("Jacobin 24", folders=["Zeitschriften"], pages=80, uploaded_at="2026-05-01"), + row("Heft", folders=[], magazine=True, uploaded_at="2026-05-02"), + ] + + +def titles(rs): + return [r.title for r in rs] + + +def test_sections_split_books_from_magazines(rows): + assert titles(lib.section(rows, True)) == ["Jacobin 24", "Heft"] + assert "Jacobin 24" not in titles(lib.section(rows, False)) + assert lib.is_magazine(row("x", folders=["Magazine"])) and not lib.is_magazine(row("x", folders=["Zeitschriften"], magazine=False)) + + +def test_status_counts_and_filters(rows): + books = lib.section(rows, False) + assert lib.status_counts(books) == {"all": 4, "started": 2, "unread": 1, "read": 1} + assert titles(lib.shown(books, status="started", sort="title")) == ["Das Kapital 2", "Zauberberg"] + assert titles(lib.shown(books, status="unread")) == ["Das Kapital 10"] + assert titles(lib.shown(books, status="read")) == ["Ödipus"] + + +def test_folders_filter_and_order(rows): + books = lib.section(rows, False) + assert titles(lib.shown(books, folder="Politik", sort="title")) == ["Das Kapital 2", "Das Kapital 10"] + assert titles(lib.shown(books, folder="")) == ["Ödipus"] # without a folder + # the user's order first, the rest by name + assert lib.folder_list(books, ["Roman", "Politik"]) == [("Roman", 1), ("Politik", 2), ("Philosophie", 1)] + assert lib.folder_list(books, None) == [("Philosophie", 1), ("Politik", 2), ("Roman", 1)] + + +def test_search_ignores_case_and_accents_and_reads_author(rows): + assert titles(lib.shown(rows, q="odipus")) == ["Ödipus"] + assert titles(lib.shown(rows, q="MARX", sort="title")) == ["Das Kapital 2", "Das Kapital 10"] + assert titles(lib.shown(rows, q="sophokles ödipus")) == [] # one run of text, as the web matches + + +def test_sorts(rows): + books = lib.section(rows, False) + assert titles(lib.shown(books, sort="recent")) == ["Zauberberg", "Das Kapital 2", "Ödipus", "Das Kapital 10"] + assert titles(lib.shown(books, sort="title")) == ["Das Kapital 2", "Das Kapital 10", "Ödipus", "Zauberberg"] # numbers by value + assert titles(lib.shown(books, sort="author")) == ["Das Kapital 2", "Das Kapital 10", "Ödipus", "Zauberberg"] # no author last + assert titles(lib.shown(books, sort="added")) == ["Zauberberg", "Das Kapital 10", "Ödipus", "Das Kapital 2"] + assert titles(lib.shown(books, sort="short")) == ["Das Kapital 10", "Zauberberg", "Das Kapital 2", "Ödipus"] # unknown length last + assert titles(lib.shown(books, sort="long")) == ["Das Kapital 2", "Zauberberg", "Das Kapital 10", "Ödipus"] + + +def test_continue_card_takes_the_last_read_unfinished_book(rows): + going, recent = lib.continue_rows(lib.section(rows, False)) + assert going.title == "Zauberberg" + assert titles(recent) == ["Das Kapital 2", "Ödipus"] # the others read lately, finished ones too + only_done = [r for r in rows if r.is_read] + assert lib.continue_rows(only_done) == (None, []) + assert lib.continue_rows([row("neu")]) == (None, []) + + +def test_continue_card_looks_only_at_the_seven_read_last(): + many = [row(f"b{i}", read_at=NOW - i * 1000, is_read=(i < 7)) for i in range(10)] + assert lib.continue_rows(many) == (None, []) # the 7 read last are all finished: no card + + +def test_length_of_a_book(): + assert lib.pages_of_chars(0) == 0 + assert lib.pages_of_chars(10) == 1 + assert lib.pages_of_chars(1800 * 3 + 900) == 4 # rounds half up like Math.round + assert lib.pages_of_chars(1800 * 3 + 899) == 3 + + +@pytest.mark.parametrize("ms,expected", [ + (NOW - 30_000, "gerade eben"), + (NOW - 5 * 60_000, "vor 5 Minuten"), + (NOW - DAY * 3 - 3_600_000, "vor 3 Tagen"), +]) +def test_ago(ms, expected): + assert lib.ago(ms, NOW) == expected + + +def test_percent(): + assert lib.percent(row("a", fraction=0.425)) == 43 + assert lib.percent(row("a", is_read=True)) == 100 + + +def test_build_rows_joins_files_and_server_books(tmp_path): + f = tmp_path / "0001 - Eins.epub" + f.write_bytes(b"") + local = tmp_path / "lokal.epub" + local.write_bytes(b"") + remote = {"s1": {"server_id": 1, "title": "Eins", "author": "A", "is_read": False, "uploaded_at": "x", "folders": ["F"], "pages": 0}, + "s2": {"server_id": 2, "title": "Zwei", "is_read": True, "pages": 50}} + states = {"s1": SimpleNamespace(read_at=5, fraction=0.25, device="Handy")} + key_of = lambda p: "s1" if p.name.startswith("0001") else "hash" # noqa: E731 + rs = {r.key: r for r in lib.build_rows([f, local], remote, states, {"s1": 12}, key_of)} + assert set(rs) == {"s1", "s2", "hash"} + assert (rs["s1"].title, rs["s1"].path, rs["s1"].folders, rs["s1"].fraction, rs["s1"].pages) == ("Eins", f, ["F"], 0.25, 12) + assert rs["s2"].path is None and rs["s2"].is_read and rs["s2"].pages == 50 + assert rs["hash"].title == "lokal" and rs["hash"].server_id is None diff --git a/tui/tests/test_library_screen.py b/tui/tests/test_library_screen.py new file mode 100644 index 0000000..ff4c8dc --- /dev/null +++ b/tui/tests/test_library_screen.py @@ -0,0 +1,148 @@ +"""The library screen: chips, search, sort, folders, section, the Weiterlesen card.""" +import asyncio + +import pytest +from textual.widgets import Input, ListView + +from diora_tui import library_meta, progress +from diora_tui.app import DioraTuiApp +from diora_tui.library_screen import ChoiceScreen, LibraryScreen +from diora_tui.reader_screen import ReaderScreen + +T = 1_760_000_000_000 + + +def entry(i, title, **kw): + return {"server_id": i, "title": title, "author": kw.pop("author", ""), "is_read": kw.pop("is_read", False), + "uploaded_at": f"2026-0{i}-01", "folders": kw.pop("folders", []), "pages": kw.pop("pages", 0), **kw} + + +@pytest.fixture +def world(tmp_path, monkeypatch): + store = progress.ProgressStore(tmp_path / "p.json") + monkeypatch.setattr(progress, "_default", store) + now = progress.now_ms() + library_meta.set_remote({ + "s1": entry(1, "Das Kapital", author="Marx", folders=["Politik"], pages=900), + "s2": entry(2, "Zauberberg", author="Mann", folders=["Roman"], pages=700), + "s3": entry(3, "Ödipus", is_read=True, pages=90), + "s4": entry(4, "Jacobin 24", folders=["Zeitschriften"], pages=80), + }) + library_meta.set_extra("folder_order", ["Roman", "Politik"]) + library_meta.set_extra("sort", "recent") + store.apply_server("s2", {"position_anchor": "5:0.5", "scroll_fraction": 0.5, "read_at": now - 120_000, "device": "Handy"}) + store.apply_server("s1", {"position_anchor": "5:0.5", "scroll_fraction": 0.2, "read_at": now - 3_600_000 * 30}) + lib = tmp_path / "lib" + lib.mkdir() + yield lib + library_meta.set_remote({}) + library_meta.set_extra("folder_order", []) + library_meta.set_extra("sort", "recent") + + +def run(lib, scenario): + async def go(): + app = DioraTuiApp(lib) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause(0.3) + await scenario(app, app.screen, pilot) + + asyncio.run(go()) + + +def titles(screen): + return [r.title for r in screen.shown] + + +def text_of(screen, selector): + return str(screen.query_one(selector).render()) + + +def test_card_chips_and_list(world): + async def scenario(app, screen, pilot): + assert screen.going.title == "Zauberberg" + assert "Zauberberg" not in titles(screen) # once, on top + assert titles(screen) == ["Das Kapital", "Ödipus"] # read last first, the rest by upload (never opened last) + card = text_of(screen, "#lib-card") + assert "Zauberberg" in card and "50 %" in card and "Seite 350 von 700" in card and "vor 2 Minuten" in card and "Handy" in card + chips = text_of(screen, "#lib-chips") + assert "Angefangen 2" in chips and "Ungelesen 0" in chips and "Gelesen 1" in chips + assert "Roman 1" in chips and "Politik 1" in chips and chips.index("Roman") < chips.index("Politik") # the user's order + assert "Jacobin" not in " ".join(titles(screen)) # a magazine: the other section + + run(world, scenario) + + +def test_status_search_and_reset(world): + async def scenario(app, screen, pilot): + await pilot.press("2") + assert titles(screen) == ["Zauberberg", "Das Kapital"] and screen.going is None # no card while filtered + await pilot.press("2") # the same chip again: off + assert screen.status == "all" + await pilot.press("4") + assert titles(screen) == ["Ödipus"] + await pilot.press("escape") + assert screen.status == "all" + + await pilot.press("/") + assert screen.query_one(Input).has_focus + await pilot.press(*"mann") # typing digits or letters is not a shortcut while the box has focus + assert titles(screen) == ["Zauberberg"] + await pilot.press("escape") + assert screen.q == "" and not screen.query_one(Input).display and len(screen.shown) == 2 + + run(world, scenario) + + +def test_sort_and_folder_pickers(world): + async def scenario(app, screen, pilot): + await pilot.press("s") + assert isinstance(app.screen, ChoiceScreen) + await pilot.press("down", "down", "enter") # Autor + assert screen.sort == "author" + assert library_meta.get_extra("sort") == screen.sort + + await pilot.press("s", "end", "enter") # Längste zuerst + assert screen.sort == "long" + assert titles(screen)[0] == "Das Kapital" + assert "· 900 S." in str(screen.query_one(ListView).query("Label").first().render()) + + await pilot.press("o", "down", "enter") # first folder in the user's order: Roman + assert screen.folder == "Roman" and screen.going is None + assert titles(screen) == ["Zauberberg"] # filtered: the card gives way, the book is in the list + await pilot.press("o", "home", "enter") # Alle Ordner + assert screen.folder is None + + run(world, scenario) + + +def test_sections_and_continue_key(world, monkeypatch): + async def scenario(app, screen, pilot): + await pilot.press("m") + assert screen.magazines and titles(screen) == ["Jacobin 24"] + await pilot.press("m") + assert not screen.magazines + opened = [] + monkeypatch.setattr(app, "push_screen", lambda s, *a, **k: opened.append(s)) + await pilot.press("c") + assert len(opened) == 0 # the book has no file here and no login: it says so instead + assert not isinstance(app.screen, ReaderScreen) + + run(world, scenario) + + +def test_lengths_are_worked_out_for_downloaded_books_when_sorting_by_length(world, epub): + path = epub(["

" + "wort " * 2000 + "

"], title="Lokal") # ~10000 chars: 6 pages + path.rename(world / "lokal.epub") + + async def scenario(app, screen, pilot): + await pilot.press("s", "end", "enter") + for _ in range(60): + await pilot.pause(0.1) + if library_meta.local_pages(): + break + await pilot.pause(0.5) + row = next(r for r in screen.rows if r.title == "lokal") + assert library_meta.local_pages() == {row.key: 6} and row.pages == 6 + + run(world, scenario) diff --git a/tui/tests/test_remote.py b/tui/tests/test_remote.py index 2ecae39..fc1c971 100644 --- a/tui/tests/test_remote.py +++ b/tui/tests/test_remote.py @@ -57,3 +57,16 @@ def test_flush_ignores_local_books(fresh_store, monkeypatch): fresh_store.write("abcdef0123456789", "3:0.5", 0.25, T, now=T) # queue=True by default, but not a server key monkeypatch.setattr(api, "post_progress", lambda *a, **k: pytest.fail("must not be sent")) assert remote.flush_pending(CFG) == 0 + + +def test_encrypt_roundtrip_matches_decrypt(): + import base64 + import os + + from diora_tui import crypto + + key = base64.b64encode(os.urandom(32)).decode() + iv, ct = crypto.encrypt(key, "Zäune".encode()) + assert len(bytes.fromhex(iv)) == 12 and crypto.decrypt(key, iv, ct).decode() == "Zäune" + with pytest.raises(crypto.DecryptError): + crypto.decrypt(base64.b64encode(os.urandom(32)).decode(), iv, ct) diff --git a/tui/tools/smoke_server.py b/tui/tools/smoke_server.py index 6703671..f527665 100644 --- a/tui/tools/smoke_server.py +++ b/tui/tools/smoke_server.py @@ -56,6 +56,29 @@ path = remote.download_book(lib, cfg, info["server_id"], info["title"]) check("book downloaded on demand and keyed by the server id", path.exists() and epub._book_id(path) == skey, path.name) book = blocks.load_flat_book(path) +# folders, length and the folder order come from the encrypted meta and prefs +import json # noqa: E402 + +import requests # noqa: E402 + +H = {"Authorization": f"Bearer {token}"} +raw_books = {b["id"]: b for b in api.fetch_books(server, token)[0]} +b0 = raw_books[info["server_id"]] +meta = json.loads(crypto.decrypt(key_b64, b0["meta_iv"], b0["meta_ct"])) +meta.update(folders=["Smoke B", "Smoke A"], pages=123) +iv, ct = crypto.encrypt(key_b64, json.dumps(meta).encode()) +check("meta written", requests.post(f"{server}/books/{info['server_id']}/meta/", json={"meta_ct": ct, "meta_iv": iv}, headers=H).ok) +iv, ct = crypto.encrypt(key_b64, json.dumps({"folder_order": ["Smoke B", "Smoke A"]}).encode()) +check("prefs written", requests.post(f"{server}/books/prefs/", json={"prefs_ct": ct, "prefs_iv": iv}, headers=H).ok) +remote.sync_library(lib, cfg) +from diora_tui import library, library_meta # noqa: E402 + +e = library_meta.remote_entries()[skey] +check("folders and length pulled from the meta", e["folders"] == ["Smoke B", "Smoke A"] and e["pages"] == 123, str(e)) +check("folder order pulled from the prefs", library_meta.get_extra("folder_order") == ["Smoke B", "Smoke A"]) +rows = library.build_rows([path], library_meta.remote_entries(), {}, {}, epub._book_id) +check("a row carries them", [r for r in rows if r.key == skey][0].folders == ["Smoke B", "Smoke A"]) + # the reader arrives at block 12 now now = progress.now_ms() a1 = blocks.format_anchor(12, 0.5)