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:
@@ -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
|
||||
|
||||
|
||||
# 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):
|
||||
@classmethod
|
||||
async def extract_metadata(
|
||||
@@ -166,7 +237,14 @@ class Extractor:
|
||||
|
||||
# format the title
|
||||
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["subtitle"] = subtitle
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ from pathlib import Path
|
||||
(
|
||||
Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
|
||||
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"],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
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()
|
||||
@@ -74,3 +79,96 @@ class TestIdentifierMerging:
|
||||
metadata = await Extractor.extract_metadata([PDF])
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user