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
298 lines
12 KiB
Python
298 lines
12 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Build a bilingual side-by-side review page for the translation.
|
|
|
|
The output is a single self-contained HTML file. It contains the English source
|
|
and the Chinese draft, so it must stay inside the git-ignored production
|
|
directory. It is meant for a human editor: every paragraph is on one row, the
|
|
critic and mechanical issues are attached to the row, and a filter hides the
|
|
paragraphs that have no issues.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from qa import check_chapter # noqa: E402
|
|
|
|
|
|
def _load(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _severity_rank(sev: str) -> int:
|
|
return {"block": 0, "major": 1, "minor": 2, "info": 3}.get(sev, 4)
|
|
|
|
|
|
def _critic_issues(data: dict) -> list[dict]:
|
|
out = []
|
|
for raw in data.get("issues", []) or []:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
out.append(
|
|
{
|
|
"id": str(raw.get("id") or ""),
|
|
"severity": str(raw.get("severity") or "major"),
|
|
"type": str(raw.get("type") or "meaning"),
|
|
"problem": str(raw.get("problem") or ""),
|
|
"source_excerpt": str(raw.get("source_excerpt") or ""),
|
|
"target_excerpt": str(raw.get("target_excerpt") or ""),
|
|
"fix": str(raw.get("fix") or ""),
|
|
"origin": "critic",
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _mechanical_issues(chapter: dict, data: dict) -> list[dict]:
|
|
issues, _metrics = check_chapter(chapter, data)
|
|
out = []
|
|
for raw in issues:
|
|
out.append(
|
|
{
|
|
"id": str(raw.get("id") or ""),
|
|
"severity": str(raw.get("level") or "major"),
|
|
"type": str(raw.get("kind") or "mechanical"),
|
|
"problem": str(raw.get("detail") or ""),
|
|
"source_excerpt": "",
|
|
"target_excerpt": "",
|
|
"fix": "",
|
|
"origin": "qa",
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _badge(sev: str) -> str:
|
|
label = {"block": "BLOCK", "major": "MAJOR", "minor": "minor", "info": "info"}.get(sev, sev)
|
|
return f'<span class="badge {html.escape(sev)}">{html.escape(label)}</span>'
|
|
|
|
|
|
def _issue_html(issue: dict) -> str:
|
|
parts = [
|
|
f'<div class="issue {html.escape(issue["severity"])}">',
|
|
_badge(issue["severity"]),
|
|
f' <span class="kind">{html.escape(issue["type"])}</span> ',
|
|
f'<span class="origin">[{html.escape(issue["origin"])}]</span>',
|
|
]
|
|
if issue["problem"]:
|
|
parts.append(f'<div class="problem">{html.escape(issue["problem"])}</div>')
|
|
if issue["source_excerpt"]:
|
|
parts.append(f'<div class="excerpt">EN: {html.escape(issue["source_excerpt"])}</div>')
|
|
if issue["target_excerpt"]:
|
|
parts.append(f'<div class="excerpt">ZH: {html.escape(issue["target_excerpt"])}</div>')
|
|
if issue["fix"]:
|
|
parts.append(f'<div class="fix">建议:{html.escape(issue["fix"])}</div>')
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
|
|
def build_html(book: dict, run_dir: Path, out_path: Path, only: set[str] | None = None) -> None:
|
|
sections: list[str] = []
|
|
stats = {"chapters": 0, "paragraphs": 0, "flagged": 0, "chars": 0}
|
|
total_issues = {"block": 0, "major": 0, "minor": 0, "info": 0}
|
|
|
|
for chapter in book["chapters"]:
|
|
if only and chapter["id"] not in only:
|
|
continue
|
|
path = run_dir / chapter["id"] / "translation.json"
|
|
if not path.exists():
|
|
continue
|
|
data = _load(path)
|
|
drafts: dict[str, str] = data.get("paragraphs", {})
|
|
if not drafts:
|
|
continue
|
|
issues = _critic_issues(data) + _mechanical_issues(chapter, data)
|
|
by_id: dict[str, list[dict]] = {}
|
|
unmatched: list[dict] = []
|
|
for issue in issues:
|
|
total_issues[issue["severity"]] = total_issues.get(issue["severity"], 0) + 1
|
|
if issue["id"] and issue["id"] in drafts:
|
|
by_id.setdefault(issue["id"], []).append(issue)
|
|
else:
|
|
unmatched.append(issue)
|
|
|
|
rows: list[str] = []
|
|
chapter_chars = 0
|
|
flagged = 0
|
|
heading = chapter.get("heading_zh") or chapter["title_zh"]
|
|
for p in chapter["paragraphs"]:
|
|
pid = p["id"]
|
|
if p.get("type") == "scene_break":
|
|
rows.append('<tr class="scene"><td colspan="3">* * *</td></tr>')
|
|
continue
|
|
zh = drafts.get(pid)
|
|
if zh is None:
|
|
rows.append(
|
|
f'<tr id="{pid}" class="row missing" data-flagged="1">'
|
|
f'<td class="pid">{pid}</td>'
|
|
f'<td class="en">{html.escape(p.get("text") or "")}</td>'
|
|
f'<td class="zh"><em>(未译)</em></td></tr>'
|
|
)
|
|
stats["flagged"] += 1
|
|
continue
|
|
zh = str(zh)
|
|
chapter_chars += len(re.findall(r"[\u4e00-\u9fff]", zh))
|
|
row_issues = sorted(by_id.get(pid, []), key=lambda i: _severity_rank(i["severity"]))
|
|
if row_issues:
|
|
flagged += 1
|
|
row_class = "row flagged" if row_issues else "row"
|
|
detail = "".join(_issue_html(i) for i in row_issues)
|
|
rows.append(
|
|
f'<tr id="{pid}" class="{row_class}" data-flagged="{1 if row_issues else 0}">'
|
|
f'<td class="pid">{html.escape(pid)}</td>'
|
|
f'<td class="en">{html.escape(p.get("text") or "")}</td>'
|
|
f'<td class="zh"><div class="zh-text">{html.escape(zh)}</div>{detail}</td>'
|
|
f"</tr>"
|
|
)
|
|
|
|
stats["chapters"] += 1
|
|
stats["paragraphs"] += len(drafts)
|
|
stats["chars"] += chapter_chars
|
|
stats["flagged"] += flagged
|
|
|
|
unmatched_html = ""
|
|
if unmatched:
|
|
unmatched_html = (
|
|
'<div class="unmatched"><h4>未能定位到段落的审校意见</h4>'
|
|
+ "".join(_issue_html(i) for i in unmatched)
|
|
+ "</div>"
|
|
)
|
|
|
|
sections.append(
|
|
f"""
|
|
<section class="chapter" id="{html.escape(chapter['id'])}">
|
|
<h2>{html.escape(heading)} <span class="meta">{html.escape(chapter['title'])} · {chapter['id']}</span></h2>
|
|
<p class="chapstats">{len(drafts)} 段 · {chapter_chars} 汉字 · {flagged} 段有意见</p>
|
|
{unmatched_html}
|
|
<table>
|
|
<thead><tr><th class="pid">ID</th><th class="en">English source</th><th class="zh">中文译文 / 意见</th></tr></thead>
|
|
<tbody>
|
|
{''.join(rows)}
|
|
</tbody>
|
|
</table>
|
|
</section>"""
|
|
)
|
|
|
|
doc = f"""<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Deal Hunter 中译审校表</title>
|
|
<style>
|
|
:root {{ --bg:#fbfaf7; --ink:#222; --muted:#666; --line:#d9d4c8; --flag:#fff4e5; --block:#c62828; --major:#b26a00; --minor:#5c6bc0; --info:#607d8b; }}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{ margin:0; background:var(--bg); color:var(--ink); font:15px/1.65 "Noto Serif CJK SC","Source Han Serif SC",serif; }}
|
|
header {{ position:sticky; top:0; z-index:10; background:rgba(251,250,247,.96); border-bottom:1px solid var(--line); padding:14px 22px; backdrop-filter:blur(6px); }}
|
|
header h1 {{ margin:0 0 4px; font-size:22px; }}
|
|
header .sub {{ color:var(--muted); font-size:13px; }}
|
|
header .controls {{ margin-top:8px; display:flex; gap:16px; align-items:center; flex-wrap:wrap; font-size:13px; }}
|
|
main {{ max-width:1500px; margin:0 auto; padding:20px 22px 80px; }}
|
|
.chapter {{ margin:28px 0 40px; }}
|
|
.chapter h2 {{ font-size:20px; border-bottom:2px solid var(--ink); padding-bottom:6px; margin:0 0 4px; }}
|
|
.chapter h2 .meta {{ font-size:12px; color:var(--muted); font-weight:normal; margin-left:8px; }}
|
|
.chapstats {{ margin:0 0 10px; color:var(--muted); font-size:12px; }}
|
|
table {{ width:100%; border-collapse:collapse; table-layout:fixed; background:#fff; }}
|
|
th,td {{ border:1px solid var(--line); vertical-align:top; padding:8px 10px; }}
|
|
thead th {{ position:sticky; top:87px; background:#efe9dc; z-index:5; text-align:left; font-size:12px; letter-spacing:.04em; }}
|
|
col.pid, th.pid, td.pid {{ width:92px; }}
|
|
th.en, td.en {{ width:48%; color:#333; font-family:"Noto Serif","Times New Roman",serif; font-size:14px; }}
|
|
th.zh, td.zh {{ width:calc(52% - 92px); }}
|
|
td.pid {{ font:11px/1.4 ui-monospace,monospace; color:var(--muted); }}
|
|
td.zh-text {{ white-space:pre-wrap; }}
|
|
tr.flagged {{ background:var(--flag); }}
|
|
tr.missing td.zh {{ color:var(--block); }}
|
|
tr.scene td {{ text-align:center; color:var(--muted); letter-spacing:.5em; background:#f6f2e9; }}
|
|
.issue {{ margin-top:8px; padding:6px 8px; border-left:4px solid var(--line); background:#fffdf8; font-size:12.5px; }}
|
|
.issue.block {{ border-color:var(--block); }}
|
|
.issue.major {{ border-color:var(--major); }}
|
|
.issue.minor {{ border-color:var(--minor); }}
|
|
.issue.info {{ border-color:var(--info); }}
|
|
.badge {{ display:inline-block; padding:0 5px; border-radius:3px; color:#fff; font:10px/1.6 ui-monospace,monospace; }}
|
|
.badge.block {{ background:var(--block); }} .badge.major {{ background:var(--major); }}
|
|
.badge.minor {{ background:var(--minor); }} .badge.info {{ background:var(--info); }}
|
|
.kind {{ font:11px/1.4 ui-monospace,monospace; color:var(--muted); }}
|
|
.origin {{ font-size:10px; color:#999; }}
|
|
.problem {{ margin:3px 0 0; }}
|
|
.excerpt {{ color:#555; font-size:11.5px; }}
|
|
.fix {{ color:#1b5e20; font-size:11.5px; }}
|
|
.unmatched {{ border:1px dashed var(--line); padding:8px 10px; margin:8px 0; background:#fff; }}
|
|
.unmatched h4 {{ margin:0 0 6px; font-size:13px; }}
|
|
@media print {{
|
|
header {{ position:static; }}
|
|
thead th {{ position:static; }}
|
|
tr.flagged {{ background:#fff; }}
|
|
.chapter {{ page-break-before:always; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Deal Hunter · 交易猎人 — 中译审校表</h1>
|
|
<div class="sub">生成 {time.strftime('%Y-%m-%d %H:%M')} · {stats['chapters']} 章 · {stats['paragraphs']} 段 · {stats['chars']} 汉字 · {stats['flagged']} 段有意见 · block {total_issues['block']} / major {total_issues['major']} / minor {total_issues['minor']}</div>
|
|
<div class="controls">
|
|
<label><input type="checkbox" id="onlyFlagged"> 只看有意见的段落</label>
|
|
<label><input type="checkbox" id="hideEnglish"> 隐藏英文</label>
|
|
<span id="count"></span>
|
|
</div>
|
|
</header>
|
|
<main>
|
|
{''.join(sections)}
|
|
</main>
|
|
<script>
|
|
const rows = [...document.querySelectorAll('tr.row')];
|
|
const only = document.getElementById('onlyFlagged');
|
|
const hideEn = document.getElementById('hideEnglish');
|
|
const count = document.getElementById('count');
|
|
function apply() {{
|
|
let shown = 0;
|
|
for (const r of rows) {{
|
|
const flagged = r.dataset.flagged === '1';
|
|
const visible = !only.checked || flagged;
|
|
r.style.display = visible ? '' : 'none';
|
|
if (visible) shown++;
|
|
}}
|
|
document.querySelectorAll('td.en, th.en').forEach(el => el.style.display = hideEn.checked ? 'none' : '');
|
|
count.textContent = `显示 ${{shown}} / ${{rows.length}} 段`;
|
|
}}
|
|
only.addEventListener('change', apply);
|
|
hideEn.addEventListener('change', apply);
|
|
apply();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(doc, encoding="utf-8")
|
|
print(f"html {out_path}")
|
|
print(
|
|
f" {stats['chapters']} chapters, {stats['paragraphs']} paragraphs, "
|
|
f"{stats['chars']} chars, {stats['flagged']} flagged"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description="Build a bilingual HTML review page.")
|
|
ap.add_argument("--book", type=Path, required=True)
|
|
ap.add_argument("--run-dir", type=Path, required=True)
|
|
ap.add_argument("--out", type=Path, required=True)
|
|
ap.add_argument("--chapters", help="comma-separated chapter ids (default: all translated)")
|
|
args = ap.parse_args()
|
|
book = _load(args.book)
|
|
only = {c.strip() for c in args.chapters.split(",")} if args.chapters else None
|
|
build_html(book, args.run_dir, args.out, only)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|