diff --git a/backend/src/chitai/services/metadata_extractor.py b/backend/src/chitai/services/metadata_extractor.py index e757d91..9429331 100644 --- a/backend/src/chitai/services/metadata_extractor.py +++ b/backend/src/chitai/services/metadata_extractor.py @@ -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\d{{1,2}})\s*(?:st|nd|rd|th)?[\s_]* + (?:{_EDITION_QUALIFIER}\s+)*(?:edition\b|edn\b|ed\b\.?|e\b) + | (?P{"|".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 diff --git a/backend/tests/integration/test_book.py b/backend/tests/integration/test_book.py index d312a8e..910bcdd 100644 --- a/backend/tests/integration/test_book.py +++ b/backend/tests/integration/test_book.py @@ -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"], ), ], diff --git a/backend/tests/unit/test_metadata_extractor.py b/backend/tests/unit/test_metadata_extractor.py index 35f17d6..e6c687b 100644 --- a/backend/tests/unit/test_metadata_extractor.py +++ b/backend/tests/unit/test_metadata_extractor.py @@ -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