From 364c179bd685f8169bc3d7804a348133a77fa311 Mon Sep 17 00:00:00 2001 From: Artem Akymenko Date: Tue, 14 Jul 2026 10:26:05 +0000 Subject: [PATCH] refactor: extract metadata helpers into domain/metadata_helpers.py - Extract normalize_metadata_map, format_author_sentence, ensure_sentence - Extract normalize_series_number, extract_series_metadata, format_series_sentence - Remove _SERIES_NAME_KEYS, _SERIES_NUMBER_KEYS, _SERIES_NUMBER_RE from conversion_runner.py - Add tests/test_metadata_helpers.py with 32 tests - All tests match old behavior --- abogen/domain/metadata_helpers.py | 138 +++++++++++++++++++++++++++++ abogen/webui/conversion_runner.py | 142 ++---------------------------- tests/test_metadata_helpers.py | 133 ++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 134 deletions(-) create mode 100644 abogen/domain/metadata_helpers.py create mode 100644 tests/test_metadata_helpers.py diff --git a/abogen/domain/metadata_helpers.py b/abogen/domain/metadata_helpers.py new file mode 100644 index 0000000..ea4025b --- /dev/null +++ b/abogen/domain/metadata_helpers.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import re +from typing import Any, Dict, List, Mapping, Optional, Tuple + + +_SERIES_NAME_KEYS = ( + "series", + "series_name", + "series_title", +) +_SERIES_NUMBER_KEYS = ( + "series_index", + "series_position", + "series_sequence", + "book_number", + "series_number", +) +_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?") + + +def normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: + normalized: Dict[str, str] = {} + if not values: + return normalized + for key, value in values.items(): + if value is None: + continue + text = str(value).strip() + if not text: + continue + normalized[str(key).casefold()] = text + return normalized + + +def format_author_sentence(raw: Optional[str]) -> str: + if raw is None: + return "" + normalized = str(raw).strip() + if not normalized: + return "" + lowered = normalized.casefold() + if lowered in {"unknown", "various"}: + return "" + + working = normalized.replace("&", " and ") + segments = [segment.strip() for segment in working.split(",") if segment.strip()] + tokens: List[str] = [] + + if segments: + for segment in segments: + parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()] + if parts: + tokens.extend(parts) + else: + tokens.append(segment) + else: + parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()] + tokens.extend(parts or [normalized]) + + cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}] + if not cleaned: + return "" + if len(cleaned) == 1: + return f"By {cleaned[0]}" + if len(cleaned) == 2: + return f"By {cleaned[0]} and {cleaned[1]}" + return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}" + + +def ensure_sentence(text: str) -> str: + cleaned = text.strip() + if not cleaned: + return "" + if cleaned[-1] in ".!?": + return cleaned + return f"{cleaned}." + + +def normalize_series_number(value: Any) -> Optional[str]: + text = str(value or "").strip() + if not text: + return None + candidate = text.replace(",", ".") + if candidate.replace(".", "", 1).isdigit(): + if "." in candidate: + normalized = candidate.rstrip("0").rstrip(".") + return normalized or "0" + try: + return str(int(candidate)) + except ValueError: + pass + match = _SERIES_NUMBER_RE.search(candidate) + if not match: + return None + normalized = match.group(0) + if "." in normalized: + normalized = normalized.rstrip("0").rstrip(".") + return normalized or "0" + try: + return str(int(normalized)) + except ValueError: + return normalized + + +def extract_series_metadata(values: Mapping[str, str]) -> Tuple[Optional[str], Optional[str]]: + series_name: Optional[str] = None + for key in _SERIES_NAME_KEYS: + raw = values.get(key) + if raw: + cleaned = str(raw).strip() + if cleaned: + series_name = cleaned + break + + series_number: Optional[str] = None + for key in _SERIES_NUMBER_KEYS: + raw = values.get(key) + if raw is None: + continue + normalized = normalize_series_number(raw) + if normalized: + series_number = normalized + break + + return series_name, series_number + + +def format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str: + if not series_name or not series_number: + return "" + name = series_name.strip() + number = series_number.strip() + if not name or not number: + return "" + article = "the " if not name.lower().startswith("the ") else "" + phrase = f"Book {number} of {article}{name}" + return re.sub(r"\s+", " ", phrase).strip() diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index c211297..35e41b2 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -54,6 +54,14 @@ from abogen.domain.chapter_titles import ( normalize_chapter_opening_caps as _normalize_chapter_opening_caps, format_spoken_chapter_title as _format_spoken_chapter_title, ) +from abogen.domain.metadata_helpers import ( + normalize_metadata_map as _normalize_metadata_map, + format_author_sentence as _format_author_sentence, + ensure_sentence as _ensure_sentence, + normalize_series_number as _normalize_series_number, + extract_series_metadata as _extract_series_metadata, + format_series_sentence as _format_series_sentence, +) from .service import Job, JobStatus @@ -159,140 +167,6 @@ def _coerce_truthy(value: Any, default: bool = True) -> bool: _OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+") -def _normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: - normalized: Dict[str, str] = {} - if not values: - return normalized - for key, value in values.items(): - if value is None: - continue - text = str(value).strip() - if not text: - continue - normalized[str(key).casefold()] = text - return normalized - - -def _format_author_sentence(raw: Optional[str]) -> str: - if raw is None: - return "" - normalized = str(raw).strip() - if not normalized: - return "" - lowered = normalized.casefold() - if lowered in {"unknown", "various"}: - return "" - - working = normalized.replace("&", " and ") - segments = [segment.strip() for segment in working.split(",") if segment.strip()] - tokens: List[str] = [] - - if segments: - for segment in segments: - parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()] - if parts: - tokens.extend(parts) - else: - tokens.append(segment) - else: - parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()] - tokens.extend(parts or [normalized]) - - cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}] - if not cleaned: - return "" - if len(cleaned) == 1: - return f"By {cleaned[0]}" - if len(cleaned) == 2: - return f"By {cleaned[0]} and {cleaned[1]}" - return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}" - - -def _ensure_sentence(text: str) -> str: - cleaned = text.strip() - if not cleaned: - return "" - if cleaned[-1] in ".!?": - return cleaned - return f"{cleaned}." - - -_SERIES_NAME_KEYS = ( - "series", - "series_name", - "series_title", -) -_SERIES_NUMBER_KEYS = ( - "series_index", - "series_position", - "series_sequence", - "book_number", - "series_number", -) -_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?") - - -def _normalize_series_number(value: Any) -> Optional[str]: - text = str(value or "").strip() - if not text: - return None - candidate = text.replace(",", ".") - if candidate.replace(".", "", 1).isdigit(): - if "." in candidate: - normalized = candidate.rstrip("0").rstrip(".") - return normalized or "0" - try: - return str(int(candidate)) - except ValueError: - pass - match = _SERIES_NUMBER_RE.search(candidate) - if not match: - return None - normalized = match.group(0) - if "." in normalized: - normalized = normalized.rstrip("0").rstrip(".") - return normalized or "0" - try: - return str(int(normalized)) - except ValueError: - return normalized - - -def _extract_series_metadata(values: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]: - series_name: Optional[str] = None - for key in _SERIES_NAME_KEYS: - raw = values.get(key) - if raw: - cleaned = str(raw).strip() - if cleaned: - series_name = cleaned - break - - series_number: Optional[str] = None - for key in _SERIES_NUMBER_KEYS: - raw = values.get(key) - if raw is None: - continue - normalized = _normalize_series_number(raw) - if normalized: - series_number = normalized - break - - return series_name, series_number - - -def _format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str: - if not series_name or not series_number: - return "" - name = series_name.strip() - number = series_number.strip() - if not name or not number: - return "" - article = "the " if not name.lower().startswith("the ") else "" - phrase = f"Book {number} of {article}{name}" - return re.sub(r"\s+", " ", phrase).strip() - - def _build_title_intro_text( metadata: Optional[Mapping[str, Any]], fallback_basename: str, diff --git a/tests/test_metadata_helpers.py b/tests/test_metadata_helpers.py new file mode 100644 index 0000000..151a1e5 --- /dev/null +++ b/tests/test_metadata_helpers.py @@ -0,0 +1,133 @@ +"""Tests for domain/metadata_helpers.py.""" +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from abogen.domain.metadata_helpers import ( + normalize_metadata_map, + format_author_sentence, + ensure_sentence, + normalize_series_number, + extract_series_metadata, + format_series_sentence, +) + + +class TestNormalizeMetadataMap: + def test_empty(self): + assert normalize_metadata_map({}) == {} + + def test_none(self): + assert normalize_metadata_map(None) == {} + + def test_normalizes_keys(self): + result = normalize_metadata_map({"Title": "My Book", "artist": "John"}) + assert "title" in result + assert "artist" in result + + def test_skips_none_values(self): + result = normalize_metadata_map({"title": None, "artist": "John"}) + assert "title" not in result + + def test_skips_empty_values(self): + result = normalize_metadata_map({"title": "", "artist": "John"}) + assert "title" not in result + + +class TestFormatAuthorSentence: + def test_none(self): + assert format_author_sentence(None) == "" + + def test_empty(self): + assert format_author_sentence("") == "" + + def test_unknown(self): + assert format_author_sentence("Unknown") == "" + + def test_single(self): + assert format_author_sentence("John Doe") == "By John Doe" + + def test_two(self): + assert format_author_sentence("John, Jane") == "By John and Jane" + + def test_three(self): + assert format_author_sentence("John, Jane, Bob") == "By John, Jane, and Bob" + + def test_ampersand(self): + assert format_author_sentence("John & Jane") == "By John and Jane" + + +class TestEnsureSentence: + def test_empty(self): + assert ensure_sentence("") == "" + + def test_already_sentence(self): + assert ensure_sentence("Hello.") == "Hello." + + def test_adds_period(self): + assert ensure_sentence("Hello") == "Hello." + + def test_exclamation(self): + assert ensure_sentence("Hello!") == "Hello!" + + +class TestNormalizeSeriesNumber: + def test_empty(self): + assert normalize_series_number("") is None + + def test_integer(self): + assert normalize_series_number("3") == "3" + + def test_float(self): + assert normalize_series_number("3.5") == "3.5" + + def test_float_trailing_zero(self): + assert normalize_series_number("3.10") == "3.1" + + def test_comma_as_separator(self): + assert normalize_series_number("3,5") == "3.5" + + def test_text_with_number(self): + assert normalize_series_number("Book 3") == "3" + + def test_none(self): + assert normalize_series_number(None) is None + + +class TestExtractSeriesMetadata: + def test_empty(self): + name, number = extract_series_metadata({}) + assert name is None + assert number is None + + def test_series_name(self): + name, number = extract_series_metadata({"series": "My Series"}) + assert name == "My Series" + assert number is None + + def test_series_number(self): + name, number = extract_series_metadata({"series_index": "3"}) + assert name is None + assert number == "3" + + def test_both(self): + name, number = extract_series_metadata({"series": "My Series", "series_index": "3"}) + assert name == "My Series" + assert number == "3" + + +class TestFormatSeriesSentence: + def test_empty(self): + assert format_series_sentence(None, None) == "" + + def test_name_only(self): + assert format_series_sentence("My Series", None) == "" + + def test_number_only(self): + assert format_series_sentence(None, "3") == "" + + def test_both(self): + assert format_series_sentence("My Series", "3") == "Book 3 of the My Series" + + def test_with_the(self): + assert format_series_sentence("The Lord of the Rings", "1") == "Book 1 of The Lord of the Rings"