refactor: move calculate_text_length to domain, add build_chapter_payload to app-layer

- Create domain/text_utils.py with canonical calculate_text_length
  (strips chapter markers, voice markers, metadata tags)
- Remove calculate_text_length from utils.py and subtitle_utils.py
- Update all imports to use domain.text_utils directly
- Create application/chapter_selection.py with build_chapter_payload()
  (orchestrates preselection + character count + safety net)
- Replace manual chapter logic in form.py with build_chapter_payload()
- 22 new tests for text_utils and chapter_selection (1550 total)
This commit is contained in:
Artem Akymenko
2026-07-30 14:57:10 +00:00
parent d334266238
commit 94e6b3f62e
13 changed files with 243 additions and 70 deletions
+62
View File
@@ -0,0 +1,62 @@
"""Chapter selection helpers for the application layer.
Builds chapter payloads with smart defaults (preselection based on
supplement score) and character counts. Used by both WebUI and PyQt.
"""
from __future__ import annotations
from typing import Any, Dict, List
from abogen.domain.chapter_classification import (
ensure_at_least_one_chapter_enabled,
should_preselect_chapter,
)
from abogen.domain.text_utils import calculate_text_length
def build_chapter_payload(
chapters: List[Any],
source_name: str = "",
) -> List[Dict[str, Any]]:
"""Build a chapter payload with preselection and character counts.
Args:
chapters: List of chapter-like objects with ``title`` and ``text`` attributes.
source_name: Fallback title for the placeholder chapter when *chapters* is empty.
Returns:
List of chapter dicts ready for ``PendingJob.chapters`` or ``ChapterChunkConfig``.
"""
total = len(chapters)
payload: List[Dict[str, Any]] = []
for index, chapter in enumerate(chapters):
title = getattr(chapter, "title", "") or ""
text = getattr(chapter, "text", "") or ""
enabled = should_preselect_chapter(title, text, index, total)
payload.append(
{
"id": f"{index:04d}",
"index": index,
"title": title,
"text": text,
"characters": calculate_text_length(text),
"enabled": enabled,
}
)
if not payload:
payload.append(
{
"id": "0000",
"index": 0,
"title": source_name,
"text": "",
"characters": 0,
"enabled": True,
}
)
ensure_at_least_one_chapter_enabled(payload)
return payload
+2 -1
View File
@@ -12,7 +12,8 @@ import fitz # PyMuPDF
import markdown
from abogen.utils import detect_encoding
from abogen.subtitle_utils import clean_text, calculate_text_length
from abogen.subtitle_utils import clean_text
from abogen.domain.text_utils import calculate_text_length
# Pre-compile frequently used regex patterns
_BRACKETED_NUMBERS_PATTERN = re.compile(r"\[\s*\d+\s*\]")
+22
View File
@@ -0,0 +1,22 @@
"""Text utility functions for the domain layer."""
from __future__ import annotations
import re
# Pre-compiled patterns for calculate_text_length
_METADATA_TAG_PATTERN = re.compile(r"<<METADATA_[^:]+:[^>]*>>")
_CHAPTER_MARKER_PATTERN = re.compile(r"<<CHAPTER_MARKER:[^>]*>>")
_VOICE_MARKER_PATTERN = re.compile(r"<<VOICE:[^>]*>>")
def calculate_text_length(text: str) -> int:
"""Calculate character count, ignoring internal markers and newlines.
Strips chapter markers, voice markers, and metadata tags before counting.
"""
text = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
text = _METADATA_TAG_PATTERN.sub("", text)
text = text.replace("\n", "").strip()
return len(text)
+2 -4
View File
@@ -36,10 +36,8 @@ from abogen.domain.metadata_extraction import (
format_metadata_tags,
)
from abogen.subtitle_utils import (
clean_text,
calculate_text_length,
)
from abogen.subtitle_utils import clean_text
from abogen.domain.text_utils import calculate_text_length
import os
import logging
+2 -4
View File
@@ -70,10 +70,8 @@ from abogen.utils import (
LoadPipelineThread,
)
from abogen.subtitle_utils import (
clean_text,
calculate_text_length,
)
from abogen.subtitle_utils import clean_text
from abogen.domain.text_utils import calculate_text_length
from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog
from abogen.pyqt.book_handler import HandlerDialog
+1 -1
View File
@@ -523,7 +523,7 @@ class QueueManager(QDialog):
return attrs
def add_files_from_paths(self, file_paths):
from abogen.subtitle_utils import calculate_text_length
from abogen.domain.text_utils import calculate_text_length
from PyQt6.QtWidgets import QMessageBox
import os
-11
View File
@@ -34,17 +34,6 @@ def clean_subtitle_text(text):
return text.strip()
def calculate_text_length(text):
# Use pre-compiled patterns for better performance
# Ignore chapter markers, voice markers, and metadata patterns in a single pass
text = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
text = _METADATA_TAG_PATTERN.sub("", text)
# Ignore newlines and leading/trailing spaces
text = text.replace("\n", "").strip()
# Calculate character count
char_count = len(text)
return char_count
def clean_text(text, *args, **kwargs):
+2 -1
View File
@@ -16,7 +16,8 @@ import markdown # type: ignore[import]
from bs4 import BeautifulSoup, NavigableString # type: ignore[import]
from ebooklib import epub # type: ignore[import]
from .utils import calculate_text_length, clean_text, detect_encoding
from .utils import clean_text, detect_encoding
from .domain.text_utils import calculate_text_length
logger = logging.getLogger(__name__)
-13
View File
@@ -428,19 +428,6 @@ def save_config(config):
pass
def calculate_text_length(text):
# Ignore chapter markers
text = re.sub(r"<<CHAPTER_MARKER:.*?>>", "", text)
# Ignore metadata patterns
text = re.sub(r"<<METADATA_[^:]+:[^>]*>>", "", text)
# Ignore newlines
text = text.replace("\n", "")
# Ignore leading/trailing spaces
text = text.strip()
# Calculate character count
char_count = len(text)
return char_count
def get_gpu_acceleration(enabled):
try:
+3 -34
View File
@@ -6,11 +6,7 @@ from flask import request, render_template, jsonify
from flask.typing import ResponseReturnValue
from abogen.domain.enums import Language
from abogen.domain.chapter_classification import (
supplement_score,
should_preselect_chapter,
ensure_at_least_one_chapter_enabled,
)
from abogen.application.chapter_selection import build_chapter_payload
from abogen.webui.service import PendingJob, JobStatus
from abogen.webui.routes.utils.service import get_service
from abogen.tts_plugin.utils import is_plugin_registered
@@ -40,7 +36,7 @@ from abogen.domain.voice_resolution import (
from abogen.webui.routes.utils.entity import sync_pronunciation_overrides
from abogen.webui.routes.utils.epub import job_download_flags
from abogen.webui.routes.utils.common import split_profile_spec, extract_checkbox
from abogen.utils import calculate_text_length
from abogen.domain.text_utils import calculate_text_length
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
from abogen.tts_plugin.utils import get_default_voice
@@ -641,34 +637,7 @@ def build_pending_job_from_extraction(
getattr(extraction, "combined_text", "")
)
chapters_source = getattr(extraction, "chapters", []) or []
total_chapter_count = len(chapters_source)
chapters_payload: List[Dict[str, Any]] = []
for index, chapter in enumerate(chapters_source):
enabled = should_preselect_chapter(chapter.title, chapter.text, index, total_chapter_count)
chapters_payload.append(
{
"id": f"{index:04d}",
"index": index,
"title": chapter.title,
"text": chapter.text,
"characters": calculate_text_length(chapter.text),
"enabled": enabled,
}
)
if not chapters_payload:
chapters_payload.append(
{
"id": "0000",
"index": 0,
"title": original_name,
"text": "",
"characters": 0,
"enabled": True,
}
)
ensure_at_least_one_chapter_enabled(chapters_payload)
chapters_payload = build_chapter_payload(chapters_source, source_name=original_name)
raw_language = str(form.get("language") or "a").strip() or "a"
try: