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