"""EPUB parsing: turn a .epub file into a spine-ordered list of chapters.""" from __future__ import annotations import hashlib from dataclasses import dataclass from pathlib import Path import ebooklib from bs4 import BeautifulSoup from ebooklib import epub @dataclass class Chapter: title: str paragraphs: list[str] @dataclass class Book: id: str title: str author: str path: Path chapters: list[Chapter] def _book_id(path: Path) -> str: # Identifies a book by its resolved path for now. Once the sync API exists, # this should switch to whatever stable id diora assigns server-side. return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16] def _extract_paragraphs(html: bytes) -> list[str]: soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style"]): tag.decompose() paragraphs = [] for el in soup.find_all(["p", "h1", "h2", "h3", "h4", "li", "blockquote"]): text = el.get_text(" ", strip=True) if text: paragraphs.append(text) if not paragraphs: text = soup.get_text(" ", strip=True) if text: paragraphs.append(text) return paragraphs def load_epub(path: Path) -> Book: raw = epub.read_epub(str(path), options={"ignore_ncx": True}) title_meta = raw.get_metadata("DC", "title") title = title_meta[0][0] if title_meta else path.stem author_meta = raw.get_metadata("DC", "creator") author = author_meta[0][0] if author_meta else "Unbekannt" chapters: list[Chapter] = [] for idref, linear in raw.spine: if linear == "no": continue item = raw.get_item_with_id(idref) if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT: continue paragraphs = _extract_paragraphs(item.get_content()) if not paragraphs: continue chapters.append(Chapter(title=paragraphs[0][:60], paragraphs=paragraphs)) return Book(id=_book_id(path), title=title, author=author, path=path, chapters=chapters) def scan_library(library_dir: Path) -> list[Path]: if not library_dir.exists(): return [] return sorted(library_dir.rglob("*.epub"))