211 lines
8 KiB
Python
211 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
|