mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
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
This commit is contained in:
@@ -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()
|
||||||
@@ -54,6 +54,14 @@ from abogen.domain.chapter_titles import (
|
|||||||
normalize_chapter_opening_caps as _normalize_chapter_opening_caps,
|
normalize_chapter_opening_caps as _normalize_chapter_opening_caps,
|
||||||
format_spoken_chapter_title as _format_spoken_chapter_title,
|
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
|
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\-_.]+")
|
_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(
|
def _build_title_intro_text(
|
||||||
metadata: Optional[Mapping[str, Any]],
|
metadata: Optional[Mapping[str, Any]],
|
||||||
fallback_basename: str,
|
fallback_basename: str,
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user