- 新增 Editor 窗口:AIBIS → Yarn 本地化校验 - 新增 BatchMode CLI:Tools/validate_yarn_l10n.py - 新增文档:Docs/本地化校验工具.md - 校验 Yarn 原文 string table 与各语言 CSV 的 line id 一致性
213 lines
6.9 KiB
Python
213 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Yarn 本地化校验 CLI — 通过 Unity BatchMode 调用 YarnL10nValidatorCli。
|
||
|
||
用法:
|
||
python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid
|
||
python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all
|
||
python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid --export report.csv
|
||
python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all --fail-on-issues
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
JSON_BEGIN = "YARN_L10N_JSON_BEGIN"
|
||
JSON_END = "YARN_L10N_JSON_END"
|
||
|
||
DEFAULT_UNITY_EDITOR = r"C:\Program Files\Unity\Hub\Editor\2022.3.7f1c1\Editor\Unity.exe"
|
||
|
||
|
||
def find_project_root() -> Path:
|
||
current = Path(__file__).resolve()
|
||
for parent in current.parents:
|
||
if (parent / "Assets").is_dir() and (parent / "ProjectSettings").is_dir():
|
||
return parent
|
||
raise RuntimeError("无法定位 Unity 项目根目录。")
|
||
|
||
|
||
def resolve_unity_editor() -> Path:
|
||
env_path = os.environ.get("UNITY_EDITOR_PATH")
|
||
if env_path:
|
||
candidate = Path(env_path)
|
||
if candidate.exists():
|
||
return candidate
|
||
|
||
if Path(DEFAULT_UNITY_EDITOR).exists():
|
||
return Path(DEFAULT_UNITY_EDITOR)
|
||
|
||
raise RuntimeError(
|
||
"未找到 Unity Editor。请设置环境变量 UNITY_EDITOR_PATH,"
|
||
f"或安装默认路径下的 Unity: {DEFAULT_UNITY_EDITOR}"
|
||
)
|
||
|
||
|
||
def extract_json_from_output(text: str) -> dict:
|
||
if JSON_BEGIN in text and JSON_END in text:
|
||
payload = text.split(JSON_BEGIN, 1)[1].split(JSON_END, 1)[0].strip()
|
||
return json.loads(payload)
|
||
|
||
raise RuntimeError("Unity 输出中未找到 YARN_L10N_JSON 标记。")
|
||
|
||
|
||
def load_result_json(project_root: Path, log_file: Path) -> dict:
|
||
candidates = [
|
||
project_root / "Build" / "yarn_l10n_result.json",
|
||
project_root / "Temp" / "yarn_l10n_result.json",
|
||
]
|
||
for candidate in candidates:
|
||
if candidate.exists():
|
||
return json.loads(candidate.read_text(encoding="utf-8"))
|
||
|
||
if log_file.exists():
|
||
return extract_json_from_output(log_file.read_text(encoding="utf-8", errors="replace"))
|
||
|
||
raise RuntimeError("未找到校验结果 JSON,且无法从 Unity 日志解析。")
|
||
|
||
|
||
def print_summary(result: dict) -> None:
|
||
print(f"Issue 总数: {result.get('TotalIssueCount', 0)}")
|
||
chapters = result.get("Chapters") or []
|
||
print(f"章节数: {len(chapters)}")
|
||
|
||
for chapter in chapters:
|
||
folder = chapter.get("ChapterFolder", "")
|
||
source_count = chapter.get("SourceLineCount", 0)
|
||
locales = chapter.get("Locales") or {}
|
||
locale_parts = []
|
||
for language, summary in locales.items():
|
||
locale_parts.append(
|
||
f"{language}: 缺 {summary.get('MissingCount', 0)} / "
|
||
f"多余 {summary.get('OrphanCount', 0)} / "
|
||
f"变更 {summary.get('SourceChangedCount', 0)}"
|
||
)
|
||
locale_text = " | ".join(locale_parts) if locale_parts else "无翻译语言"
|
||
print(f"- {folder} — 原文 {source_count} 行 | {locale_text}")
|
||
|
||
issues = result.get("Issues") or []
|
||
if not issues:
|
||
print("未发现 issue。")
|
||
return
|
||
|
||
print("\nIssues:")
|
||
for issue in issues[:50]:
|
||
issue_type = issue.get("IssueTypeLabel") or issue.get("Type", "")
|
||
language = issue.get("Language", "")
|
||
line_id = issue.get("LineId", "")
|
||
chapter = issue.get("ChapterFolder", "")
|
||
print(f" [{issue_type}] {language} {line_id} @ {chapter}")
|
||
|
||
if len(issues) > 50:
|
||
print(f" ... 另有 {len(issues) - 50} 条 issue 未显示")
|
||
|
||
|
||
def build_unity_command(
|
||
project_root: Path,
|
||
unity_editor: Path,
|
||
yarn_path: str,
|
||
scan_all: bool,
|
||
export_path: str | None,
|
||
output_json_path: Path,
|
||
log_file: Path,
|
||
) -> list[str]:
|
||
command = [
|
||
str(unity_editor),
|
||
"-batchmode",
|
||
"-nographics",
|
||
"-projectPath",
|
||
str(project_root),
|
||
"-executeMethod",
|
||
"AibisDream.YarnLocalizationValidation.Editor.YarnL10nValidatorCli.Run",
|
||
"-yarnL10nPath",
|
||
yarn_path.replace("\\", "/"),
|
||
f"-yarnL10nOutput={output_json_path.as_posix()}",
|
||
"-logFile",
|
||
str(log_file),
|
||
]
|
||
|
||
if scan_all:
|
||
command.append("-yarnL10nScanAll")
|
||
|
||
if export_path:
|
||
command.append(f"-yarnL10nExport={export_path.replace(chr(92), '/')}")
|
||
|
||
return command
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Yarn 本地化校验(Unity BatchMode)")
|
||
parser.add_argument("path", help="章节文件夹或扫描根目录(相对 Assets/ 或绝对路径)")
|
||
parser.add_argument("--scan-all", action="store_true", help="递归扫描子目录下所有 .yarnproject")
|
||
parser.add_argument("--export", dest="export_path", help="导出 CSV 报告路径")
|
||
parser.add_argument(
|
||
"--fail-on-issues",
|
||
action="store_true",
|
||
help="存在 blocking issue 时返回非零退出码",
|
||
)
|
||
parser.add_argument(
|
||
"--unity",
|
||
dest="unity_editor",
|
||
help="Unity Editor 可执行文件路径(默认读取 UNITY_EDITOR_PATH)",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
project_root = find_project_root()
|
||
unity_editor = Path(args.unity_editor) if args.unity_editor else resolve_unity_editor()
|
||
|
||
temp_dir = project_root / "Build"
|
||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||
output_json_path = temp_dir / "yarn_l10n_result.json"
|
||
log_file = project_root / "Build" / "yarn_l10n_validate.log"
|
||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
if output_json_path.exists():
|
||
output_json_path.unlink()
|
||
|
||
command = build_unity_command(
|
||
project_root=project_root,
|
||
unity_editor=unity_editor,
|
||
yarn_path=args.path,
|
||
scan_all=args.scan_all,
|
||
export_path=args.export_path,
|
||
output_json_path=output_json_path,
|
||
log_file=log_file,
|
||
)
|
||
|
||
print("Running Unity BatchMode validation...")
|
||
completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
|
||
combined_output = "\n".join(part for part in [completed.stdout, completed.stderr] if part)
|
||
result = load_result_json(project_root, log_file)
|
||
|
||
print_summary(result)
|
||
|
||
if args.export_path and not Path(args.export_path).is_absolute():
|
||
export_absolute = project_root / args.export_path
|
||
if export_absolute.exists():
|
||
print(f"CSV 已导出: {export_absolute}")
|
||
|
||
exit_code = completed.returncode
|
||
if args.fail_on_issues and result.get("HasBlockingIssues"):
|
||
exit_code = max(exit_code, 1)
|
||
|
||
if completed.returncode != 0 and exit_code == 0:
|
||
exit_code = completed.returncode
|
||
|
||
if exit_code != 0:
|
||
print(f"Unity 退出码: {completed.returncode}")
|
||
print(f"日志: {log_file}")
|
||
|
||
return exit_code
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|