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:
parent
51b7316398
commit
695037fb9c
17 changed files with 1065 additions and 156 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
210
tui/diora_tui/library.py
Normal 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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
388
tui/diora_tui/library_screen.py
Normal file
388
tui/diora_tui/library_screen.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
121
tui/tests/test_library.py
Normal 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
|
||||
148
tui/tests/test_library_screen.py
Normal file
148
tui/tests/test_library_screen.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue