feat: split the edition out of a book's title

"Fluent Python, 2nd Edition" is one book with a field for the edition. Left in
the title it also splits the library, since the second edition never looks like
the first. Only a numbered statement is moved, so "Catch 22" keeps its number
and "Global Edition" — which has nowhere to go in an integer column — stays put.
This commit is contained in:
2026-08-15 21:57:17 -04:00
parent f6bb06ac6e
commit e967019964
3 changed files with 180 additions and 3 deletions
@@ -101,6 +101,77 @@ def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] |
return name or ("uuid" if _UUID.match(value) else "id"), value return name or ("uuid" if _UUID.match(value) else "id"), value
# Numbered editions, in the forms covers and catalogue records actually use:
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
_ORDINAL_WORDS = {
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12,
}
# Words that sit between the number and "Edition" and belong to the same statement.
_EDITION_QUALIFIER = (
r"(?:international|global|revised|updated|expanded|anniversary|deluxe|student|"
r"instructors?|annotated|illustrated|reprint)"
)
_EDITION = re.compile(
rf"""
[\s,;:/\-–—(\[]+ # the separator the statement hangs off
(?:
(?P<num>\d{{1,2}})\s*(?:st|nd|rd|th)?[\s_]*
(?:{_EDITION_QUALIFIER}\s+)*(?:edition\b|edn\b|ed\b\.?|e\b)
| (?P<word>{"|".join(_ORDINAL_WORDS)})\s+
(?:{_EDITION_QUALIFIER}\s+)*(?:edition\b|edn\b|ed\b\.?)
)
[\s)\]]* # and its closing bracket, if it had one
""",
re.IGNORECASE | re.VERBOSE,
)
def split_edition(title: str | None) -> tuple[str | None, int | None]:
"""
Separate a numbered edition statement from the title it is written into.
"Fluent Python, 2nd Edition" is one book with a field for the edition, not a
title. Left in place it also splits the library: the second edition never looks
like the first, and neither matches the copy whose file simply did not mention it.
The number is what makes this safe. Nothing is stripped without one, so
"Catch 22" and "Blade Runner 2049" keep their numbers and "Global Edition"
which is a variant, not a numbered edition, and has nowhere to go in an
integer column — is left in the title where it can still be read.
Args:
title: The title as the file or filename gave it.
Returns:
The title without the edition statement, and the edition number. The title
unchanged and None when there is no numbered edition in it, or when removing
it would leave nothing behind.
"""
if not title:
return title, None
if (match := _EDITION.search(title)) is None:
return title, None
edition = (
int(match["num"]) if match["num"] else _ORDINAL_WORDS[match["word"].casefold()]
)
stripped = _EDITION.sub(" ", title)
stripped = re.sub(r"\s{2,}", " ", stripped)
stripped = re.sub(r"\s+([,;:.!?])", r"\1", stripped) # "Works : What" → "Works: What"
stripped = stripped.strip(" ,;:-–—/")
# A title that is only an edition statement is not improved by having none.
if not stripped:
return title, None
return stripped, edition
class FileExtractor(Protocol): class FileExtractor(Protocol):
@classmethod @classmethod
async def extract_metadata( async def extract_metadata(
@@ -166,7 +237,14 @@ class Extractor:
# format the title # format the title
if metadata.get('title', None): if metadata.get('title', None):
title, subtitle = Extractor.format_book_title(metadata["title"]) # Before the subtitle split, so the edition cannot be mistaken for one:
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
# lose the edition first for the colon count to mean anything.
title, edition = split_edition(metadata["title"])
if edition is not None:
metadata.setdefault("edition", edition)
title, subtitle = Extractor.format_book_title(title)
metadata["title"] = title metadata["title"] = title
metadata["subtitle"] = subtitle metadata["subtitle"] = subtitle
+2 -1
View File
@@ -37,7 +37,8 @@ from pathlib import Path
( (
Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"), Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
2, 2,
"The Project Gutenberg eBook #33283: Calculus Made Easy, 2nd Edition", # The ", 2nd Edition" is split off into `edition`, not kept in the title.
"The Project Gutenberg eBook #33283: Calculus Made Easy",
["Silvanus Phillips Thompson"], ["Silvanus Phillips Thompson"],
), ),
], ],
+99 -1
View File
@@ -1,7 +1,12 @@
import pytest import pytest
from pathlib import Path from pathlib import Path
from datetime import date from datetime import date
from chitai.services.metadata_extractor import EpubExtractor, Extractor, PdfExtractor from chitai.services.metadata_extractor import (
EpubExtractor,
Extractor,
PdfExtractor,
split_edition,
)
@pytest.mark.asyncio() @pytest.mark.asyncio()
@@ -74,3 +79,96 @@ class TestIdentifierMerging:
metadata = await Extractor.extract_metadata([PDF]) metadata = await Extractor.extract_metadata([PDF])
assert "identifiers" not in metadata assert "identifiers" not in metadata
class TestSplitEdition:
"""An edition is a field on the book, not part of what the book is called."""
@pytest.mark.parametrize(
("title", "stripped", "edition"),
[
# Every form below is one that turned up in a real library.
("Fluent Python, 2nd Edition", "Fluent Python", 2),
("Building Microservices, 2E", "Building Microservices", 2),
("Digital Image Processing, 4e", "Digital Image Processing", 4),
(
"Network Security Essentials: Applications and Standards/6e",
"Network Security Essentials: Applications and Standards",
6,
),
(
"Refactoring: Improving the Design of Existing Code (2nd edition)",
"Refactoring: Improving the Design of Existing Code",
2,
),
# Ordinal words, including a qualifier sitting inside the statement.
(
"The Art of Computer Programming: Volume 1 / Fundamental Algorithms, Third Edition",
"The Art of Computer Programming: Volume 1 / Fundamental Algorithms",
3,
),
(
"Introduction to the Theory of Computation, Third International Edition",
"Introduction to the Theory of Computation",
3,
),
# Mid-title, before a subtitle and before a trailing author.
(
"How Linux Works, 3rd Edition: What Every Superuser Should Know",
"How Linux Works: What Every Superuser Should Know",
3,
),
(
"Code Complete, 2nd Edition - Steve McConnell",
"Code Complete - Steve McConnell",
2,
),
# An underscore between the number and the "e", beside an unnumbered
# qualifier that has nowhere to go in an integer column and so stays put.
(
"Cryptography and Network Security, Global Edition, 8_e - Stallings",
"Cryptography and Network Security, Global Edition - Stallings",
8,
),
],
)
def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None:
assert split_edition(title) == (stripped, edition)
@pytest.mark.parametrize(
"title",
[
# A number alone is never an edition — these are titles.
"Catch 22",
"Fahrenheit 451",
"Blade Runner 2049",
"1984",
"Apollo 13",
"Slaughterhouse 5",
"The Art of Computer Programming: Volume 1",
# "Edition" with no number cannot be stored, so it stays where it can
# still be read.
"Cryptography and Network Security: Principles and Practice, Global Edition",
"Building Microservices",
],
)
def test_titles_are_left_alone(self, title: str) -> None:
assert split_edition(title) == (title, None)
def test_a_title_that_is_only_an_edition_is_kept(self) -> None:
"""Stripping must never leave a book with no title at all."""
assert split_edition("2nd Edition") == ("2nd Edition", None)
@pytest.mark.parametrize("title", ["", None])
def test_nothing_yields_nothing(self, title: str | None) -> None:
assert split_edition(title) == (title, None)
@pytest.mark.asyncio()
class TestEditionFromFiles:
async def test_extraction_moves_the_edition_off_the_title(self) -> None:
"""The PDF fixture calls itself a 2nd edition in its own metadata title."""
metadata = await Extractor.extract_metadata([PDF])
assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy"
assert metadata["edition"] == 2