diora-web/tui/diora_tui/library.py
mrwnslz 695037fb9c 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>
2026-10-26 13:18:49 +01:00

210 lines
8 KiB
Python

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