Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text
Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
208 lines
7.9 KiB
Python
208 lines
7.9 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Project-status adapter backed by the ``productions/`` directory.
|
|
|
|
The master store does not yet hold translation/audio/format state, so this
|
|
adapter reads ``productions/<book>-<lang>/`` directly. It is a seam: when the
|
|
store gains those rows, this module can be replaced without changing the API.
|
|
|
|
For each program, the adapter returns every configured target language plus the
|
|
English source row. Languages without a production directory are reported as
|
|
``not_started``; languages with a production directory report real chapter
|
|
progress from the runs directory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# slug -> productions directory name stem
|
|
_SLUG_TO_PROD = {
|
|
"dealhunter-trans": "deal-hunter-zh",
|
|
}
|
|
|
|
# Configured target languages per program slug.
|
|
# Each entry: language_tag -> (label, role)
|
|
_TARGET_LANGUAGES = {
|
|
"dealhunter-trans": {
|
|
"en": ("English (source)", "source"),
|
|
"zh-Hans": ("Simplified Chinese (zh-CN)", "translation"),
|
|
"es-ES": ("Spanish (es-ES)", "translation"),
|
|
"fr-FR": ("French (fr-FR)", "translation"),
|
|
"de-DE": ("German (de-DE)", "translation"),
|
|
"it-IT": ("Italian (it-IT)", "translation"),
|
|
"pt-BR": ("Portuguese (pt-BR)", "translation"),
|
|
"ru-RU": ("Russian (ru-RU)", "translation"),
|
|
"ja-JP": ("Japanese (ja-JP)", "translation"),
|
|
"ko-KR": ("Korean (ko-KR)", "translation"),
|
|
"ar-SA": ("Arabic (ar-SA)", "translation"),
|
|
},
|
|
}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class LanguageStatus:
|
|
language_tag: str
|
|
language_label: str
|
|
source_present: bool
|
|
translation: dict[str, Any]
|
|
audio: dict[str, Any]
|
|
outputs: dict[str, Any]
|
|
review: dict[str, Any]
|
|
updated_at: str | None
|
|
|
|
|
|
def _load_json(path: Path) -> dict[str, Any] | None:
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
return data if isinstance(data, dict) else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _max_mtime(paths: list[Path]) -> str | None:
|
|
mtimes = [p.stat().st_mtime for p in paths if p.exists()]
|
|
if not mtimes:
|
|
return None
|
|
return datetime.fromtimestamp(max(mtimes)).isoformat()
|
|
|
|
|
|
def _count_translated_chapters(runs_dir: Path) -> tuple[int, int]:
|
|
"""Return (done, total) chapter counts from the runs directory."""
|
|
done = 0
|
|
chapter_dirs: list[Path] = []
|
|
if runs_dir.is_dir():
|
|
for ch in sorted(runs_dir.iterdir()):
|
|
if ch.is_dir() and ch.name.startswith("ch-"):
|
|
chapter_dirs.append(ch)
|
|
if (ch / "translation.json").is_file():
|
|
done += 1
|
|
return done, len(chapter_dirs)
|
|
|
|
|
|
def _read_outputs(out_progress: Path, out_final: Path) -> dict[str, Any]:
|
|
"""Read available output files from progress and final directories."""
|
|
outputs: dict[str, Any] = {}
|
|
candidates: list[tuple[Path, str]] = []
|
|
if out_progress.is_dir():
|
|
candidates.append((out_progress, "output-progress"))
|
|
if out_final.is_dir():
|
|
candidates.append((out_final, "output"))
|
|
for subdir, key in candidates:
|
|
for p in subdir.iterdir():
|
|
if not p.is_file():
|
|
continue
|
|
name = p.name
|
|
if name.endswith(".pdf"):
|
|
outputs.setdefault("pdf-paperback", {"available": True, "path": str(p), "source": key})
|
|
elif name.endswith(".odt") or name.endswith(".asciidoc"):
|
|
outputs.setdefault("epub", {"available": True, "path": str(p), "source": key})
|
|
return outputs
|
|
|
|
|
|
class ProjectStatusAdapter:
|
|
def __init__(self, productions_root: Path | None = None) -> None:
|
|
if productions_root is not None:
|
|
self.productions_root = Path(productions_root)
|
|
else:
|
|
self.productions_root = Path(__file__).resolve().parents[1] / "productions"
|
|
|
|
def status_for(self, project) -> dict[str, Any]:
|
|
prod_name = _SLUG_TO_PROD.get(getattr(project, "slug", ""))
|
|
prod_dir = self.productions_root / prod_name if prod_name else None
|
|
runs_dir = prod_dir / "runs" if prod_dir else None
|
|
out_progress = prod_dir / "output-progress" if prod_dir else None
|
|
out_final = prod_dir / "output" if prod_dir else None
|
|
|
|
# Chapter totals come from the source book when available; fall back
|
|
# to the number of chapter directories found in runs.
|
|
done, total = _count_translated_chapters(runs_dir) if runs_dir else (0, 0)
|
|
|
|
languages: list[dict[str, Any]] = []
|
|
|
|
# English source row is always present when the project exists.
|
|
languages.append(
|
|
{
|
|
"language_tag": "en",
|
|
"language_label": "English (source)",
|
|
"role": "source",
|
|
"source_present": True,
|
|
"translation": {"state": "complete", "chapters_done": total, "chapters_total": total},
|
|
"audio": {"state": "unknown"},
|
|
"outputs": {},
|
|
"review": {"state": "ok"},
|
|
}
|
|
)
|
|
|
|
target_langs = _TARGET_LANGUAGES.get(getattr(project, "slug", ""), {})
|
|
|
|
for tag in sorted(target_langs.keys()):
|
|
if tag == "en":
|
|
continue # source row already added above
|
|
label, role = target_langs[tag]
|
|
|
|
if prod_dir and prod_dir.is_dir():
|
|
# A language-specific production directory would be named
|
|
# deal-hunter-<tag> (e.g. deal-hunter-es for Spanish).
|
|
lang_prod_name = f"deal-hunter-{tag.split('-')[0]}"
|
|
lang_prod_dir = self.productions_root / lang_prod_name
|
|
lang_runs = lang_prod_dir / "runs" if lang_prod_dir.is_dir() else None
|
|
lang_out_progress = lang_prod_dir / "output-progress" if lang_prod_dir.is_dir() else None
|
|
lang_out_final = lang_prod_dir / "output" if lang_prod_dir.is_dir() else None
|
|
|
|
if lang_runs and lang_runs.is_dir():
|
|
lang_done, lang_total = _count_translated_chapters(lang_runs)
|
|
state = (
|
|
"complete"
|
|
if lang_total and lang_done >= lang_total
|
|
else ("in_progress" if lang_done else "not_started")
|
|
)
|
|
outputs = _read_outputs(
|
|
lang_out_progress or Path(), lang_out_final or Path()
|
|
)
|
|
updated_at = _max_mtime(
|
|
[lang_runs / f"ch-{i}" / "translation.json" for i in range(1, 40)]
|
|
if lang_runs.is_dir()
|
|
else []
|
|
)
|
|
else:
|
|
state = "not_started"
|
|
lang_done, lang_total = 0, 0
|
|
outputs = {}
|
|
updated_at = None
|
|
else:
|
|
state = "not_started"
|
|
lang_done, lang_total = 0, 0
|
|
outputs = {}
|
|
updated_at = None
|
|
|
|
languages.append(
|
|
{
|
|
"language_tag": tag,
|
|
"language_label": label,
|
|
"role": role,
|
|
"source_present": tag == "en",
|
|
"translation": {
|
|
"state": state,
|
|
"chapters_done": lang_done,
|
|
"chapters_total": lang_total,
|
|
},
|
|
"audio": {"state": "not_started"},
|
|
"outputs": outputs,
|
|
"review": {
|
|
"state": "needs_review" if state == "complete" else "pending"
|
|
},
|
|
"updated_at": updated_at,
|
|
}
|
|
)
|
|
|
|
return {"project_id": str(getattr(project, "id")), "languages": languages}
|