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.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 5df2806694
commit df9959a078
2 changed files with 408 additions and 0 deletions
+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())
+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