4 Commits
Author SHA1 Message Date
tim 8faab2463b Move and delete chapters by hand in the chapter list
The merge cannot always tell where new chapters go. For example, a site
can list an announcement before the chapters of an old book. You can now
select chapters and move them, in their order, to after another chapter
or to the start. You can also delete them with their revisions. A story
keeps at least one chapter. The book is then rebuilt. A deleted chapter
that the site still lists comes back with the next download.

The Move… and Delete… buttons open a dialog that names the selected
chapters before anything changes. A shift-click selects a range of
chapters.

The Select all checkbox moves into the first heading of the chapter
list. On a phone, that heading shows with the text "Select all".
2026-09-27 23:38:28 +02:00
tim ee611cd283 Sort the story list without a leading The, A or An 2026-09-27 23:38:28 +02:00
tim df9959a078 Add a script that cleans up smart punctuation in chapter revisions
Some revisions differ from others only by curly quotes, curly
apostrophes and the ellipsis. The script deletes those revisions and
keeps the one with the least smart punctuation. In revisions with other
text changes, it makes the punctuation straight and stores the new text
hash. It does not change the newest revision if it came from the site.

The script uses only the Python standard library, so you can run it on
the host against bindery.db. It is a dry run unless you give --apply,
and --apply makes a backup first.
2026-09-27 23:38:28 +02:00
tim 5df2806694 Add a script that fills a gap in a story from an ePub without chapter links
A FanFicFare ePub records the site address of each chapter, so Bindery
can import it. Other ePubs, such as the author's own ebook of a story,
have no chapter links. The script asks which stored chapters come before
and after the gap, and which ePub chapters match them. It suggests
matches by title and puts the suggestion on the input line. It then adds
the ePub chapters between the matches, with their images, in the order
of the ePub.

