refactor: extract output path utilities to domain/output_paths.py

- Extract slugify, sanitize_output_stem, output_timestamp_token, build_output_path
- Extract apply_newline_policy, resolve_output_directory, resolve_project_layout
- _prepare_output_dir and _prepare_project_layout become thin wrappers with mkdir
- Add tests/test_output_paths.py (21 tests)
- conversion_runner.py: 1443 → 1410 lines
- All tests pass
This commit is contained in:
Artem Akymenko
2026-07-15 11:56:06 +00:00
parent 914e77de46
commit 4364276a5b
3 changed files with 355 additions and 119 deletions
+91
View File
@@ -0,0 +1,91 @@
"""Output path resolution utilities.
Pure functions for resolving output directories, building file paths,
and computing project folder layouts.
"""
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from abogen.text_extractor import ExtractedChapter
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
def slugify(title: str, index: int) -> str:
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
if not sanitized:
sanitized = f"chapter_{index:02d}"
return sanitized[:80]
def sanitize_output_stem(name: str) -> str:
base = Path(name or "").stem
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
return sanitized or "output"
def output_timestamp_token() -> str:
return datetime.now().strftime("%Y%m%d-%H%M%S")
def build_output_path(directory: Path, original_name: str, extension: str) -> Path:
sanitized = sanitize_output_stem(original_name)
return directory / f"{sanitized}.{extension}"
def apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
if not replace_single_newlines:
return
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
for chapter in chapters:
chapter.text = newline_regex.sub(" ", chapter.text)
def resolve_output_directory(
*,
save_mode: str,
stored_path: Path,
output_folder: Optional[str],
desktop_dir: Optional[Path],
user_output_path: Optional[Path],
user_cache_outputs: Optional[Path],
) -> Path:
if save_mode == "Save to Desktop" and desktop_dir:
return desktop_dir
if save_mode == "Save next to input file":
return stored_path.parent
if save_mode == "Choose output folder" and output_folder:
return Path(output_folder)
if save_mode == "Use default save location" and user_output_path:
return user_output_path
return user_cache_outputs or Path(".")
def resolve_project_layout(
*,
original_filename: str,
save_as_project: bool,
base_dir: Path,
timestamp_fn: Callable[[], str] = output_timestamp_token,
sanitize_fn: Callable[[str, int], str] = sanitize_output_stem,
) -> Tuple[Path, Path, Path, Optional[Path]]:
sanitized = sanitize_fn(original_filename, 0)
folder_name = f"{timestamp_fn()}_{sanitized}"
project_root = base_dir / folder_name
project_root.mkdir(parents=True, exist_ok=True)
if save_as_project:
audio_dir = project_root / "audio"
subtitle_dir = project_root / "subtitles"
metadata_dir = project_root / "metadata"
for directory in (audio_dir, subtitle_dir, metadata_dir):
directory.mkdir(parents=True, exist_ok=True)
return project_root, audio_dir, subtitle_dir, metadata_dir
return project_root, project_root, project_root, None
+22 -55
View File
@@ -102,6 +102,15 @@ from abogen.domain.voice_utils import (
infer_provider_from_spec as _infer_provider_from_spec,
coerce_truthy as _coerce_truthy,
)
from abogen.domain.output_paths import (
slugify as _slugify,
sanitize_output_stem as _sanitize_output_stem,
output_timestamp_token as _output_timestamp_token,
build_output_path as _build_output_path,
apply_newline_policy as _apply_newline_policy,
resolve_output_directory as _resolve_output_directory,
resolve_project_layout as _resolve_project_layout,
)
from .service import Job, JobStatus
@@ -1250,67 +1259,25 @@ def _prepare_output_dir(job: Job) -> Path:
from platformdirs import user_desktop_dir # type: ignore[import-not-found]
default_output = Path(str(get_user_cache_path("outputs")))
if job.save_mode == "Save to Desktop":
directory = Path(user_desktop_dir())
elif job.save_mode == "Save next to input file":
directory = job.stored_path.parent
elif job.save_mode == "Choose output folder" and job.output_folder:
directory = Path(job.output_folder)
elif job.save_mode == "Use default save location":
directory = Path(get_user_output_path())
else:
directory = default_output
directory = _resolve_output_directory(
save_mode=job.save_mode,
stored_path=job.stored_path,
output_folder=getattr(job, "output_folder", None),
desktop_dir=Path(user_desktop_dir()),
user_output_path=Path(get_user_output_path()),
user_cache_outputs=default_output,
)
directory.mkdir(parents=True, exist_ok=True)
return directory
def _build_output_path(directory: Path, original_name: str, extension: str) -> Path:
sanitized = _sanitize_output_stem(original_name)
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{sanitized}.{extension}"
def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]:
base_dir.mkdir(parents=True, exist_ok=True)
sanitized = _sanitize_output_stem(job.original_filename)
folder_name = f"{_output_timestamp_token()}_{sanitized}"
project_root = base_dir / folder_name
project_root.mkdir(parents=True, exist_ok=True)
if job.save_as_project:
audio_dir = project_root / "audio"
subtitle_dir = project_root / "subtitles"
metadata_dir = project_root / "metadata"
for directory in (audio_dir, subtitle_dir, metadata_dir):
directory.mkdir(parents=True, exist_ok=True)
return project_root, audio_dir, subtitle_dir, metadata_dir
return project_root, project_root, project_root, None
def _apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
if not replace_single_newlines:
return
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
for chapter in chapters:
chapter.text = newline_regex.sub(" ", chapter.text)
def _slugify(title: str, index: int) -> str:
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
if not sanitized:
sanitized = f"chapter_{index:02d}"
return sanitized[:80]
def _sanitize_output_stem(name: str) -> str:
base = Path(name or "").stem
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
return sanitized or "output"
def _output_timestamp_token() -> str:
return datetime.now().strftime("%Y%m%d-%H%M%S")
return _resolve_project_layout(
original_filename=job.original_filename,
save_as_project=job.save_as_project,
base_dir=base_dir,
)
def _open_audio_sink(