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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user