Each added chapter gets a stable URL with the #bindery-import- marker
and the revision source "epub-import". A second run with the same ePub
does not add the same chapters again. --remove deletes the chapters that
the script added.
2026-09-27 23:38:28 +02:00
14 changed files with 1465 additions and 17 deletions
+28
View File
@@ -482,6 +482,34 @@ def set_chapters_included(story_id: int, chapter_ids: list[int], included: bool)
return queue_story(story_id, DownloadMode.REBUILD.value) return queue_story(story_id, DownloadMode.REBUILD.value)
def move_chapters(story_id: int, chapter_ids: list[int], after_id: int | None) -> dict[str, Any]:
"""Move chapters, in their current order, to directly after the chapter `after_id`, or to the start with None."""
order = [c["id"] for c in store.list_chapters(story_id)]
moving = [cid for cid in order if cid in set(chapter_ids)]
if not moving:
raise ValueError("Select the chapters to move")
if after_id is not None and after_id not in order:
raise KeyError(f"No chapter {after_id} in this story")
if after_id in moving:
raise ValueError("The chapters cannot go after a chapter that moves with them")
rest = [cid for cid in order if cid not in moving]
at = rest.index(after_id) + 1 if after_id is not None else 0
store.set_positions(story_id, rest[:at] + moving + rest[at:])
return queue_story(story_id, DownloadMode.REBUILD.value)
def delete_chapters(story_id: int, chapter_ids: list[int]) -> dict[str, Any]:
"""Delete chapters and their revisions. A chapter that the site still lists comes back with the next download."""
order = [c["id"] for c in store.list_chapters(story_id)]
deleting = [cid for cid in order if cid in set(chapter_ids)]
if not deleting:
raise ValueError("Select the chapters to delete")
if len(deleting) == len(order):
raise ValueError("A story must keep at least one chapter. To remove the story, stop tracking it")
store.delete_chapters(story_id, deleting)
return queue_story(story_id, DownloadMode.REBUILD.value)
def link_story(story_id: int, book_uuid: str | None) -> None: def link_story(story_id: int, book_uuid: str | None) -> None:
"""Link a story to a library book by its uuid, or unlink it with None.""" """Link a story to a library book by its uuid, or unlink it with None."""
story = _require_story(story_id) story = _require_story(story_id)
+83 -6
View File
@@ -87,7 +87,12 @@ export function storyPage(main, { storyId }, config) {
const metadata = h('div', { class: 'metadata' }); const metadata = h('div', { class: 'metadata' });
const updates = h('div'); const updates = h('div');
const chapters = h('div'); const chapters = h('div');
const selectAll = h('span', { class: 'chapter-select-all' }); // One dialog for Move… and Delete…. It names the selected chapters before anything changes.
const dialog = h('dialog', { class: 'chapter-dialog', 'aria-labelledby': 'chapter-dialog-title' });
let moveAfterId = null;
// A shift-click on a chapter checkbox sets every chapter from the last one clicked.
let anchor = null;
let shift = false;
const jobs = jobPanel({ hours: config.story_jobs_hours, count: config.story_jobs_count }); const jobs = jobPanel({ hours: config.story_jobs_hours, count: config.story_jobs_count });
const linked = h('div'); const linked = h('div');
const searchResults = h('div'); const searchResults = h('div');
@@ -381,8 +386,17 @@ export function storyPage(main, { storyId }, config) {
function renderChapters() { function renderChapters() {
const list = details.chapters; const list = details.chapters;
const ids = list.map((c) => c.id);
const pick = (i, on) => {
const from = shift ? ids.indexOf(anchor) : -1;
const range = from < 0 ? [ids[i]] : ids.slice(Math.min(from, i), Math.max(from, i) + 1);
for (const cid of range) on ? selected.add(cid) : selected.delete(cid);
anchor = ids[i];
shift = false;
if (range.length > 1) renderChapters();
};
let number = 0; let number = 0;
const rows = list.map((c) => { const rows = list.map((c, i) => {
if (c.included) number += 1; if (c.included) number += 1;
const route = `/story/${id}/chapter/${c.id}`; const route = `/story/${id}/chapter/${c.id}`;
return h( return h(
@@ -391,8 +405,10 @@ export function storyPage(main, { storyId }, config) {
h( h(
'td', 'td',
{}, {},
checkbox(selected.has(c.id), (on) => (on ? selected.add(c.id) : selected.delete(c.id)), { checkbox(selected.has(c.id), (on) => pick(i, on), {
'aria-label': `Select ${chapterTitle(c.title)}`, 'aria-label': `Select ${chapterTitle(c.title)}`,
title: 'Shift-click to select all chapters from the last one you clicked',
onclick: (e) => (shift = e.shiftKey),
}), }),
), ),
h('td', { 'data-label': '#' }, c.included ? String(number) : ''), h('td', { 'data-label': '#' }, c.included ? String(number) : ''),
@@ -412,11 +428,12 @@ export function storyPage(main, { storyId }, config) {
selected = new Set(on ? list.map((c) => c.id) : []); selected = new Set(on ? list.map((c) => c.id) : []);
renderChapters(); renderChapters();
}, { 'aria-label': 'Select all chapters' }); }, { 'aria-label': 'Select all chapters' });
fill(selectAll, h('label', {}, all, ' Select all')); // A phone hides the other headings, so the text shows only there.
const selectAll = h('label', {}, all, h('span', { class: 'select-all-text' }, ' Select all'));
fill( fill(
chapters, chapters,
table( table(
['', '#', 'Title', 'State', 'Revisions', 'Added'], [selectAll, '#', 'Title', 'State', 'Revisions', 'Added'],
rows.length ? rows : h('tr', {}, h('td', { colspan: 6, class: 'empty' }, 'No chapters stored yet.')), rows.length ? rows : h('tr', {}, h('td', { colspan: 6, class: 'empty' }, 'No chapters stored yet.')),
'chapters-table', 'chapters-table',
), ),
@@ -462,6 +479,64 @@ export function storyPage(main, { storyId }, config) {
await load(); await load();
}; };
const finish = async (message) => {
dialog.close();
toast(message);
refreshJobs();
await load();
};
const moveForm = (picked, chapterIds) => {
const others = details.chapters.filter((c) => !selected.has(c.id));
const after = h(
'select',
{ id: 'move-after' },
h('option', { value: '' }, 'The start'),
others.map((c) => h('option', { value: String(c.id) }, chapterTitle(c.title))),
);
after.value = others.some((c) => c.id === moveAfterId) ? String(moveAfterId) : '';
return [
h('div', { class: 'controls' }, h('label', { for: 'move-after' }, picked.length === 1 ? 'Put it after' : 'Put them after'), after),
button('Move', async () => {
moveAfterId = after.value === '' ? null : Number(after.value);
await api(`stories/${id}/chapters/move`, 'POST', { chapter_ids: chapterIds, after: moveAfterId });
await finish('Chapters moved. The book is being rebuilt.');
}, { class: 'primary' }),
];
};
const deleteForm = (picked, chapterIds) => [
h(
'p',
{},
`Bindery deletes ${picked.length === 1 ? 'this chapter and its' : 'these chapters and their'} revisions. `,
'You cannot undo this.',
picked.some((c) => c.on_site) ? ' A chapter that the site still lists comes back with the next download of new chapters.' : '',
),
button('Delete', async () => {
await api(`stories/${id}/chapters/delete`, 'POST', { chapter_ids: chapterIds });
await finish('Chapters deleted. The book is being rebuilt.');
}, { class: 'danger' }),
];
const openDialog = (verb) => {
if (!selected.size) {
toast('Select chapters first', true);
return;
}
const picked = details.chapters.filter((c) => selected.has(c.id));
const chapterIds = picked.map((c) => c.id);
const [body, action] = (verb === 'Move' ? moveForm : deleteForm)(picked, chapterIds);
fill(
dialog,
h('h3', { id: 'chapter-dialog-title' }, `${verb} ${plural(picked.length, 'chapter')}`),
h('ol', { class: 'dialog-chapters' }, picked.map((c) => h('li', {}, chapterTitle(c.title)))),
body,
h('div', { class: 'dialog-actions' }, h('button', { type: 'button', onclick: () => dialog.close() }, 'Cancel'), action),
);
dialog.showModal();
};
const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]); const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]);
const modes = SITE_MODES.map((m) => const modes = SITE_MODES.map((m) =>
h( h(
@@ -601,7 +676,6 @@ export function storyPage(main, { storyId }, config) {
'details', 'details',
{ class: 'card', open: true }, { class: 'card', open: true },
h('summary', {}, h('h2', {}, 'Chapters')), h('summary', {}, h('h2', {}, 'Chapters')),
selectAll,
h( h(
'div', 'div',
{ class: 'controls chapter-actions' }, { class: 'controls chapter-actions' },
@@ -610,8 +684,11 @@ export function storyPage(main, { storyId }, config) {
), ),
button('Take out of book', () => setIncluded(false)), button('Take out of book', () => setIncluded(false)),
button('Put back in book', () => setIncluded(true)), button('Put back in book', () => setIncluded(true)),
button('Move…', () => openDialog('Move')),
button('Delete…', () => openDialog('Delete'), { class: 'danger' }),
), ),
chapters, chapters,
dialog,
), ),
epubImport(id, 'Import chapters from an ePub'), epubImport(id, 'Import chapters from an ePub'),
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el), h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el),
+54 -5
View File
@@ -390,9 +390,8 @@ details.card[open] > summary .summary-action {
justify-content: space-between; justify-content: space-between;
} }
.chapter-select-all { .select-all-text {
display: block; display: none;
margin-top: 8px;
} }
/* Stays visible while the chapter list scrolls, so the buttons need no scrolling back up to /* Stays visible while the chapter list scrolls, so the buttons need no scrolling back up to
@@ -768,8 +767,7 @@ textarea {
display: block; display: block;
} }
.stories-table thead, .stories-table thead {
.chapters-table thead {
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 1px; height: 1px;
@@ -823,6 +821,57 @@ textarea {
flex-basis: 100%; flex-basis: 100%;
font-weight: 600; font-weight: 600;
} }
/* Of the chapter list headings, only the Select all checkbox shows. */
/* The left padding is the 1px border and 12px padding of a row card, so the checkboxes line up. */
.chapters-table th {
border-bottom: 0;
padding: 0 0 8px 13px;
}
.chapters-table th:not(:first-child) {
display: none;
}
.select-all-text {
display: inline;
}
}
/* The Move… and Delete… dialog of the chapter list. */
.chapter-dialog {
width: min(32rem, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 16px 20px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
}
.chapter-dialog::backdrop {
background: rgb(0 0 0 / 0.4);
}
.chapter-dialog h3 {
margin: 0 0 8px;
}
.dialog-chapters {
max-height: 40dvh;
overflow-y: auto;
margin: 0 0 12px;
padding: 8px 8px 8px 32px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card-bg);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
} }
/* Jobs */ /* Jobs */
+18 -2
View File
@@ -6,6 +6,7 @@ site without losing text.
""" """
import json import json
import re
import sqlite3 import sqlite3
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
@@ -120,6 +121,14 @@ def _story(row: sqlite3.Row | None) -> dict[str, Any] | None:
return d return d
_ARTICLE = re.compile(r"^(?:the|an?)\s+", re.I)
def sort_title(title: str | None) -> str:
"""The key that sorts a story list by title, without a leading "The", "A" or "An"."""
return _ARTICLE.sub("", (title or "").strip()).casefold()
class Store: class Store:
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
self.path = Path(path) self.path = Path(path)
@@ -190,8 +199,8 @@ class Store:
AND c.on_site = 0) AS offsite_count, AND c.on_site = 0) AS offsite_count,
(SELECT COUNT(*) FROM chapters c WHERE c.story_id = s.id AND c.included = 1 (SELECT COUNT(*) FROM chapters c WHERE c.story_id = s.id AND c.included = 1
AND (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1) AS revised_count AND (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1) AS revised_count
FROM stories s ORDER BY s.title COLLATE NOCASE""").fetchall() FROM stories s""").fetchall()
return [_story(r) for r in rows] return [_story(r) for r in sorted(rows, key=lambda r: sort_title(r["title"]))]
def update_story(self, story_id: int, **fields: Any) -> None: def update_story(self, story_id: int, **fields: Any) -> None:
if not fields: if not fields:
@@ -336,6 +345,13 @@ class Store:
[(i, cid, story_id) for i, cid in enumerate(chapter_ids)], [(i, cid, story_id) for i, cid in enumerate(chapter_ids)],
) )
def delete_chapters(self, story_id: int, chapter_ids: list[int]) -> None:
"""Delete chapters of the story and their revisions."""
with self._connect() as conn:
conn.executemany(
"DELETE FROM chapters WHERE id = ? AND story_id = ?", [(cid, story_id) for cid in chapter_ids]
)
def list_revisions(self, chapter_id: int) -> list[dict[str, Any]]: def list_revisions(self, chapter_id: int) -> list[dict[str, Any]]:
with self._connect() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
+14
View File
@@ -332,6 +332,20 @@ def chapters_included(story_id: int) -> Any:
) )
@app.post("/api/stories/<int:story_id>/chapters/move")
def move_chapters(story_id: int) -> Any:
data = _json()
after = data.get("after")
return orchestrator.move_chapters(
story_id, [int(c) for c in data.get("chapter_ids", [])], None if after is None else int(after)
)
@app.post("/api/stories/<int:story_id>/chapters/delete")
def delete_chapters(story_id: int) -> Any:
return orchestrator.delete_chapters(story_id, [int(c) for c in _json().get("chapter_ids", [])])
@app.get("/api/stories/<int:story_id>/epub") @app.get("/api/stories/<int:story_id>/epub")
def story_epub(story_id: int) -> Any: def story_epub(story_id: int) -> Any:
path, title = orchestrator.build_for_download(story_id) path, title = orchestrator.build_for_download(story_id)
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""Fill a gap in a tracked story with chapters from an ePub that has no chapter links.
Bindery can import chapters from an ePub that FanFicFare made, because that
ePub records the site address (the link) of each chapter. Other ePubs, such as
the author's own ebook of the story, have no chapter links, so Bindery cannot
tell where their chapters go. Use this script when the site no longer has some
chapters, and you have an ePub without chapter links that has them.
Usage:
CONFIG_DIR=./config uv run python scripts/import_unlinked_epub_chapters.py <story-url> <path-to-epub>
CONFIG_DIR=./config uv run python scripts/import_unlinked_epub_chapters.py <story-url> --remove
Run the script on the host, in the project folder, with CONFIG_DIR set to the
folder that holds bindery.db. The container image does not include the
scripts folder. Stop Bindery before you run the script, so that nothing else
writes to the database at the same time.
The story must be tracked in Bindery, with at least one stored chapter. The
script asks four questions:
1. Which stored chapter comes just before the gap. Type "start" if the gap
is at the start of the story.
2. Which ePub chapter is the same as that stored chapter. After "start", the
question is which ePub chapter to add first, so that you can leave out the
cover and the title pages.
3. Which stored chapter comes just after the gap. Type "end" if the gap is at
the end of the story.
4. Which ePub chapter is the same as that stored chapter. After "end", the
question is which ePub chapter to add last.
The script suggests an answer where the chapter titles match, and puts it on
the input line. Press Enter to accept it, or change it. Type "list" to see all
the chapters again. The script then shows the ePub chapters that go into the
gap, in the order of the ePub, and adds them after you say yes. Their images
come with them. A second run with the same ePub does not add the same chapters
again.
The added chapters have no address on the site, so they show as "removed from
site" on the story page. A "full" download takes them out of the book, like
other chapters that the site does not list. Select them and use "Put back in
book" to put them back. To delete them, use "Delete…" on the story page, or run
the script with --remove. Rebuild the book after each change.
"""
import argparse
import hashlib
import posixpath
import re
import sqlite3
import sys
import warnings
import zipfile
from difflib import SequenceMatcher
from pathlib import Path
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
from defusedxml import ElementTree
try:
import readline
except ImportError: # Windows
readline = None
# Run as a file, Python puts scripts/ on the path, not the project folder that holds bindery/.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from fanficfare.story import imagetypes # noqa: E402
from bindery import config, fff # noqa: E402
from bindery.diffing import text_hash # noqa: E402
from bindery.store import Store # noqa: E402
OPF = "{http://www.idpf.org/2007/opf}"
NCX = "{http://www.daisy.org/z3986/2005/ncx/}"
DC = "{http://purl.org/dc/elements/1.1/}"
CONTAINER = "{urn:oasis:names:tc:opendocument:xmlns:container}"
URL_MARK = "#bindery-import-"
SOURCE = "epub-import"
# Images get a file: URL, so a build never asks the story site for them.
IMAGE_URL = "file:///bindery-epub-import/"
# A long list shows only this many chapters around the one that the question is about.
WINDOW = 12
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
class ScriptError(Exception):
"""A problem the user can fix."""
def _resolve(folder: str, href: str) -> str:
"""The path in the zip file of a link from a file in `folder`, without its #fragment."""
return posixpath.normpath(posixpath.join(folder, href.split("#", 1)[0]))
def _toc_titles(z: zipfile.ZipFile, opf, folder: str) -> dict[str, str]:
"""The title of each file in the ePub's table of contents (EPUB 2 NCX or EPUB 3 nav), by path.
The first entry of a file wins. Entries that are only a number, such as page numbers, are left out.
"""
items = list(opf.find(f"{OPF}manifest"))
titles: dict[str, str] = {}
def add(path: str, label: str) -> None:
label = " ".join(label.split())
if label and not label.isdigit():
titles.setdefault(path, label)
ncx = next((i for i in items if i.get("media-type") == "application/x-dtbncx+xml"), None)
if ncx is not None:
ncx_path = _resolve(folder, ncx.get("href"))
nav_map = ElementTree.fromstring(z.read(ncx_path)).find(f"{NCX}navMap")
for point in nav_map.iter(f"{NCX}navPoint") if nav_map is not None else []:
text, content = point.find(f"{NCX}navLabel/{NCX}text"), point.find(f"{NCX}content")
if text is not None and content is not None and content.get("src"):
add(_resolve(posixpath.dirname(ncx_path), content.get("src")), text.text or "")
nav = next((i for i in items if "nav" in (i.get("properties") or "").split()), None)
if nav is not None:
nav_path = _resolve(folder, nav.get("href"))
soup = BeautifulSoup(z.read(nav_path), "html5lib")
toc = soup.find("nav", attrs={"epub:type": "toc"}) or soup.find("nav")
for a in toc.find_all("a", href=True) if toc else []:
add(_resolve(posixpath.dirname(nav_path), a["href"]), a.get_text(" "))
return titles
def _only_links(soup: BeautifulSoup) -> bool:
"""Whether all the text of a file is in links, as in a table of contents."""
texts = [t for t in soup.body.find_all(string=True) if t.strip()] if soup.body else []
return bool(texts) and all(t.find_parent("a") for t in texts)
def _book_id(opf, path: Path) -> str:
"""A short ID of the ePub, from its identifier, or else its file name."""
identifier = opf.find(f"{OPF}metadata/{DC}identifier")
key = identifier.text.strip() if identifier is not None and identifier.text else path.name
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
def _take_images(z: zipfile.ZipFile, soup: BeautifulSoup, folder: str, book: str) -> list[dict]:
"""Point each <img> of a chapter at a URL of its own, and return the images with those URLs.
An image that the ePub does not have, or that FanFicFare cannot use, is left out.
"""
images = []
for img in soup.body.find_all("img", src=True):
if img["src"].startswith(("data:", "http:", "https:")):
continue
path = _resolve(folder, img["src"])
ext = path.rsplit(".", 1)[-1].lower()
if ext in imagetypes and path in z.namelist():
img["src"] = f"{IMAGE_URL}{book}/{path}"
images.append({"url": img["src"], "ext": ext, "mime": imagetypes[ext], "data": z.read(path)})
else:
img.decompose()
return images
def read_epub(path: Path) -> list[dict]:
"""Read the chapters of an ePub, in reading order, with the images that each one uses.
The script reads the OPF manifest and spine directly, so it can read any
ePub. A file that has only links, such as a table of contents, is not a
chapter. Titles come from the table of contents of the ePub, or else from
the first heading of the chapter. Each chapter has a stable `key`: the ID
of the ePub and the path of the chapter in it.
"""
try:
with zipfile.ZipFile(path) as z:
container = ElementTree.fromstring(z.read("META-INF/container.xml"))
rootfile = container.find(f".//{CONTAINER}rootfile")
if rootfile is None or not rootfile.get("full-path"):
raise ScriptError(f"{path.name} has no OPF file")
opf_path = rootfile.get("full-path")
opf = ElementTree.fromstring(z.read(opf_path))
folder = posixpath.dirname(opf_path)
manifest = {item.get("id"): item for item in opf.find(f"{OPF}manifest")}
toc = _toc_titles(z, opf, folder)
book = _book_id(opf, path)
chapters = []
for itemref in opf.find(f"{OPF}spine").findall(f"{OPF}itemref"):
item = manifest.get(itemref.get("idref"))
if itemref.get("linear") == "no" or item is None:
continue
if "nav" in (item.get("properties") or "").split():
continue
href = item.get("href") or ""
if not href.lower().endswith((".xhtml", ".html", ".htm")):
continue
full = _resolve(folder, href)
soup = BeautifulSoup(z.read(full), "html5lib")
if not soup.body or _only_links(soup):
continue
heading = soup.find(["h1", "h2", "h3"])
if full in toc:
title = toc[full]
elif heading and heading.get_text(strip=True):
title = heading.get_text(" ", strip=True)
elif soup.title and soup.title.get_text(strip=True):
title = soup.title.get_text(strip=True)
else:
title = href
images = _take_images(z, soup, posixpath.dirname(full), book)
html = "".join(str(c) for c in soup.body.contents).strip()
if html:
chapters.append({"title": title, "html": html, "key": f"{book}-{full}", "images": images})
except (zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
raise ScriptError(f"{path.name} is not a readable ePub: {e}") from e
if not chapters:
raise ScriptError(f"{path.name} has no chapters that the script can read")
return chapters
def suggest_matches(title: str, candidates: list[str], limit: int = 3) -> list[int]:
"""Indexes of the candidates most like `title`, best first."""
ratio = {i: SequenceMatcher(None, title.lower(), c.lower()).ratio() for i, c in enumerate(candidates)}
return sorted((i for i in ratio if ratio[i] > 0.4), key=lambda i: ratio[i], reverse=True)[:limit]
def chapter_url(story_url: str, chapter: dict) -> str:
"""A made-up, stable URL for a chapter that has no link. The same ePub chapter always gets the same URL."""
slug = re.sub(r"[^a-z0-9]+", "-", chapter["key"].lower()).strip("-")
return f"{story_url.rstrip('/')}{URL_MARK}{slug}"
def splice_order(existing_urls: list[str], before_idx: int, new_urls: list[str]) -> list[str]:
"""`existing_urls` with `new_urls` put directly after position `before_idx` (-1 for the start)."""
return existing_urls[: before_idx + 1] + new_urls + existing_urls[before_idx + 1 :]
def _show(items: list[str], marks: set[int] = frozenset(), around: int | None = None) -> None:
"""Print a numbered list. With `around`, a long list shows only the part near that index."""
first, last = 0, len(items)
if around is not None and len(items) > WINDOW * 2:
first = max(0, min(around - WINDOW // 2, len(items) - WINDOW))
last = first + WINDOW
# The numbers go in brackets, padded so that the titles line up. A suggestion has a * in front.
width = len(str(len(items))) + 2
if first:
print(" …")
for i in range(first, last):
print(f"{'*' if i in marks else ' '} {f'[{i + 1}]':<{width}} {items[i]}")
if last < len(items):
print(f" … (type 'list' to see all {len(items)})")
def _ask(prompt: str, default: str) -> str:
"""Read a line. The default is already on the input line, where the terminal allows it."""
prefill = bool(default) and readline is not None and sys.stdin.isatty()
if prefill:
readline.set_startup_hook(lambda: readline.insert_text(default))
elif default:
prompt = f"{prompt[:-2]} [{default}]: "
try:
answer = input(prompt).strip()
finally:
if prefill:
readline.set_startup_hook()
return answer or default
def choose(
prompt: str,
items: list[str],
*,
word: str | None = None,
default: int | None = None,
marks: set[int] = frozenset(),
around: int | None = None,
minimum: int = 0,
) -> int | str:
"""Show `items` and ask for one of them. Returns its index, or `word` if the user typed it.
The prompt says what the question is, so the user does not have to look above a long list for it.
"""
_show(items, marks, around)
words = ["'list'"] + ([f"'{word}'"] if word else [])
while True:
answer = _ask(f"{prompt}: ", "" if default is None else str(default + 1)).lower()
if answer == "list":
_show(items, marks)
elif word and answer == word:
return word
elif answer.isdigit() and minimum < int(answer) <= len(items):
return int(answer) - 1
else:
print(f"Type a number from {minimum + 1} to {len(items)}, or {' or '.join(words)}.")
def _confirm(prompt: str) -> bool:
return input(f"{prompt} [y/N] ").strip().lower() == "y"
def _added_by_script(url: str) -> bool:
return URL_MARK in url
def remove_added_chapters(db_store: Store, story: dict, existing: list[dict]) -> None:
added = [c for c in existing if _added_by_script(c["url"])]
if not added:
print(f"{story['title']} has no chapters that this script added.")
return
print(f"{story['title']} has {_count(len(added), 'chapter')} that this script added:")
for c in added:
print(f" - {fff.plain_title(c['title'])}")
if not _confirm("Delete them and all their revisions?"):
return
conn = sqlite3.connect(config.DB_PATH)
try:
# Without this, SQLite keeps the revisions of a deleted chapter.
conn.execute("PRAGMA foreign_keys=ON")
with conn:
conn.executemany("DELETE FROM chapters WHERE id = ?", [(c["id"],) for c in added])
finally:
conn.close()
print(f"Deleted {_count(len(added), 'chapter')}. Rebuild {story['title']} in Bindery.")
def _count(n: int, word: str) -> str:
return f"{n} {word}{'' if n == 1 else 's'}"
def ask_for_gap(
story_title: str, titles: list[str], labels: list[str], epub_name: str, epub_titles: list[str]
) -> tuple[int, int, int]:
"""Ask where the gap is. Returns the index of the stored chapter before the gap (-1 for the start),
and the indexes of the first and last ePub chapters to add.
`titles` are the stored chapter titles, and `labels` are the same titles as the lists show them.
"""
print(f"\n{story_title} has {_count(len(titles), 'stored chapter')}:")
before = choose("Stored chapter just BEFORE the gap (or 'start')", labels, word="start")
print(f"\n{epub_name} has {_count(len(epub_titles), 'chapter')}:")
if before == "start":
before = -1
first = choose("FIRST ePub chapter to add", epub_titles)
else:
guess = suggest_matches(titles[before], epub_titles)
match = choose(
f'ePub chapter that matches "{titles[before]}"',
epub_titles,
default=guess[0] if guess else None,
marks=set(guess),
)
first = match + 1
print(f"\n{story_title}, near the gap:")
if before + 1 < len(titles):
after = choose(
"Stored chapter just AFTER the gap (or 'end')",
labels,
word="end",
default=before + 1,
around=before + 1,
minimum=before + 1,
)
else:
print(f' None. The gap is after the last stored chapter, "{titles[before]}".')
after = "end"
print(f"\n{epub_name}, near the gap:")
if after == "end":
last = choose(
"LAST ePub chapter to add", epub_titles, default=len(epub_titles) - 1, around=first, minimum=first
)
else:
if after != before + 1:
skipped = _count(after - before - 1, "stored chapter")
print(f"Note: the {skipped} between those two stay where they are, after the added chapters.")
later = epub_titles[first:]
guess = [first + i for i in suggest_matches(titles[after], later)]
match = choose(
f'ePub chapter that matches "{titles[after]}"',
epub_titles,
default=guess[0] if guess else None,
marks=set(guess),
around=guess[0] if guess else first,
minimum=first,
)
last = match - 1
return before, first, last
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("story_url", help="The URL of the tracked story")
parser.add_argument("epub", type=Path, nargs="?", help="The ePub that has the missing chapters")
parser.add_argument("--remove", action="store_true", help="Delete the chapters that this script added")
args = parser.parse_args(argv)
normal = fff.normalize_url(args.story_url)
if not normal:
raise ScriptError(f"Not a supported story URL: {args.story_url}")
if not args.remove and (args.epub is None or not args.epub.is_file()):
raise ScriptError(f"No such file: {args.epub}" if args.epub else "Give the path to the ePub.")
db_store = Store(config.DB_PATH)
story = db_store.get_story_by_url(normal[0])
if not story:
raise ScriptError(f"No tracked story at {normal[0]}. Add it in Bindery first.")
existing = db_store.list_chapters(story["id"])
if args.remove:
remove_added_chapters(db_store, story, existing)
return
if not existing:
raise ScriptError(f"{story['title']} has no stored chapters yet. Download it in Bindery first.")
epub_chapters = read_epub(args.epub)
titles = [fff.plain_title(c["title"]) for c in existing]
labels = [t + ("" if c["on_site"] else " (not on site)") for t, c in zip(titles, existing, strict=True)]
epub_titles = [c["title"] for c in epub_chapters]
before, first, last = ask_for_gap(story["title"], titles, labels, args.epub.name, epub_titles)
stored_urls = {c["url"] for c in existing}
to_add = [c for c in epub_chapters[first : last + 1] if chapter_url(story["url"], c) not in stored_urls]
already = last + 1 - first - len(to_add)
if already:
print(f"\n{_count(already, 'ePub chapter')} of the gap are in the story already, from an earlier run.")
if not to_add:
print("There are no chapters to add.")
return
where = "at the start" if before == -1 else f'after "{titles[before]}"'
print(f"\nThe script will add {'this chapter' if len(to_add) == 1 else f'these {len(to_add)} chapters'} {where}:")
for c in to_add:
print(f" - {c['title']}")
if not _confirm("Add it?" if len(to_add) == 1 else "Add them?"):
return
new_chapters = [
{
"url": chapter_url(story["url"], c),
"title": fff.escape_title(c["title"]),
"html": c["html"],
"text_hash": text_hash(c["html"]),
}
for c in to_add
]
for img in {img["url"]: img for c in to_add for img in c["images"]}.values():
db_store.put_image(story["id"], img["url"], img["ext"], img["mime"], img["data"])
order = splice_order([c["url"] for c in existing], before, [c["url"] for c in new_chapters])
for chapter_id in db_store.add_chapters(story["id"], new_chapters, SOURCE, order):
# The site does not list these chapters. The next download would set this too.
db_store.update_chapter(chapter_id, on_site=0)
print(f"\nAdded {_count(len(new_chapters), 'chapter')} to {story['title']}. Rebuild the book in Bindery.")
if __name__ == "__main__":
try:
main()
except ScriptError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
print("\nStopped. Nothing changed.")
sys.exit(1)
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Clean up smart punctuation in chapter revisions: curly quotes, curly apostrophes and the ellipsis.
Usage:
python3 scripts/remove_smart_quote_revisions.py /path/to/config/bindery.db
python3 scripts/remove_smart_quote_revisions.py /path/to/config/bindery.db --apply
The first command only lists what it would do. With --apply, the script copies
the database to a backup file next to it, and then changes the revisions. Stop
Bindery before you use --apply.
The script compares the text of the revisions of each chapter, as Bindery does,
so differences in the markup do not count. Smart punctuation is ‘ ’ “ ” and …,
and its straight form is ' " and ...
- If two or more revisions of a chapter differ only by smart punctuation, the
revision with the least smart punctuation stays, and the others go. If a
deleted revision was in the book, the revision that stays goes in the book.
- If a revision has text that no other revision of its chapter has, the script
keeps it, and changes its smart punctuation to straight. Only the text
changes, not the tags or comments. The script stores the new text hash, so
Bindery compares the next site text with the new text.
The script does not delete or change the newest revision of a chapter if it came
from the site. Each recheck compares the site text with the newest revision, so
the next recheck would add the site text again.
Rebuild the stories that the script names, because the text in their books
changed. The script uses only the Python standard library.
"""
import argparse
import hashlib
import re
import sqlite3
import sys
from collections import defaultdict
from datetime import datetime
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
SMART = {"‘": "'", "’": "'", "“": '"', "”": '"', "…": "..."}
STRAIGHT = str.maketrans(SMART)
ENTITY = re.compile(r"&(?:#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);")
MARKUP = re.compile(r"(<!--.*?-->|<[^>]*>)", re.S)
IMPORT_SOURCES = {"import", "epub-import"}
# The same tags as BLOCK_TAGS in bindery/diffing.py.
BLOCK_TAGS = {
"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre",
"tr", "table", "hr", "section", "article", "ul", "ol", "center",
} # fmt: skip
class _TextParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.hidden = 0
def handle_starttag(self, tag: str, attrs: list) -> None:
if tag in ("script", "style"):
self.hidden += 1
elif tag == "br" or tag in BLOCK_TAGS:
self.parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag in ("script", "style"):
self.hidden = max(0, self.hidden - 1)
elif tag in BLOCK_TAGS:
self.parts.append("\n")
def handle_data(self, data: str) -> None:
if not self.hidden:
self.parts.append(data)
def text_blocks(markup: str) -> list[str]:
"""The text of a chapter, one string per paragraph, as html_to_blocks in bindery/diffing.py makes it."""
parser = _TextParser()
parser.feed(markup or "")
parser.close()
lines = (re.sub(r"\s+", " ", line).strip() for line in "".join(parser.parts).split("\n"))
return [line for line in lines if line]
def text_hash(markup: str) -> str:
"""The hash of the text of a chapter, as text_hash in bindery/diffing.py makes it."""
return hashlib.sha256("\n".join(text_blocks(markup)).encode("utf-8")).hexdigest()
def smart_count(text: str) -> int:
return sum(text.count(c) for c in SMART)
def straighten_html(markup: str) -> str:
"""Change the smart punctuation in the text of a chapter to straight, also where the HTML has it as an entity.
Tags and comments do not change, so a straight quote cannot end an attribute value.
"""
def entity(match: re.Match) -> str:
return SMART.get(unescape(match.group(0)), match.group(0))
# With a group in the pattern, split() puts the tags and comments at the odd places.
parts = MARKUP.split(markup)
for i in range(0, len(parts), 2):
parts[i] = ENTITY.sub(entity, parts[i]).translate(STRAIGHT)
return "".join(parts)
def plan(conn: sqlite3.Connection) -> list[dict]:
"""For each chapter with revisions to change: what goes, what stays, what gets straight, and what is in the book."""
chapters = conn.execute(
"""
SELECT c.id, c.title, c.current_rev, s.id AS story_id, s.title AS story_title
FROM chapters c JOIN stories s ON s.id = c.story_id
WHERE (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1
ORDER BY s.title COLLATE NOCASE, c.position"""
).fetchall()
actions = []
for chapter in chapters:
revisions = conn.execute(
"SELECT rev, source, html FROM revisions WHERE chapter_id = ? ORDER BY rev", (chapter["id"],)
).fetchall()
newest = revisions[-1]["rev"]
sources = {r["rev"]: r["source"] for r in revisions}
markup = {r["rev"]: r["html"] for r in revisions}
def from_site(rev: int, newest: int = newest, sources: dict = sources) -> bool:
return rev == newest and sources[rev] not in IMPORT_SOURCES
groups = defaultdict(list)
smart = {}
for r in revisions:
text = "\n".join(text_blocks(r["html"]))
smart[r["rev"]] = smart_count(text)
groups[text.translate(STRAIGHT)].append(r["rev"])
delete, keep, skipped = [], [], []
for group in groups.values():
least = min(smart[rev] for rev in group)
if all(smart[rev] == least for rev in group):
continue
keep += [rev for rev in group if smart[rev] == least]
for rev in group:
if smart[rev] > least:
(skipped if from_site(rev) else delete).append(rev)
straighten = []
if len(groups) > 1:
for group in groups.values():
left = [rev for rev in group if rev not in delete]
if len(left) == 1 and smart[left[0]] and left[0] not in skipped:
(skipped if from_site(left[0]) else straighten).append(left[0])
if not delete and not straighten and not skipped:
continue
current = chapter["current_rev"]
if current in delete:
# The revision that stays in the group of the deleted one.
same = next(g for g in groups.values() if current in g)
current = max(rev for rev in same if smart[rev] == min(smart[x] for x in same))
straight = {rev: straighten_html(markup[rev]) for rev in straighten}
actions.append(
{
"chapter_id": chapter["id"],
"chapter": unescape(chapter["title"]),
"story": chapter["story_title"],
"delete": sorted(delete),
"keep": sorted(keep),
"straighten": sorted(straighten),
"straight": {rev: (html, text_hash(html)) for rev, html in straight.items()},
"skipped": sorted(skipped),
"sources": sources,
"old_current": chapter["current_rev"],
"new_current": current,
}
)
return actions
def book_changed(action: dict) -> bool:
return action["new_current"] != action["old_current"] or action["new_current"] in action["straighten"]
def apply(conn: sqlite3.Connection, actions: list[dict]) -> None:
now = datetime.now().isoformat(timespec="seconds")
with conn:
for a in actions:
if a["new_current"] != a["old_current"]:
conn.execute(
"UPDATE chapters SET current_rev = ?, changed_at = ? WHERE id = ?",
(a["new_current"], now, a["chapter_id"]),
)
conn.executemany(
"UPDATE revisions SET html = ?, text_hash = ? WHERE chapter_id = ? AND rev = ?",
[(html, digest, a["chapter_id"], rev) for rev, (html, digest) in a["straight"].items()],
)
conn.executemany(
"DELETE FROM revisions WHERE chapter_id = ? AND rev = ?",
[(a["chapter_id"], rev) for rev in a["delete"]],
)
def backup(conn: sqlite3.Connection, db: Path) -> Path:
path = db.with_name(f"{db.stem}.before-quote-cleanup-{datetime.now():%Y%m%d-%H%M%S}{db.suffix}")
target = sqlite3.connect(path)
try:
conn.backup(target)
finally:
target.close()
return path
def _revs(revs: list[int], sources: dict[int, str]) -> str:
return ", ".join(f"{rev} ({sources[rev]})" for rev in revs)
def report(actions: list[dict]) -> None:
for a in actions:
print(f"{a['story']} / {a['chapter']}")
if a["delete"]:
print(f" delete revision {_revs(a['delete'], a['sources'])}, keep {_revs(a['keep'], a['sources'])}")
if a["new_current"] != a["old_current"]:
print(f" revision {a['new_current']} goes in the book instead of revision {a['old_current']}")
if a["straighten"]:
print(f" make the punctuation straight in revision {_revs(a['straighten'], a['sources'])}")
if a["skipped"]:
print(
f" not changed: revision {_revs(a['skipped'], a['sources'])} is the newest text from the site, "
"so the next recheck would add it again"
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("db", type=Path, help="Path to bindery.db")
parser.add_argument("--apply", action="store_true", help="Back up the database, then change the revisions")
args = parser.parse_args(argv)
if not args.db.is_file():
print(f"Error: no such file: {args.db}", file=sys.stderr)
return 1
conn = sqlite3.connect(args.db, timeout=30)
conn.row_factory = sqlite3.Row
try:
actions = plan(conn)
report(actions)
deletions = sum(len(a["delete"]) for a in actions)
straightened = sum(len(a["straighten"]) for a in actions)
if not deletions and not straightened:
print("No revisions to change.")
return 0
print(f"\n{deletions} revisions to delete, and {straightened} revisions to make straight.")
if not args.apply:
print("Nothing changed. Run again with --apply to change them.")
return 0
print(f"Backup: {backup(conn, args.db)}")
apply(conn, actions)
print(f"Deleted {deletions} revisions, and made {straightened} revisions straight.")
rebuild = sorted({a["story"] for a in actions if book_changed(a)})
if rebuild:
print("The text in the book changed for these stories. Rebuild them:")
for title in rebuild:
print(f" - {title}")
finally:
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+2
View File
@@ -30,6 +30,8 @@ export class Element {
removeAttribute(name) { delete this.attributes[name]; } removeAttribute(name) { delete this.attributes[name]; }
addEventListener(name, fn) { this.listeners[name] = fn; } addEventListener(name, fn) { this.listeners[name] = fn; }
focus() { document.activeElement = this; } focus() { document.activeElement = this; }
showModal() { this.open = true; }
close() { this.open = false; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); } remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); }
get firstElementChild() { return this.children.find((child) => child instanceof Element); } get firstElementChild() { return this.children.find((child) => child instanceof Element); }
get textContent() { return this.children.map((child) => child instanceof Element ? child.textContent : String(child)).join(''); } get textContent() { return this.children.map((child) => child instanceof Element ? child.textContent : String(child)).join(''); }
+69 -4
View File
@@ -275,13 +275,13 @@ test('the chapter table stacks on a narrow screen instead of scrolling sideways'
await mountStory(); await mountStory();
const wrap = nodes(main).find((el) => el.className === 'table-wrap chapters-table'); const wrap = nodes(main).find((el) => el.className === 'table-wrap chapters-table');
assert.ok(wrap); assert.ok(wrap);
const headings = nodes(wrap).filter((el) => el.tagName === 'th').map((el) => el.textContent); const headings = nodes(wrap).filter((el) => el.tagName === 'th');
assert.deepEqual(headings, ['', '#', 'Title', 'State', 'Revisions', 'Added']); assert.deepEqual(headings.map((el) => el.textContent.trim()), ['Select all', '#', 'Title', 'State', 'Revisions', 'Added']);
const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']); const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
const perRow = [undefined, '#', undefined, 'State', 'Revisions', 'Added']; const perRow = [undefined, '#', undefined, 'State', 'Revisions', 'Added'];
assert.deepEqual(dataLabels, [...perRow, ...perRow]); assert.deepEqual(dataLabels, [...perRow, ...perRow]);
// "Select all" moved above the table, so a hidden table header on mobile does not hide it. // The Select all checkbox is in the first heading. On a phone, it is the only heading that shows.
assert.ok(nodes(main).find((el) => el.tagName === 'label' && el.textContent.trim() === 'Select all')); assert.ok(nodes(headings[0]).includes(field(main, 'Select all chapters')));
}); });
test('story list stacks on a narrow screen instead of scrolling sideways', async () => { test('story list stacks on a narrow screen instead of scrolling sideways', async () => {
@@ -348,6 +348,47 @@ test('chapter inclusion changes reload the story after queuing the rebuild', asy
assert.equal(api.sent('GET', 'stories/1').length, 3); assert.equal(api.sent('GET', 'stories/1').length, 3);
}); });
test('shift-click selects a range of chapters, and Move sends them with the chapter to go after', async () => {
details.chapters.push({ id: 4, title: 'Three', url: 'chapter-3', included: true, on_site: true, current_rev: 1, revisions: 1 });
await mountStory();
api.on('POST', 'stories/1/chapters/move', {});
const dialog = nodes(main).find((el) => el.tagName === 'dialog');
const texts = (tag) => nodes(dialog).filter((el) => el.tagName === tag).map((el) => el.textContent);
await click(main, 'Move…');
assert.ok(!dialog.open);
const box = (title) => field(main, `Select ${title}`);
const shiftClick = (el, on) => {
el.listeners.click({ shiftKey: true, target: el });
return change(el, on);
};
change(box('One'), true);
shiftClick(box('Three'), true);
assert.equal(box('Two').checked, true);
// The dialog names the chapters, and they are not in the list of chapters to go after.
await click(main, 'Move…');
assert.ok(dialog.open);
assert.deepEqual(texts('h3'), ['Move 3 chapters']);
assert.deepEqual(texts('li'), ['One', 'Two', 'Three']);
assert.deepEqual(texts('option'), ['The start']);
await click(dialog, 'Cancel');
assert.ok(!dialog.open);
assert.equal(writes().length, 0);
await click(main, 'Move…');
await click(dialog, 'Move');
assert.deepEqual(api.sent('POST', 'stories/1/chapters/move').at(-1).body, { chapter_ids: [2, 3, 4], after: null });
assert.ok(!dialog.open);
shiftClick(box('One'), false);
change(box('Two'), true);
await click(main, 'Move…');
assert.deepEqual(texts('option'), ['The start', 'One', 'Three']);
field(dialog, 'move-after').value = '4';
await click(dialog, 'Move');
assert.deepEqual(api.sent('POST', 'stories/1/chapters/move').at(-1).body, { chapter_ids: [3], after: 4 });
// The next dialog starts with the last choice.
await click(main, 'Move…');
assert.equal(field(dialog, 'move-after').value, '4');
});
test('unsaved metadata and update settings survive job completion, then save typed values', async () => { test('unsaved metadata and update settings survive job completion, then save typed values', async () => {
await mountStory(); await mountStory();
startPolling(); startPolling();
@@ -449,6 +490,30 @@ test('library search trims the query and links by UUID', async () => {
assert.equal(api.sent('GET', 'stories/1').length, 2); assert.equal(api.sent('GET', 'stories/1').length, 2);
}); });
test('deleting chapters asks in a dialog and warns about chapters that are on the site', async () => {
await mountStory();
api.on('POST', 'stories/1/chapters/delete', {});
const dialog = nodes(main).find((el) => el.tagName === 'dialog');
const text = (tag) => nodes(dialog).find((el) => el.tagName === tag).textContent;
await click(main, 'Delete…');
assert.ok(!dialog.open);
change(field(main, 'Select Two'), true);
await click(main, 'Delete…');
assert.ok(dialog.open);
assert.equal(text('h3'), 'Delete 1 chapter');
assert.equal(text('p'), 'Bindery deletes this chapter and its revisions. You cannot undo this.');
await click(dialog, 'Cancel');
change(field(main, 'Select One'), true);
await click(main, 'Delete…');
assert.equal(text('h3'), 'Delete 2 chapters');
assert.match(text('p'), /^Bindery deletes these chapters and their revisions\..*comes back with the next download/);
assert.equal(writes().length, 0);
await click(dialog, 'Delete');
assert.deepEqual(api.sent('POST', 'stories/1/chapters/delete').at(-1).body, { chapter_ids: [2, 3] });
assert.ok(!dialog.open);
assert.equal(api.sent('GET', 'stories/1').length, 2);
});
test('sending as a new book and stopping tracking require confirmation', async () => { test('sending as a new book and stopping tracking require confirmation', async () => {
details.story.ingested_at = '2026-01-01'; details.story.ingested_at = '2026-01-01';
await mountStory(); await mountStory();
+8
View File
@@ -64,6 +64,14 @@ def test_settings_and_inclusion_persist_and_delete_cascades(client, story):
) )
assert response.json["mode"] == "rebuild" assert response.json["mode"] == "rebuild"
assert not o.store.get_chapter(cid)["included"] assert not o.store.get_chapter(cid)["included"]
response = client.post(f"/api/stories/{sid}/chapters/move", json={"chapter_ids": [cid], "after": None}, headers=H)
assert response.json["mode"] == "rebuild"
move = client.post(f"/api/stories/{sid}/chapters/move", json={"chapter_ids": [cid], "after": 999}, headers=H)
assert move.status_code == 404
# The story has only this chapter, so it cannot go.
response = client.post(f"/api/stories/{sid}/chapters/delete", json={"chapter_ids": [cid]}, headers=H)
assert response.status_code == 400
assert "at least one chapter" in response.json["error"]
o.store.set_rules(sid, [{"pattern": "a", "replacement": "b", "enabled": True}]) o.store.set_rules(sid, [{"pattern": "a", "replacement": "b", "enabled": True}])
response = client.delete(f"/api/stories/{sid}", headers=H) response = client.delete(f"/api/stories/{sid}", headers=H)
assert response.json == {"deleted": sid} assert response.json == {"deleted": sid}
+274
View File
@@ -0,0 +1,274 @@
"""Tests for scripts/import_unlinked_epub_chapters.py, a manual tool."""
import zipfile
import pytest
from bindery import config
from bindery.store import Store
from scripts import import_unlinked_epub_chapters as script
from scripts.import_unlinked_epub_chapters import (
ScriptError,
chapter_url,
main,
read_epub,
splice_order,
suggest_matches,
)
STORY_URL = "https://www.royalroad.com/fiction/1234"
CONTAINER_XML = """<?xml version="1.0"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>"""
def chapter_html(title: str, text: str) -> str:
return f"<html><head><title>{title}</title></head><body><h1>{title}</h1><p>{text}</p></body></html>"
def make_opf(count: int, identifier: str) -> str:
items = "\n".join(
f' <item id="c{i}" href="chap{i}.xhtml" media-type="application/xhtml+xml"/>' for i in range(1, count + 1)
)
refs = "\n".join(f' <itemref idref="c{i}"/>' for i in range(1, count + 1))
return f"""<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier>{identifier}</dc:identifier></metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
{items}
</manifest>
<spine>
<itemref idref="nav" linear="no"/>
{refs}
</spine>
</package>"""
def make_epub(path, chapters, identifier="book-1", files=None):
"""An ePub with a chapter file for each (title, text). `files` adds more files, such as images."""
with zipfile.ZipFile(path, "w") as z:
z.writestr("META-INF/container.xml", CONTAINER_XML)
z.writestr("OEBPS/content.opf", make_opf(len(chapters), identifier))
z.writestr("OEBPS/nav.xhtml", "<html><body><nav></nav></body></html>")
for i, (title, text) in enumerate(chapters, start=1):
z.writestr(f"OEBPS/chap{i}.xhtml", chapter_html(title, text))
for name, data in (files or {}).items():
z.writestr(name, data)
return path
def test_read_epub_reads_spine_order_and_skips_nonlinear_items(tmp_path):
path = make_epub(tmp_path / "book.epub", [("Chapter One", "First text"), ("Chapter Two", "Second text")])
chapters = read_epub(path)
assert [c["title"] for c in chapters] == ["Chapter One", "Chapter Two"]
assert "First text" in chapters[0]["html"]
assert "<body" not in chapters[0]["html"]
assert chapters[0]["key"] != chapters[1]["key"]
NCX_OPF = """<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata/>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="contents" href="text/contents.xhtml" media-type="application/xhtml+xml"/>
<item id="c1" href="text/one.xhtml" media-type="application/xhtml+xml"/>
<item id="c2" href="text/two.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine toc="ncx"><itemref idref="contents"/><itemref idref="c1"/><itemref idref="c2"/></spine>
</package>"""
NCX_TOC = """<?xml version="1.0"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><navMap>
<navPoint id="p1"><navLabel><text>CHAPTER ONE: Arrival</text></navLabel><content src="text/one.xhtml"/></navPoint>
<navPoint id="p2"><navLabel><text>12</text></navLabel><content src="text/two.xhtml#page_12"/></navPoint>
</navMap></ncx>"""
def test_read_epub_skips_a_contents_page_and_takes_titles_from_the_ncx(tmp_path):
# As in a calibre conversion: a contents page in the reading order, a title in two headings,
# and a page number in the NCX. Curly quotes stay.
path = tmp_path / "book.epub"
with zipfile.ZipFile(path, "w") as z:
z.writestr("META-INF/container.xml", CONTAINER_XML)
z.writestr("OEBPS/content.opf", NCX_OPF)
z.writestr("OEBPS/toc.ncx", NCX_TOC)
z.writestr(
"OEBPS/text/contents.xhtml",
'<html><body><h1><a href="one.xhtml">CHAPTER ONE: Arrival</a></h1>'
'<p><a href="two.xhtml">CHAPTER TWO: Decision</a></p></body></html>',
)
z.writestr(
"OEBPS/text/one.xhtml", "<html><body><h1>CHAPTER ONE</h1><h1>Arrival</h1><p>“I’m here.”</p></body></html>"
)
z.writestr("OEBPS/text/two.xhtml", "<html><body><h1>CHAPTER TWO</h1><h1>Decision</h1><p>Text</p></body></html>")
chapters = read_epub(path)
assert [c["title"] for c in chapters] == ["CHAPTER ONE: Arrival", "CHAPTER TWO"]
assert "<p>“I’m here.”</p>" in chapters[0]["html"]
def test_read_epub_points_images_at_their_own_urls_and_drops_missing_ones(tmp_path):
path = make_epub(
tmp_path / "book.epub",
[("Map", '<img src="images/map.png"/><img src="images/gone.png"/><img src="images/notes.txt"/>')],
files={"OEBPS/images/map.png": b"png data", "OEBPS/images/notes.txt": b"text"},
)
(chapter,) = read_epub(path)
(image,) = chapter["images"]
assert image["url"].startswith("file:///bindery-epub-import/")
assert image["url"].endswith("/OEBPS/images/map.png")
assert (image["ext"], image["mime"], image["data"]) == ("png", "image/png", b"png data")
assert f'src="{image["url"]}"' in chapter["html"]
assert "gone.png" not in chapter["html"]
assert "notes.txt" not in chapter["html"]
def test_read_epub_rejects_a_bad_file(tmp_path):
path = tmp_path / "not-an-epub.epub"
path.write_text("nope")
with pytest.raises(ScriptError):
read_epub(path)
def test_suggest_matches_ranks_the_closest_title_first_and_drops_weak_ones():
candidates = ["Chapter One: A New Start", "Interlude", "Chapter Two: Trouble Brews"]
assert suggest_matches("Chapter Two", candidates)[0] == 2
assert suggest_matches("Chapter One", ["Completely unrelated title"]) == []
def test_chapter_url_is_stable_and_differs_between_epubs(tmp_path):
one = read_epub(make_epub(tmp_path / "one.epub", [("Prologue", "a")], identifier="book-1"))[0]
again = read_epub(make_epub(tmp_path / "again.epub", [("Prologue", "a")], identifier="book-1"))[0]
other = read_epub(make_epub(tmp_path / "other.epub", [("Prologue", "a")], identifier="book-3"))[0]
url = chapter_url(STORY_URL, one)
assert url.startswith(f"{STORY_URL}#bindery-import-")
assert chapter_url(STORY_URL, again) == url
assert chapter_url(STORY_URL, other) != url
def test_splice_order_puts_the_new_urls_after_the_given_index():
assert splice_order(["a", "b", "c"], 0, ["x", "y"]) == ["a", "x", "y", "b", "c"]
assert splice_order(["a", "b"], -1, ["x"]) == ["x", "a", "b"]
assert splice_order(["a", "b"], 1, ["x"]) == ["a", "b", "x"]
@pytest.fixture
def story(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", tmp_path / "bindery.db")
db_store = Store(config.DB_PATH)
story_id = db_store.create_story(STORY_URL, "www.royalroad.com", "Story", "Writer", [])
db_store.add_chapter(story_id, f"{STORY_URL}/1", "Chapter 1", "<p>one</p>", "h1", "new", 0)
db_store.add_chapter(story_id, f"{STORY_URL}/4", "Chapter 4", "<p>four</p>", "h4", "new", 1)
epub = make_epub(
tmp_path / "book.epub",
[
("Cover", "cover"),
("Chapter 1", "one text"),
("Chapter 2", '<img src="map.png"/>two text'),
("Chapter 3", "three text"),
("Chapter 4", "four text"),
("About the Author", "author"),
],
files={"OEBPS/map.png": b"png"},
)
return db_store, story_id, epub
def answer(monkeypatch, *answers):
"""Answer the prompts in order. A prompt that shows a default in [brackets] gets it for an empty answer."""
replies = iter(answers)
prompts = []
def fake_input(prompt=""):
prompts.append(prompt)
return next(replies)
monkeypatch.setattr("builtins.input", fake_input)
return prompts
def titles(db_store, story_id):
return [c["title"] for c in db_store.list_chapters(story_id)]
def test_main_fills_a_gap_with_the_suggested_matches(story, monkeypatch, capsys):
db_store, story_id, epub = story
# Before the gap: stored chapter 1. Then Enter takes each suggestion.
prompts = answer(monkeypatch, "1", "", "", "", "y")
main([STORY_URL, str(epub)])
# Each prompt says what it asks for, and shows the suggestion. Numbers start at 1.
assert prompts[:4] == [
"Stored chapter just BEFORE the gap (or 'start'): ",
'ePub chapter that matches "Chapter 1" [2]: ',
"Stored chapter just AFTER the gap (or 'end') [2]: ",
'ePub chapter that matches "Chapter 4" [5]: ',
]
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 2", "Chapter 3", "Chapter 4"]
added = db_store.list_chapters(story_id)[1]
assert added["url"].startswith(f"{STORY_URL}#bindery-import-")
assert added["on_site"] == 0
assert db_store.list_revisions(added["id"])[0]["source"] == "epub-import"
html = db_store.revision_html(added["id"], 1)
(image,) = db_store.images(story_id)
assert f'src="{image["url"]}"' in html
assert 'The script will add these 2 chapters after "Chapter 1"' in capsys.readouterr().out
# A second run over the same gap finds the same chapters in the story, and adds nothing.
answer(monkeypatch, "1", "", "4", "")
main([STORY_URL, str(epub)])
assert len(db_store.list_chapters(story_id)) == 4
assert "2 ePub chapters of the gap are in the story already" in capsys.readouterr().out
def test_main_asks_again_for_a_bad_answer_and_can_show_the_whole_list(story, monkeypatch, capsys):
db_store, story_id, epub = story
monkeypatch.setattr(script, "WINDOW", 2)
answer(monkeypatch, "9", "list", "1", "", "1", "", "", "n")
main([STORY_URL, str(epub)])
out = capsys.readouterr().out
assert "Type a number from 1 to 2, or 'list' or 'start'." in out
# The AFTER question cannot go before the BEFORE chapter.
assert "Type a number from 2 to 2, or 'list' or 'end'." in out
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 4"]
def test_main_asks_for_the_first_and_last_epub_chapters_at_the_start_and_end(story, monkeypatch):
db_store, story_id, epub = story
# The gap is at the start: add from ePub chapter 1 up to the match of stored Chapter 1.
prompts = answer(monkeypatch, "start", "1", "", "", "y")
main([STORY_URL, str(epub)])
assert prompts[1] == "FIRST ePub chapter to add: "
assert titles(db_store, story_id) == ["Cover", "Chapter 1", "Chapter 4"]
# The gap is after the last stored chapter, so the script does not ask for the chapter after it.
prompts = answer(monkeypatch, "3", "", "", "y")
main([STORY_URL, str(epub)])
assert prompts[2] == "LAST ePub chapter to add [6]: "
assert titles(db_store, story_id) == ["Cover", "Chapter 1", "Chapter 4", "About the Author"]
def test_main_changes_nothing_when_the_user_says_no(story, monkeypatch):
db_store, story_id, epub = story
answer(monkeypatch, "1", "", "", "", "n")
main([STORY_URL, str(epub)])
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 4"]
assert db_store.images(story_id) == []
def test_remove_deletes_only_the_added_chapters_and_their_revisions(story, monkeypatch):
db_store, story_id, _ = story
added = db_store.add_chapter(story_id, f"{STORY_URL}#bindery-import-x", "Two", "<p>2</p>", "h2", "epub-import", 1)
db_store.add_revision(added, "<p>2b</p>", "h2b", "recheck")
answer(monkeypatch, "n")
main([STORY_URL, "--remove"])
assert len(db_store.list_chapters(story_id)) == 3
answer(monkeypatch, "y")
main([STORY_URL, "--remove"])
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 4"]
assert db_store.list_revisions(added) == []
+137
View File
@@ -0,0 +1,137 @@
"""Tests for scripts/remove_smart_quote_revisions.py, a manual cleanup tool."""
import pytest
from bindery import diffing
from bindery.store import Store
from scripts.remove_smart_quote_revisions import main, straighten_html, text_blocks, text_hash
CURLY = "<div><p class='a1'>“Hi,” he said.</p><p>It’s ‘fine’…</p></div>"
STRAIGHT = "<div><p class='b2'>\"Hi,\" he said.</p><p>It's 'fine'...</p></div>"
# The same text as CURLY, with the smart punctuation as entities.
ENTITIES = "<div><p>&ldquo;Hi,&#8221; he said.</p><p>It&rsquo;s &#x2018;fine&#x2019;&hellip;</p></div>"
@pytest.mark.parametrize(
"html",
[
CURLY,
ENTITIES,
"<p>One<br/>Two</p><script>x()</script><style>p{}</style>",
"<div>Loose <em>text</em><ul><li>a</li><li>b &amp; c</li></ul> tail </div><hr/>",
"",
],
)
def test_text_and_hash_match_bindery(html):
assert text_blocks(html) == diffing.html_to_blocks(html)
assert text_hash(html) == diffing.text_hash(html)
def test_straighten_html_changes_only_the_text():
html = '<p title="“x”">“Hi,” it’s ‘fine’… &amp; &rsquo;<!-- “note” --></p>'
assert straighten_html(html) == "<p title=\"“x”\">\"Hi,\" it's 'fine'... &amp; '<!-- “note” --></p>"
assert diffing.html_to_blocks(straighten_html(ENTITIES)) == diffing.html_to_blocks(STRAIGHT)
@pytest.fixture
def db(tmp_path):
path = tmp_path / "bindery.db"
store = Store(path)
story_id = store.create_story("https://example.com/s/1", "example.com", "Story", "Writer", [])
return path, store, story_id
def chapter(store, story_id, n, revisions, current=None):
"""A chapter with revisions given as (html, source). The last one is in the book, unless `current` says."""
html, source = revisions[0]
cid = store.add_chapter(story_id, f"u{n}", f"Chapter {n}", html, diffing.text_hash(html), source, n)
for html, source in revisions[1:]:
store.add_revision(cid, html, diffing.text_hash(html), source)
if current:
store.update_chapter(cid, current_rev=current)
return cid
def revs(store, cid):
return [r["rev"] for r in store.list_revisions(cid)]
def test_dry_run_changes_nothing(db, capsys):
path, store, story_id = db
cid = chapter(store, story_id, 1, [(CURLY, "import"), (STRAIGHT, "recheck")])
assert main([str(path)]) == 0
assert revs(store, cid) == [1, 2]
out = capsys.readouterr().out
assert "delete revision 1 (import), keep 2 (recheck)" in out
assert "Run again with --apply" in out
assert not list(path.parent.glob("*before-quote-cleanup*"))
def test_apply_deletes_revisions_that_differ_only_by_smart_punctuation(db, capsys):
path, store, story_id = db
# The smart import, then the straight site text.
older = chapter(store, story_id, 1, [(CURLY, "import"), (STRAIGHT, "recheck")])
# The same, but the user put the smart revision in the book.
chosen = chapter(store, story_id, 2, [(CURLY, "import"), (STRAIGHT, "recheck")], current=1)
# A smart import after the site text. It is the newest, but not from the site.
imported = chapter(store, story_id, 3, [(STRAIGHT, "new"), (ENTITIES, "import")])
# The site sends smart punctuation now. The next recheck would add it again.
site = chapter(store, story_id, 4, [(STRAIGHT, "new"), (CURLY, "recheck")])
assert main([str(path), "--apply"]) == 0
assert revs(store, older) == [2]
assert revs(store, chosen) == [2]
assert store.get_chapter(chosen)["current_rev"] == 2
assert revs(store, imported) == [1]
assert store.get_chapter(imported)["current_rev"] == 1
assert revs(store, site) == [1, 2]
out = capsys.readouterr().out
assert "3 revisions to delete, and 0 revisions to make straight" in out
assert "not changed: revision 2 (recheck) is the newest text from the site" in out
assert "Rebuild them:\n - Story" in out
(backup,) = path.parent.glob("bindery.before-quote-cleanup-*.db")
assert revs(Store(backup), older) == [1, 2]
def test_apply_makes_revisions_with_other_changes_straight(db, capsys):
path, store, story_id = db
changed = CURLY.replace("fine", "good")
# Three different texts. The newest is not from the site, so all of them get straight.
both = chapter(
store, story_id, 1, [(CURLY, "import"), (changed, "recheck"), (ENTITIES.replace("Hi", "Yo"), "import")]
)
# The newest is from the site, so only the older one gets straight.
older = chapter(store, story_id, 2, [(CURLY, "import"), (changed, "recheck")])
# A smart revision that also matches a straight one goes, and the other text gets straight.
mixed = chapter(store, story_id, 3, [(CURLY, "import"), (STRAIGHT, "new"), (changed, "import")], current=1)
assert main([str(path), "--apply"]) == 0
def smart(cid, rev):
text = "\n".join(diffing.html_to_blocks(store.revision_html(cid, rev)))
return any(c in text for c in "‘’“”…")
assert [smart(both, rev) for rev in (1, 2, 3)] == [False, False, False]
assert [smart(older, rev) for rev in (1, 2)] == [False, True]
assert revs(store, mixed) == [2, 3]
assert [smart(mixed, rev) for rev in (2, 3)] == [False, False]
assert store.get_chapter(mixed)["current_rev"] == 2
# Bindery compares the next site text with the stored hash, so it must be the hash of the new text.
for cid in (both, older, mixed):
for r in store.list_revisions(cid):
assert r["text_hash"] == diffing.text_hash(store.revision_html(cid, r["rev"]))
assert "class='a1'" in store.revision_html(both, 1)
out = capsys.readouterr().out
assert "make the punctuation straight in revision 1 (import), 2 (recheck), 3 (import)" in out
assert "1 revisions to delete, and 5 revisions to make straight" in out
# A second run finds nothing to do.
assert main([str(path)]) == 0
assert "not changed: revision 2 (recheck)" in capsys.readouterr().out
def test_no_work_and_a_missing_file(db, tmp_path, capsys):
path, store, story_id = db
chapter(store, story_id, 1, [(STRAIGHT, "new")])
assert main([str(path), "--apply"]) == 0
assert "No revisions to change." in capsys.readouterr().out
assert main([str(tmp_path / "missing.db")]) == 1
+8
View File
@@ -47,6 +47,14 @@ def test_list_stories_counts_offsite_and_revised_chapters(tmp_path):
assert story["revised_count"] == 1 assert story["revised_count"] == 1
def test_list_stories_sorts_titles_without_leading_articles(tmp_path):
store = Store(tmp_path / "db.sqlite")
for n, title in enumerate(["The Zebra", "an Owl", "Anthem", "A Bee", "Theory", "Cat"]):
store.create_story(f"https://example/s/{n}", "example", title, "A", [])
titles = [s["title"] for s in store.list_stories()]
assert titles == ["Anthem", "A Bee", "Cat", "an Owl", "Theory", "The Zebra"]
def test_cover_replaces_previous_cover(tmp_path): def test_cover_replaces_previous_cover(tmp_path):
store = Store(tmp_path / "db.sqlite") store = Store(tmp_path / "db.sqlite")
sid = store.create_story("u", "s", "T", "A", []) sid = store.create_story("u", "s", "T", "A", [])
+41
View File
@@ -250,6 +250,47 @@ def test_inclusion_changes_only_the_requested_story_and_queues_a_rebuild(state):
assert len(queue.get_status()) == 1 assert len(queue.get_status()) == 1
def test_move_chapters_keeps_their_order_and_queues_a_rebuild(state):
store, queue = state
sid, other = add_story(store), add_story(store, "other")
ids = [store.add_chapter(sid, f"c{n}", str(n), "<p>x</p>", "h", "new", n) for n in range(5)]
foreign = store.add_chapter(other, "foreign", "Other", "<p>x</p>", "h", "new", 0)
def titles():
return [c["title"] for c in store.list_chapters(sid)]
# The selection order does not count, and a chapter of another story is ignored.
assert o.move_chapters(sid, [ids[3], ids[1], foreign], ids[4])["mode"] == "rebuild"
assert titles() == ["0", "2", "4", "1", "3"]
o.move_chapters(sid, [ids[4], ids[3]], None)
assert titles() == ["4", "3", "0", "2", "1"]
assert len(queue.get_status()) == 1
with pytest.raises(ValueError, match="Select"):
o.move_chapters(sid, [foreign], None)
with pytest.raises(ValueError, match="moves with them"):
o.move_chapters(sid, [ids[0], ids[1]], ids[1])
with pytest.raises(KeyError):
o.move_chapters(sid, [ids[0]], foreign)
def test_delete_chapters_removes_their_revisions_and_keeps_one_chapter(state):
store, queue = state
sid, other = add_story(store), add_story(store, "other")
ids = [store.add_chapter(sid, f"c{n}", str(n), "<p>x</p>", "h", "new", n) for n in range(3)]
store.add_revision(ids[1], "<p>y</p>", "h2", "recheck")
foreign = store.add_chapter(other, "foreign", "Other", "<p>x</p>", "h", "new", 0)
assert o.delete_chapters(sid, [ids[1], foreign])["mode"] == "rebuild"
assert [c["id"] for c in store.list_chapters(sid)] == [ids[0], ids[2]]
assert store.list_revisions(ids[1]) == []
assert store.get_chapter(foreign)
with pytest.raises(ValueError, match="Select"):
o.delete_chapters(sid, [foreign])
with pytest.raises(ValueError, match="at least one chapter"):
o.delete_chapters(sid, [ids[0], ids[2]])
assert len(store.list_chapters(sid)) == 2
@pytest.mark.parametrize( @pytest.mark.parametrize(
"mode,chapters,fields,message", "mode,chapters,fields,message",
[ [