TUI: Bibliothek wie im Web (Weiterlesen-Karte, Status- und Ordner-Chips, Suche, Sortierung, Länge)

library.py hält die Regeln der Web-Bibliothek (53-library.js) als reine Funktionen:
Status Angefangen/Ungelesen/Gelesen, Ordner (mehrere je Buch, Reihenfolge aus den
verschlüsselten Bibliothekseinstellungen, "Ohne Ordner"), Suche ohne Akzente, sechs
Sortierungen mit natürlicher Zahlenordnung, Weiterlesen-Karte aus den zuletzt gelesenen,
Bücher/Zeitschriften. library_screen.py zeigt sie an (Tasten 1-4, o, /, s, m, c; Chips und
Karte klickbar). Metadaten (Ordner, Seitenzahl, Zeitschrift) und die Ordnerreihenfolge
kommen beim Abgleich; fehlt die Länge, rechnet die TUI sie für geladene Bücher aus dem Text
(1800 Zeichen je Seite). crypto.encrypt für Stufe 3. 54 Tests, Smoke prüft Ordner/Länge/Prefs
gegen den lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-26 13:18:49 +01:00
parent 51b7316398
commit 695037fb9c
17 changed files with 1065 additions and 156 deletions

View file

@ -37,8 +37,10 @@ Tastenkürzel:
zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
`_looksLikeFootnoteLink` (Link in/um `<sup>`, 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

View file

@ -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}")

View file

@ -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"

View file

@ -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;

View file

@ -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)

View file

@ -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

210
tui/diora_tui/library.py Normal file
View file

@ -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

View file

@ -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 '_<name>'): 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}

View file

@ -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()

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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)

121
tui/tests/test_library.py Normal file
View file

@ -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

View file

@ -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(["<p>" + "wort " * 2000 + "</p>"], 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)

View file

@ -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)

View file

@ -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)