From 6c4d22cf5fa02f495fccdcfb6ad5680c8c3b5fd7 Mon Sep 17 00:00:00 2001 From: bndct-devops Date: Mon, 6 Jul 2026 00:43:36 +0200 Subject: [PATCH] fix(chapters): zip-level TOC fallback + extraction-attempt marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod's first Word Counts run left 8 EPUBs permanently "pending" and chapterless — real books (Tolkien, Overlord, an omnibus) whose TOCs chapter extraction couldn't see. Two defects: - Extraction depended entirely on ebooklib's nav parsing, which fails three ways on real files: EPUB2 books whose TOC lives only in the NCX; files whose broken/EMPTY nav shadows a good NCX (book.toc comes back as a bare empty Link); and malformed navs (no
    ) that crash read_epub outright. Fix: the fraction math is factored out (_build_chapter_map), ebooklib's toc shapes are normalized (bare Link, node tuples, a single wrapping Section unwraps to its children), and a zip-level parser (_chapters_from_zip — container→OPF→spine word counts, TOC from NCX navPoints or the EPUB3 nav) takes over whenever ebooklib yields nothing or refuses the file. Rescues 5 of dev's 7 holdouts; the other two are genuinely TOC-less files (empty , single "Start" entry). - TOC-less EPUBs re-queued in the backfill forever, because "done" was inferred from having BookChapter rows they can never gain. New Book.chapters_extracted_at stamps every attempt (found or not); replace_book_chapters distinguishes None (never ran — non-EPUB callers) from [] (ran, nothing there — stamp only, keep existing rows) from [...] (replace + stamp). The migration marks already-chaptered books done so upgraded installs don't re-extract their whole library. Suite green (5 new tests: NCX-only EPUB2 fixture end-to-end, the []-in-meta ingest contract, marker semantics incl. stale-TOC protection, the pending predicate). Verified against the actual failing prod/dev files: Overlord volumes now yield 8-15 chapters, Cloud FinOps 34. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++ backend/main.py | 12 ++ backend/models/book.py | 5 + backend/services/chapters.py | 23 ++- backend/services/metadata.py | 257 +++++++++++++++++++++++------ backend/services/word_count_job.py | 18 +- tests/test_book_chapters.py | 110 ++++++++++++ 7 files changed, 376 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f1ea9..cef2fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to Tome are documented here. Format loosely follows ## [Unreleased] +### Fixed +- **Chapter maps now extract from EPUB2 books.** Older EPUBs keep their table + of contents in an NCX file rather than an EPUB3 nav document; chapter + extraction ignored the NCX, so classics and older rips silently produced no + Time-per-chapter data. Extraction now falls back to the NCX whenever the nav + yields nothing — re-run Admin → Word Counts to pick up the affected books. +- **TOC-less EPUBs no longer re-queue in the Word Counts backfill forever.** + Books whose files genuinely have no usable table of contents were re-parsed + on every run because "done" was inferred from having chapter rows; a + dedicated attempt marker now records "tried, nothing there" once. + ### Added - **Sync closed books (KOReader plugin, build 34).** A new TomeSync menu action walks the device for books the plugin has never synced — read before diff --git a/backend/main.py b/backend/main.py index babd25e..8dd0113 100644 --- a/backend/main.py +++ b/backend/main.py @@ -73,6 +73,18 @@ async def lifespan(app: FastAPI): if "page_count" not in cols: conn.execute(text("ALTER TABLE books ADD COLUMN page_count INTEGER")) conn.commit() + # Chapter-extraction attempt marker (see Book.chapters_extracted_at). + # Distinguishes "never tried" from "tried, no usable TOC" so TOC-less + # EPUBs stop re-queuing in the backfill. Books that already have + # chapter rows are marked done, so upgraded installs don't re-extract + # their whole library once. + if "chapters_extracted_at" not in cols: + conn.execute(text("ALTER TABLE books ADD COLUMN chapters_extracted_at DATETIME")) + conn.execute(text( + "UPDATE books SET chapters_extracted_at = CURRENT_TIMESTAMP " + "WHERE id IN (SELECT DISTINCT book_id FROM book_chapters)" + )) + conn.commit() user_cols = {r[1] for r in conn.execute(text("PRAGMA table_info(users)")).fetchall()} if "role" not in user_cols: conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'guest'")) diff --git a/backend/models/book.py b/backend/models/book.py index ee4d2dc..b8a9b8a 100644 --- a/backend/models/book.py +++ b/backend/models/book.py @@ -33,6 +33,11 @@ class Book(Base): # and must never be stored as a book property (KOReader page-stats carry # their own total_pages per row for that). page_count: Mapped[Optional[int]] = mapped_column(Integer) + # When chapter extraction last RAN for this book (found chapters or not). + # Distinguishes "never tried" from "tried, no usable TOC" — without it, a + # TOC-less EPUB re-queues in the backfill forever, since pending can't be + # inferred from BookChapter rows it will never have. + chapters_extracted_at: Mapped[Optional[datetime]] = mapped_column(DateTime) # Hardcover identity — book-level because a title's Hardcover book/edition is # user-independent. Matched lazily by the sync worker (ISBN first, strict # title+author search as fallback). match_method "none" + matched_at set = diff --git a/backend/services/chapters.py b/backend/services/chapters.py index 4678448..6f9ff4f 100644 --- a/backend/services/chapters.py +++ b/backend/services/chapters.py @@ -1,18 +1,31 @@ """Persist a book's chapter map (see BookChapter for the anchor design).""" import logging +from datetime import datetime from sqlalchemy.orm import Session -from backend.models.book import BookChapter +from backend.models.book import Book, BookChapter logger = logging.getLogger(__name__) def replace_book_chapters(db: Session, book_id: int, chapters: list[dict] | None) -> int: - """Replace the stored chapter map for a book with the given extraction - result (as produced by metadata._extract_epub_chapters). An empty/None - extraction clears nothing — no TOC today doesn't invalidate a map extracted - from the same file yesterday. Returns the number of rows written.""" + """Persist a chapter-extraction RESULT for a book. + + - ``None`` → extraction never ran (non-EPUB callers): nothing happens. + - ``[]`` → extraction ran and found no usable TOC: the attempt is + stamped (``chapters_extracted_at``) so the backfill stops re-queuing the + book, but existing rows are left alone — a missing TOC today doesn't + invalidate a map extracted from the same file yesterday. + - ``[...]`` → rows are replaced and the attempt stamped. + + Returns the number of chapter rows written. + """ + if chapters is None: + return 0 + book = db.get(Book, book_id) + if book is not None: + book.chapters_extracted_at = datetime.utcnow() if not chapters: return 0 db.query(BookChapter).filter(BookChapter.book_id == book_id).delete() diff --git a/backend/services/metadata.py b/backend/services/metadata.py index cec9fed..0cf8885 100644 --- a/backend/services/metadata.py +++ b/backend/services/metadata.py @@ -103,42 +103,32 @@ def count_words_epub(path: Path) -> Optional[int]: return _count_words_from_zip(path) -def _extract_epub_chapters(book) -> list[dict]: - """TOC → device-independent chapter boundaries as fraction-of-book. - - Fractions come from cumulative word offsets of spine items: a chapter whose - TOC entry points into spine item k starts at words(items 0..k-1) / total. - Fragment anchors inside a file are ignored — top-level TOC entries almost - always sit at a file boundary, and per-file granularity is what the - time-per-chapter stat needs. Returns [] when there's no usable structure - (no TOC, unresolvable hrefs, or fewer than two distinct chapters). +def _build_chapter_map( + doc_names: list[str], + counts: list[int], + toc_entries: list[tuple[str, str]], +) -> list[dict]: + """Shared fraction math: ordered spine document names + their word counts + + TOC entries as (title, href) → chapter boundaries as fraction-of-book. + + A chapter whose TOC entry points into spine item k starts at + words(items 0..k-1) / total. Fragment anchors are ignored — top-level TOC + entries almost always sit at a file boundary, and per-file granularity is + what the time-per-chapter stat needs. Returns [] when there's no usable + structure (no TOC, unresolvable hrefs, fewer than two distinct chapters). """ - import ebooklib + from urllib.parse import unquote - id_to_item = {item.get_id(): item for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT)} - spine_ids = [sid for sid, _linear in book.spine if sid in id_to_item] - if not spine_ids: - return [] - - counts: list[int] = [] - href_to_idx: dict[str, int] = {} - for i, sid in enumerate(spine_ids): - item = id_to_item[sid] - href_to_idx[item.get_name()] = i - try: - raw = item.get_content() - counts.append(count_words_text(_html_to_text(raw.decode("utf-8", "ignore")))) - except Exception: # noqa: BLE001 — unreadable spine item contributes no words - counts.append(0) total = sum(counts) if total <= 0: return [] cum = [0] for c in counts: cum.append(cum[-1] + c) + href_to_idx = {name: i for i, name in enumerate(doc_names)} def resolve(href: str) -> Optional[int]: - href = href.split("#", 1)[0] + href = unquote(href.split("#", 1)[0]) if href in href_to_idx: return href_to_idx[href] # Tolerate OPF-dir path differences by unique basename. @@ -147,24 +137,12 @@ def resolve(href: str) -> Optional[int]: return hits[0] if len(hits) == 1 else None chapters: list[dict] = [] - for node in book.toc or []: - # Entries are Link or (Section, [children]); a Section may carry its own - # href, otherwise its first child anchors it. Only the top level counts — - # nested subsections are noise for a per-chapter time split. - head = node[0] if isinstance(node, tuple) else node - href = getattr(head, "href", None) - if not href and isinstance(node, tuple) and node[1]: - first = node[1][0] - first = first[0] if isinstance(first, tuple) else first - href = getattr(first, "href", None) - if not href: - continue + for title, href in toc_entries: idx = resolve(href) if idx is None: continue - title = (getattr(head, "title", None) or "").strip() chapters.append({ - "title": title or f"Chapter {len(chapters) + 1}", + "title": (title or "").strip() or f"Chapter {len(chapters) + 1}", "start_fraction": cum[idx] / total, }) @@ -187,6 +165,176 @@ def resolve(href: str) -> Optional[int]: return out +def _flatten_ebooklib_toc(raw) -> list[tuple[str, str]]: + """Normalize ebooklib's book.toc into (title, href) top-level entries. + + Real-world shapes seen: a proper list of Link/(Section, children) nodes; a + BARE Link (single-entry nav); a single (Section, children) tuple; and a + lone wrapping Section whose children are the actual chapters — unwrap that + one level, or every such book collapses to "one chapter" and is dropped. + """ + def is_node_tuple(n) -> bool: + return isinstance(n, tuple) and len(n) >= 2 and isinstance(n[1], (list, tuple)) + + if raw is None: + nodes = [] + elif isinstance(raw, list): + nodes = raw + elif is_node_tuple(raw): + nodes = [raw] + elif isinstance(raw, tuple): + nodes = list(raw) + else: + nodes = [raw] # bare Link + + # Unwrap a single all-enclosing Section (or a single href-less entry with + # children): its children are the real chapter list. + while len(nodes) == 1 and is_node_tuple(nodes[0]): + nodes = list(nodes[0][1]) + + entries: list[tuple[str, str]] = [] + for node in nodes: + # Entries are Link or (Section, [children]); a Section may carry its + # own href, otherwise its first child anchors it. Only the top level + # counts — nested subsections are noise for a per-chapter time split. + head = node[0] if isinstance(node, tuple) else node + href = getattr(head, "href", None) + if not href and is_node_tuple(node) and node[1]: + first = node[1][0] + first = first[0] if isinstance(first, tuple) else first + href = getattr(first, "href", None) + if href: + entries.append((getattr(head, "title", None) or "", href)) + return entries + + +def _extract_epub_chapters(book) -> list[dict]: + """TOC → chapter boundaries, from an already-open ebooklib book.""" + import ebooklib + + id_to_item = {item.get_id(): item for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT)} + spine_ids = [sid for sid, _linear in book.spine if sid in id_to_item] + if not spine_ids: + return [] + + doc_names: list[str] = [] + counts: list[int] = [] + for sid in spine_ids: + item = id_to_item[sid] + doc_names.append(item.get_name()) + try: + raw = item.get_content() + counts.append(count_words_text(_html_to_text(raw.decode("utf-8", "ignore")))) + except Exception: # noqa: BLE001 — unreadable spine item contributes no words + counts.append(0) + + return _build_chapter_map(doc_names, counts, _flatten_ebooklib_toc(book.toc)) + + +def _chapters_from_zip(path: Path) -> list[dict]: + """Chapter extraction straight from the EPUB zip, for the files ebooklib + mangles or refuses outright (observed live: an EPUB3 nav without an
      + crashes read_epub; another file's empty nav shadows a perfectly good NCX). + + Parses container.xml → OPF → spine order + word counts per document, then + takes TOC entries from the NCX (navPoints) or, failing that, the EPUB3 nav + document's toc list. Same fraction math as the ebooklib path. + """ + import posixpath + import xml.etree.ElementTree as ET + + NS = { + "cnt": "urn:oasis:names:tc:opendocument:xmlns:container", + "opf": "http://www.idpf.org/2007/opf", + "ncx": "http://www.daisy.org/z3986/2005/ncx/", + "xhtml": "http://www.w3.org/1999/xhtml", + "ops": "http://www.idpf.org/2007/ops", + } + try: + with zipfile.ZipFile(path) as zf: + container = ET.fromstring(zf.read("META-INF/container.xml")) + rootfile = container.find(".//cnt:rootfile", NS) + if rootfile is None: + return [] + opf_path = rootfile.get("full-path", "") + opf_dir = posixpath.dirname(opf_path) + opf = ET.fromstring(zf.read(opf_path)) + + def from_opf_dir(href: str) -> str: + return posixpath.normpath(posixpath.join(opf_dir, href)) if opf_dir else href + + manifest: dict[str, tuple[str, str]] = {} # id -> (href, media-type) + for item in opf.findall(".//opf:manifest/opf:item", NS): + manifest[item.get("id", "")] = (item.get("href", ""), item.get("media-type", "")) + + spine = opf.find(".//opf:spine", NS) + if spine is None: + return [] + doc_names: list[str] = [] + counts: list[int] = [] + for ref in spine.findall("opf:itemref", NS): + href, media = manifest.get(ref.get("idref", ""), ("", "")) + if not href or "xml" not in media and "html" not in media: + continue + member = from_opf_dir(href) + doc_names.append(href) # TOC hrefs are OPF-relative, like these + try: + counts.append(count_words_text( + _html_to_text(zf.read(member).decode("utf-8", "ignore")))) + except Exception: # noqa: BLE001 + counts.append(0) + + # TOC source 1: the NCX (spine@toc, or any manifest ncx item) + toc_entries: list[tuple[str, str]] = [] + ncx_id = spine.get("toc") + ncx_href = None + if ncx_id and ncx_id in manifest: + ncx_href = manifest[ncx_id][0] + else: + for href, media in manifest.values(): + if media == "application/x-dtbncx+xml": + ncx_href = href + break + if ncx_href: + try: + ncx = ET.fromstring(zf.read(from_opf_dir(ncx_href))) + for np in ncx.findall("./ncx:navMap/ncx:navPoint", NS): + label = np.find("./ncx:navLabel/ncx:text", NS) + content = np.find("./ncx:content", NS) + if content is not None and content.get("src"): + toc_entries.append(( + (label.text or "") if label is not None else "", + content.get("src", ""), + )) + except Exception: # noqa: BLE001 — fall through to the nav + toc_entries = [] + + # TOC source 2: the EPUB3 nav document + if not toc_entries: + nav_href = None + for item in opf.findall(".//opf:manifest/opf:item", NS): + if "nav" in (item.get("properties") or "").split(): + nav_href = item.get("href") + break + if nav_href: + try: + nav = ET.fromstring(zf.read(from_opf_dir(nav_href))) + nav_dir = posixpath.dirname(nav_href) + for a in nav.findall(".//xhtml:nav//xhtml:li/xhtml:a", NS): + href = a.get("href") + if href: + # nav hrefs are nav-relative; rebase to OPF-relative + rebased = posixpath.normpath(posixpath.join(nav_dir, href)) if nav_dir else href + toc_entries.append(("".join(a.itertext()), rebased)) + except Exception: # noqa: BLE001 + toc_entries = [] + + return _build_chapter_map(doc_names, counts, toc_entries) + except Exception as e: # noqa: BLE001 — no chapters is always a valid outcome + logger.info("zip-level chapter extraction failed for %s: %s", path, e) + return [] + + def count_pages_fixed_layout(path: Path) -> Optional[int]: """Intrinsic page count for fixed-layout formats (PDF/CBZ/CBR), without doing any of the cover/metadata work extract_metadata does. None for @@ -216,15 +364,24 @@ def count_pages_fixed_layout(path: Path) -> Optional[int]: def extract_chapters_epub(path: Path) -> list[dict]: """Open an EPUB from disk and extract its chapter map. Used by the backfill - job; ingest reuses the already-open book via _extract_epub_chapters.""" + job and as the ingest fallback. + + ebooklib first (fast, and consistent with the rest of this module); when + it yields nothing — or refuses the file entirely — the zip-level parser + takes over. That second path is what rescues EPUB2 books whose TOC lives + only in the NCX, files whose empty nav shadows a good NCX, and the + malformed-nav EPUBs read_epub crashes on.""" + chapters: list[dict] = [] try: from ebooklib import epub book = epub.read_epub(str(path), options={"ignore_ncx": True}) - return _extract_epub_chapters(book) - except Exception as e: # noqa: BLE001 — no chapters is always a valid outcome - logger.info("chapter extraction failed for %s: %s", path, e) - return [] + chapters = _extract_epub_chapters(book) + except Exception as e: # noqa: BLE001 — the zip path gets its turn + logger.info("ebooklib chapter extraction failed for %s: %s", path, e) + if chapters: + return chapters + return _chapters_from_zip(path) def _opf_meta_by_name(book, name: str) -> Optional[str]: @@ -386,8 +543,14 @@ def _first(items): # key: creation sites persist it as BookChapter rows; API previews # strip underscore keys. chapters = _extract_epub_chapters(book) - if chapters: - meta["_chapters"] = chapters + if not chapters: + # The already-open book gave nothing — let the zip-level parser + # have a go (NCX-only EPUB2, empty/malformed nav — see + # extract_chapters_epub). + chapters = _chapters_from_zip(path) + # Always set for EPUBs (possibly []) — the empty list records "tried, + # no usable TOC" downstream so the backfill stops re-queuing the book. + meta["_chapters"] = chapters # Cover extraction cover_id = None diff --git a/backend/services/word_count_job.py b/backend/services/word_count_job.py index 7ab9df6..94a8d0e 100644 --- a/backend/services/word_count_job.py +++ b/backend/services/word_count_job.py @@ -32,7 +32,7 @@ from sqlalchemy import func from backend.core.database import SessionLocal -from backend.models.book import Book, BookChapter, BookFile +from backend.models.book import Book, BookFile from backend.services.chapters import replace_book_chapters from backend.services.metadata import ( count_pages_fixed_layout, @@ -58,13 +58,18 @@ class WordCountAlreadyRunning(Exception): def _epub_books() -> dict[int, tuple[Optional[int], int, str, bool]]: """One row per book that has an EPUB file: - book_id → (word_count, size, path, has_chapters). - If a book has several EPUB files we keep the first.""" + book_id → (word_count, size, path, chapters_checked). + If a book has several EPUB files we keep the first. + + chapters_checked comes from the attempt marker, NOT from having chapter + rows — a TOC-less EPUB never gains rows and would otherwise re-queue on + every run forever.""" with SessionLocal() as db: rows = ( db.query( Book.id, Book.word_count, + Book.chapters_extracted_at, BookFile.file_size, BookFile.file_path, ) @@ -72,13 +77,10 @@ def _epub_books() -> dict[int, tuple[Optional[int], int, str, bool]]: .filter(func.lower(BookFile.format) == "epub") .all() ) - chaptered = { - r[0] for r in db.query(BookChapter.book_id).distinct().all() - } out: dict[int, tuple[Optional[int], int, str, bool]] = {} - for book_id, wc, size, path in rows: + for book_id, wc, checked_at, size, path in rows: if book_id not in out: - out[book_id] = (wc, size or 0, path, book_id in chaptered) + out[book_id] = (wc, size or 0, path, checked_at is not None) return out diff --git a/tests/test_book_chapters.py b/tests/test_book_chapters.py index cea2afa..cd26f7b 100644 --- a/tests/test_book_chapters.py +++ b/tests/test_book_chapters.py @@ -213,3 +213,113 @@ def test_endpoint_carries_chapters_block(self, db, client, admin_user, make_book assert r.status_code == 200, r.text chapters = r.json()["chapters"] assert chapters[0]["seconds"] == 120 and chapters[1]["seconds"] == 0 + + +# ── EPUB2 / NCX-only fallback + the re-queue marker ────────────────────────── + +def _make_epub2_ncx_only(path: Path, chapters: list[tuple[str, str]]) -> None: + """A hand-built EPUB2: TOC lives ONLY in the NCX (no EPUB3 nav document) — + the Tolkien-shaped case the nav-only extraction missed.""" + manifest_items, spine_refs, navpoints, files = [], [], [], [] + for i, (title, txt) in enumerate(chapters): + fn = f"c{i}.xhtml" + manifest_items.append(f'') + spine_refs.append(f'') + navpoints.append( + f'{title}' + f'' + ) + files.append((f"OEBPS/{fn}", + f'' + f'{title}

      {title}

      {txt}

      ')) + opf = f''' + + + epub2-ncx-test + NCX Onlyen + + {''.join(manifest_items)} + + + {''.join(spine_refs)} +''' + ncx = f''' + + + NCX Only + {''.join(navpoints)} +''' + with zipfile.ZipFile(path, "w") as z: + z.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED) + z.writestr("META-INF/container.xml", + '' + '' + '') + z.writestr("OEBPS/content.opf", opf) + z.writestr("OEBPS/toc.ncx", ncx) + for name, content in files: + z.writestr(name, content) + + +class TestNcxFallback: + def test_epub2_ncx_only_toc_extracts(self, tmp_path): + path = tmp_path / "epub2.epub" + _make_epub2_ncx_only(path, [("Chapter I", "alpha " * 100), ("Chapter II", "beta " * 100)]) + chapters = extract_chapters_epub(path) + assert [c["title"] for c in chapters] == ["Chapter I", "Chapter II"] + assert chapters[0]["start_fraction"] == 0.0 + assert chapters[-1]["end_fraction"] == 1.0 + + def test_ingest_meta_carries_empty_list_for_toc_less_epub(self, tmp_path): + # A single-chapter book has no usable structure — the meta must still + # say "tried" ([]), not stay silent (absent key), or the backfill + # re-queues it forever. + path = tmp_path / "b.epub" + _make_epub(path, [("Only", "some words here")]) + meta = extract_metadata(path, tmp_path) + assert meta["_chapters"] == [] + + +class TestExtractionMarker: + def test_replace_semantics(self, db, make_book): + from backend.services.chapters import replace_book_chapters + book = make_book(title="Marked") + + # None → untouched (non-EPUB caller) + replace_book_chapters(db, book.id, None) + db.flush() + assert book.chapters_extracted_at is None + + # [] → attempt stamped, no rows + replace_book_chapters(db, book.id, []) + db.flush() + assert book.chapters_extracted_at is not None + assert db.query(BookChapter).filter_by(book_id=book.id).count() == 0 + + # [...] → rows written; a later [] keeps them (stale-TOC protection) + replace_book_chapters(db, book.id, [ + {"idx": 0, "title": "One", "start_fraction": 0.0, "end_fraction": 1.0}, + ]) + db.flush() + assert db.query(BookChapter).filter_by(book_id=book.id).count() == 1 + replace_book_chapters(db, book.id, []) + db.flush() + assert db.query(BookChapter).filter_by(book_id=book.id).count() == 1 + + def test_backfill_pending_predicate(self, db, make_book): + from backend.services.chapters import replace_book_chapters + # The job pends a book iff words are missing OR it was never + # chapter-checked — a checked TOC-less book must NOT pend again. + checked = make_book(title="NoTocChecked", file_format="epub") + checked.word_count = 1000 + replace_book_chapters(db, checked.id, []) + fresh = make_book(title="FreshEpub", file_format="epub") + fresh.word_count = 1000 + db.commit() + + def pending(b): + return b.word_count is None or b.chapters_extracted_at is None + + assert pending(checked) is False + assert pending(fresh) is True