From 7d7c8893fc553dbdf24c6ab370a5e1331f72d6b4 Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Sat, 4 Jul 2026 23:12:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=B7=BB=E5=8A=A0=20Yarn=20?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E6=A0=A1=E9=AA=8C=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Editor 窗口:AIBIS → Yarn 本地化校验 - 新增 BatchMode CLI:Tools/validate_yarn_l10n.py - 新增文档:Docs/本地化校验工具.md - 校验 Yarn 原文 string table 与各语言 CSV 的 line id 一致性 --- Assets/Editor/YarnLocalizationValidation.meta | 8 + .../YarnL10nIssue.cs | 101 ++++ .../YarnL10nIssue.cs.meta | 11 + .../YarnL10nReportExporter.cs | 57 +++ .../YarnL10nReportExporter.cs.meta | 11 + .../YarnL10nValidationWindow.cs | 471 ++++++++++++++++++ .../YarnL10nValidationWindow.cs.meta | 11 + .../YarnL10nValidator.cs | 412 +++++++++++++++ .../YarnL10nValidator.cs.meta | 11 + .../YarnL10nValidatorCli.cs | 116 +++++ .../YarnL10nValidatorCli.cs.meta | 11 + Docs/本地化校验工具.md | 94 ++++ Tools/validate_yarn_l10n.py | 212 ++++++++ 13 files changed, 1526 insertions(+) create mode 100644 Assets/Editor/YarnLocalizationValidation.meta create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs.meta create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs.meta create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs.meta create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs.meta create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs create mode 100644 Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs.meta create mode 100644 Docs/本地化校验工具.md create mode 100644 Tools/validate_yarn_l10n.py diff --git a/Assets/Editor/YarnLocalizationValidation.meta b/Assets/Editor/YarnLocalizationValidation.meta new file mode 100644 index 000000000..13a96cd92 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3fe2278382cb87e4993d86ca5ed00e5f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs b/Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs new file mode 100644 index 000000000..6c360b5c2 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; + +namespace AibisDream.YarnLocalizationValidation.Editor +{ + public enum YarnL10nIssueType + { + MissingLocalization, + OrphanLocalization, + SourceTextChanged, + CompileError, + UntaggedLine, + CsvMissing, + InvalidProject, + NoLocalizationConfigured, + } + + [Serializable] + public sealed class YarnL10nIssue + { + public YarnL10nIssueType Type; + public string Language; + public string LineId; + public string SourceText; + public string LocText; + public string YarnFile; + public string YarnLineNumber; + public string CsvFile; + public string ChapterFolder; + public string Message; + + public string IssueTypeLabel => GetDisplayType(); + + public string GetDisplayType() + { + return Type switch + { + YarnL10nIssueType.MissingLocalization => $"缺少本地化-{Language}", + YarnL10nIssueType.OrphanLocalization => $"找不到原文-{Language}", + YarnL10nIssueType.SourceTextChanged => $"原文已变更-{Language}", + YarnL10nIssueType.CompileError => "编译错误", + YarnL10nIssueType.UntaggedLine => "未打line标签", + YarnL10nIssueType.CsvMissing => $"CSV缺失-{Language}", + YarnL10nIssueType.InvalidProject => "项目无效", + YarnL10nIssueType.NoLocalizationConfigured => "未配置本地化", + _ => Type.ToString(), + }; + } + } + + [Serializable] + public sealed class YarnL10nChapterSummary + { + public string ChapterFolder; + public string YarnProjectPath; + public int SourceLineCount; + public Dictionary Locales = new(); + public int IssueCount; + } + + [Serializable] + public sealed class YarnL10nLocaleSummary + { + public string Language; + public int MissingCount; + public int OrphanCount; + public int SourceChangedCount; + } + + [Serializable] + public sealed class YarnL10nValidationResult + { + public List Issues = new(); + public List Chapters = new(); + public int TotalIssueCount; + + public bool HasIssues => TotalIssueCount > 0; + + public bool HasBlockingIssues + { + get + { + foreach (var issue in Issues) + { + switch (issue.Type) + { + case YarnL10nIssueType.MissingLocalization: + case YarnL10nIssueType.OrphanLocalization: + case YarnL10nIssueType.SourceTextChanged: + case YarnL10nIssueType.CompileError: + case YarnL10nIssueType.CsvMissing: + case YarnL10nIssueType.InvalidProject: + return true; + } + } + + return false; + } + } + } +} diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs.meta b/Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs.meta new file mode 100644 index 000000000..d311f2008 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nIssue.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 340d8f4fbf1fb8a458b87a164afd827b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs b/Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs new file mode 100644 index 000000000..95adade63 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace AibisDream.YarnLocalizationValidation.Editor +{ + public static class YarnL10nReportExporter + { + public static void ExportToCsv(YarnL10nValidationResult result, string outputPath) + { + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var builder = new StringBuilder(); + builder.AppendLine("issueType,language,lineId,sourceText,locText,yarnFile,yarnLineNumber,csvFile,chapterFolder,message"); + + foreach (var issue in result.Issues) + { + builder.AppendLine(string.Join(",", + Escape(issue.GetDisplayType()), + Escape(issue.Language), + Escape(issue.LineId), + Escape(issue.SourceText), + Escape(issue.LocText), + Escape(issue.YarnFile), + Escape(issue.YarnLineNumber), + Escape(issue.CsvFile), + Escape(issue.ChapterFolder), + Escape(issue.Message))); + } + + File.WriteAllText(outputPath, builder.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + } + + public static string BuildDefaultExportFileName(string chapterFolder) + { + var chapterName = string.IsNullOrEmpty(chapterFolder) + ? "yarn_l10n" + : Path.GetFileName(chapterFolder.Replace('/', Path.DirectorySeparatorChar)); + return $"{chapterName}_l10n_report_{System.DateTime.Now:yyyyMMdd_HHmmss}.csv"; + } + + private static string Escape(string value) + { + value ??= string.Empty; + if (value.Contains('"') || value.Contains(',') || value.Contains('\n') || value.Contains('\r')) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } + } +} diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs.meta b/Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs.meta new file mode 100644 index 000000000..1c7fc018e --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nReportExporter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ab3c79927b366e459587175b8325dd6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs new file mode 100644 index 000000000..c43036f5b --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; + +namespace AibisDream.YarnLocalizationValidation.Editor +{ + public class YarnL10nValidationWindow : EditorWindow + { + private const string MenuPath = "AIBIS/Yarn 本地化校验"; + private const string WindowTitle = "Yarn Localization Validator"; + private const string FolderPathPrefKey = "AibisDream.YarnL10nValidation.FolderPath"; + private const string ScanSubdirsPrefKey = "AibisDream.YarnL10nValidation.ScanSubdirs"; + + private string _folderPath = "Assets/Yarn/FP/FP_Day1_mid"; + private DefaultAsset _folderAsset; + private bool _scanSubdirectories; + private Vector2 _scrollPosition; + private YarnL10nValidationResult _lastResult; + private string _filterType = "全部"; + private string _filterLanguage = "全部"; + private string _searchText = string.Empty; + private string[] _typeOptions = { "全部" }; + private string[] _languageOptions = { "全部" }; + private int _filterTypeIndex; + private int _filterLanguageIndex; + + [MenuItem(MenuPath)] + public static void Open() + { + var window = GetWindow(WindowTitle); + window.minSize = new Vector2(640, 480); + } + + private void OnEnable() + { + _folderPath = EditorPrefs.GetString(FolderPathPrefKey, _folderPath); + _scanSubdirectories = EditorPrefs.GetBool(ScanSubdirsPrefKey, false); + SyncFolderAssetFromPath(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Yarn 本地化校验", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + DrawFolderSelector(); + EditorGUILayout.Space(4); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("校验", GUILayout.Height(28))) + { + RunValidation(); + } + + using (new EditorGUI.DisabledScope(_lastResult == null || _lastResult.Issues.Count == 0)) + { + if (GUILayout.Button("导出 CSV", GUILayout.Height(28))) + { + ExportReport(); + } + } + + if (GUILayout.Button("定位文件夹", GUILayout.Height(28))) + { + PingFolder(); + } + + EditorGUILayout.EndHorizontal(); + + _scanSubdirectories = EditorGUILayout.ToggleLeft("扫描子目录(批量)", _scanSubdirectories); + if (GUI.changed) + { + EditorPrefs.SetBool(ScanSubdirsPrefKey, _scanSubdirectories); + } + EditorGUILayout.Space(6); + + DrawSummary(); + DrawFilters(); + DrawIssueList(); + } + + private void DrawFolderSelector() + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("章节文件夹", GUILayout.Width(80)); + + var newAsset = (DefaultAsset)EditorGUILayout.ObjectField(_folderAsset, typeof(DefaultAsset), false); + if (newAsset != _folderAsset) + { + _folderAsset = newAsset; + if (_folderAsset != null) + { + _folderPath = AssetDatabase.GetAssetPath(_folderAsset); + SaveFolderPreferences(); + } + } + + var newPath = EditorGUILayout.TextField(_folderPath); + if (!string.Equals(newPath, _folderPath, StringComparison.Ordinal)) + { + _folderPath = newPath; + SyncFolderAssetFromPath(); + SaveFolderPreferences(); + } + + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + var startDirectory = GetBrowseStartDirectory(); + var selected = EditorUtility.OpenFolderPanel("选择 Yarn 章节文件夹", startDirectory, string.Empty); + if (!string.IsNullOrEmpty(selected)) + { + _folderPath = YarnL10nValidator.NormalizeProjectRelativePath(selected); + SyncFolderAssetFromPath(); + SaveFolderPreferences(); + } + } + + EditorGUILayout.EndHorizontal(); + } + + private void SaveFolderPreferences() + { + EditorPrefs.SetString(FolderPathPrefKey, _folderPath); + } + + private string GetBrowseStartDirectory() + { + var absolutePath = YarnL10nValidator.ToAbsolutePath(_folderPath); + if (!string.IsNullOrEmpty(absolutePath) && !IsFilesystemRoot(absolutePath)) + { + var parent = Directory.GetParent(absolutePath); + if (parent != null) + { + return parent.FullName; + } + } + + if (!string.IsNullOrEmpty(absolutePath) && Directory.Exists(absolutePath)) + { + return absolutePath; + } + + var assetsPath = Application.dataPath; + return Directory.Exists(assetsPath) ? assetsPath : Environment.CurrentDirectory; + } + + private static bool IsFilesystemRoot(string absolutePath) + { + var normalized = Path.GetFullPath(absolutePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var root = Path.GetPathRoot(normalized)? + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return !string.IsNullOrEmpty(root) && + string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase); + } + + private void SyncFolderAssetFromPath() + { + _folderAsset = AssetDatabase.LoadAssetAtPath(_folderPath); + } + + private void RunValidation() + { + SaveFolderPreferences(); + _lastResult = _scanSubdirectories + ? YarnL10nValidator.ValidateScanAll(_folderPath) + : YarnL10nValidator.ValidateFolder(_folderPath); + RefreshFilterOptions(); + Repaint(); + } + + private void RefreshFilterOptions() + { + if (_lastResult == null || _lastResult.Issues.Count == 0) + { + _typeOptions = new[] { "全部" }; + _languageOptions = new[] { "全部" }; + } + else + { + var types = _lastResult.Issues + .Select(issue => issue.IssueTypeLabel) + .Where(label => !string.IsNullOrEmpty(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) + .ToList(); + types.Insert(0, "全部"); + _typeOptions = types.ToArray(); + + var languages = _lastResult.Issues + .Select(issue => issue.Language) + .Where(language => !string.IsNullOrEmpty(language)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(language => language, StringComparer.OrdinalIgnoreCase) + .ToList(); + languages.Insert(0, "全部"); + _languageOptions = languages.ToArray(); + } + + _filterTypeIndex = ClampFilterIndex(_filterType, _typeOptions, ref _filterType); + _filterLanguageIndex = ClampFilterIndex(_filterLanguage, _languageOptions, ref _filterLanguage); + } + + private static int ClampFilterIndex(string currentValue, string[] options, ref string selectedValue) + { + if (options.Length == 0) + { + selectedValue = "全部"; + return 0; + } + + for (var i = 0; i < options.Length; i++) + { + if (string.Equals(options[i], currentValue, StringComparison.OrdinalIgnoreCase)) + { + selectedValue = options[i]; + return i; + } + } + + selectedValue = options[0]; + return 0; + } + + private void ExportReport() + { + var defaultName = YarnL10nReportExporter.BuildDefaultExportFileName(_folderPath); + var savePath = EditorUtility.SaveFilePanel("导出校验报告", Application.dataPath, defaultName, "csv"); + if (string.IsNullOrEmpty(savePath)) + { + return; + } + + YarnL10nReportExporter.ExportToCsv(_lastResult, savePath); + EditorUtility.RevealInFinder(savePath); + } + + private void PingFolder() + { + SyncFolderAssetFromPath(); + if (_folderAsset == null) + { + EditorUtility.DisplayDialog("Yarn 本地化校验", "无法定位文件夹,请检查路径。", "确定"); + return; + } + + EditorGUIUtility.PingObject(_folderAsset); + Selection.activeObject = _folderAsset; + } + + private void DrawSummary() + { + EditorGUILayout.LabelField("摘要", EditorStyles.boldLabel); + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + if (_lastResult == null) + { + EditorGUILayout.LabelField("尚未执行校验。"); + return; + } + + EditorGUILayout.LabelField($"Issue 总数: {_lastResult.TotalIssueCount}"); + EditorGUILayout.LabelField($"章节数: {_lastResult.Chapters.Count}"); + + foreach (var chapter in _lastResult.Chapters) + { + var localeParts = chapter.Locales.Values + .Select(locale => $"{locale.Language}: 缺 {locale.MissingCount} / 多余 {locale.OrphanCount} / 变更 {locale.SourceChangedCount}") + .ToArray(); + var localeSummary = localeParts.Length > 0 ? string.Join(" | ", localeParts) : "无翻译语言"; + EditorGUILayout.LabelField($"{chapter.ChapterFolder} — 原文 {chapter.SourceLineCount} 行 | {localeSummary}"); + } + } + + EditorGUILayout.Space(6); + } + + private void DrawFilters() + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("类型", GUILayout.Width(40)); + _filterTypeIndex = EditorGUILayout.Popup(_filterTypeIndex, _typeOptions, GUILayout.Width(180)); + _filterType = _typeOptions[_filterTypeIndex]; + + EditorGUILayout.LabelField("语言", GUILayout.Width(40)); + _filterLanguageIndex = EditorGUILayout.Popup(_filterLanguageIndex, _languageOptions, GUILayout.Width(100)); + _filterLanguage = _languageOptions[_filterLanguageIndex]; + + EditorGUILayout.LabelField("搜索", GUILayout.Width(40)); + _searchText = EditorGUILayout.TextField(_searchText); + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(4); + } + + private void DrawIssueList() + { + EditorGUILayout.LabelField("结果列表", EditorStyles.boldLabel); + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true)); + + if (_lastResult == null || _lastResult.Issues.Count == 0) + { + EditorGUILayout.LabelField(_lastResult == null ? "无结果。" : "未发现 issue。"); + } + else + { + var index = 0; + foreach (var issue in FilterIssues(_lastResult.Issues)) + { + DrawIssueRow(issue, index); + index++; + } + } + + EditorGUILayout.EndScrollView(); + } + + private IEnumerable FilterIssues(IEnumerable issues) + { + foreach (var issue in issues) + { + if (!MatchesFilter(issue)) + { + continue; + } + + yield return issue; + } + } + + private bool MatchesFilter(YarnL10nIssue issue) + { + if (!string.IsNullOrWhiteSpace(_filterType) && + !string.Equals(_filterType, "全部", StringComparison.OrdinalIgnoreCase) && + !string.Equals(issue.IssueTypeLabel, _filterType, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(_filterLanguage) && + !string.Equals(_filterLanguage, "全部", StringComparison.OrdinalIgnoreCase) && + !string.Equals(issue.Language, _filterLanguage, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(_searchText)) + { + return true; + } + + var haystack = string.Join(" ", + issue.GetDisplayType(), + issue.Language, + issue.LineId, + issue.SourceText, + issue.LocText, + issue.YarnFile, + issue.ChapterFolder, + issue.Message); + + return haystack.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private void DrawIssueRow(YarnL10nIssue issue, int index) + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField($"{issue.GetDisplayType()} | {issue.Language} | {issue.LineId}", EditorStyles.boldLabel); + if (!string.IsNullOrEmpty(issue.ChapterFolder)) + { + EditorGUILayout.LabelField("章节", issue.ChapterFolder); + } + + if (!string.IsNullOrEmpty(issue.YarnFile)) + { + EditorGUILayout.LabelField("源文件", $"{issue.YarnFile}:{issue.YarnLineNumber}"); + } + + if (!string.IsNullOrEmpty(issue.CsvFile)) + { + EditorGUILayout.LabelField("CSV", issue.CsvFile); + } + + if (!string.IsNullOrEmpty(issue.SourceText)) + { + EditorGUILayout.LabelField("原文", issue.SourceText); + } + + if (!string.IsNullOrEmpty(issue.LocText)) + { + EditorGUILayout.LabelField("翻译", issue.LocText); + } + + if (!string.IsNullOrEmpty(issue.Message)) + { + EditorGUILayout.LabelField("说明", issue.Message); + } + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("定位", GUILayout.Width(80))) + { + PingIssue(issue); + } + + EditorGUILayout.EndHorizontal(); + } + } + + private void PingIssue(YarnL10nIssue issue) + { + if (issue.Type == YarnL10nIssueType.OrphanLocalization && !string.IsNullOrEmpty(issue.CsvFile)) + { + var csvAsset = AssetDatabase.LoadAssetAtPath(issue.CsvFile); + if (csvAsset != null) + { + EditorGUIUtility.PingObject(csvAsset); + Selection.activeObject = csvAsset; + return; + } + } + + if (!string.IsNullOrEmpty(issue.YarnFile)) + { + var yarnPath = ResolveYarnAssetPath(issue); + var yarnAsset = AssetDatabase.LoadAssetAtPath(yarnPath); + if (yarnAsset != null) + { + EditorGUIUtility.PingObject(yarnAsset); + Selection.activeObject = yarnAsset; + + if (int.TryParse(issue.YarnLineNumber, out var lineNumber) && lineNumber > 0) + { + var absolutePath = YarnL10nValidator.ToAbsolutePath(yarnPath); + InternalEditorUtility.OpenFileAtLineExternal(absolutePath, lineNumber); + } + + return; + } + } + + if (!string.IsNullOrEmpty(issue.ChapterFolder)) + { + var folderAsset = AssetDatabase.LoadAssetAtPath(issue.ChapterFolder); + if (folderAsset != null) + { + EditorGUIUtility.PingObject(folderAsset); + Selection.activeObject = folderAsset; + } + } + } + + private static string ResolveYarnAssetPath(YarnL10nIssue issue) + { + if (issue.YarnFile.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return issue.YarnFile; + } + + if (!string.IsNullOrEmpty(issue.ChapterFolder)) + { + return $"{issue.ChapterFolder}/{issue.YarnFile}"; + } + + return issue.YarnFile; + } + } +} diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs.meta b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs.meta new file mode 100644 index 000000000..2928a3113 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fe514a9bb460dd74e8b0ec7e1ae7cf98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs new file mode 100644 index 000000000..e9945ca97 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Yarn.Compiler; +using Yarn.Unity; +using Yarn.Unity.Editor; + +namespace AibisDream.YarnLocalizationValidation.Editor +{ + public static class YarnL10nValidator + { + public static YarnL10nValidationResult ValidateFolder(string folderPath) + { + var result = new YarnL10nValidationResult(); + if (string.IsNullOrWhiteSpace(folderPath)) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: folderPath, + message: "章节文件夹路径为空。")); + FinalizeResult(result); + return result; + } + + var normalizedFolder = NormalizeProjectRelativePath(folderPath); + var absoluteFolder = ToAbsolutePath(normalizedFolder); + if (!Directory.Exists(absoluteFolder)) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: normalizedFolder, + message: $"文件夹不存在: {normalizedFolder}")); + FinalizeResult(result); + return result; + } + + var yarnProjects = Directory.GetFiles(absoluteFolder, "*.yarnproject", SearchOption.TopDirectoryOnly); + if (yarnProjects.Length == 0) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: normalizedFolder, + message: "文件夹内未找到 .yarnproject 文件。")); + FinalizeResult(result); + return result; + } + + if (yarnProjects.Length > 1) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: normalizedFolder, + message: $"文件夹内存在多个 .yarnproject({yarnProjects.Length} 个)。")); + FinalizeResult(result); + return result; + } + + ValidateYarnProjectFile(yarnProjects[0], result); + FinalizeResult(result); + return result; + } + + public static YarnL10nValidationResult ValidateScanAll(string rootPath) + { + var result = new YarnL10nValidationResult(); + var normalizedRoot = NormalizeProjectRelativePath(rootPath); + var absoluteRoot = ToAbsolutePath(normalizedRoot); + if (!Directory.Exists(absoluteRoot)) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: normalizedRoot, + message: $"扫描根目录不存在: {normalizedRoot}")); + FinalizeResult(result); + return result; + } + + var yarnProjectFiles = Directory.GetFiles(absoluteRoot, "*.yarnproject", SearchOption.AllDirectories); + if (yarnProjectFiles.Length == 0) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: normalizedRoot, + message: "扫描范围内未找到任何 .yarnproject。")); + FinalizeResult(result); + return result; + } + + foreach (var yarnProjectFile in yarnProjectFiles.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + ValidateYarnProjectFile(yarnProjectFile, result); + } + + FinalizeResult(result); + return result; + } + + private static void ValidateYarnProjectFile(string yarnProjectAbsolutePath, YarnL10nValidationResult result) + { + var chapterFolder = NormalizeProjectRelativePath(Path.GetDirectoryName(yarnProjectAbsolutePath)); + var yarnProjectRelativePath = NormalizeProjectRelativePath(yarnProjectAbsolutePath); + + Project project; + try + { + project = Project.LoadFromFile(yarnProjectAbsolutePath); + } + catch (Exception ex) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: chapterFolder, + message: $"无法解析 .yarnproject: {ex.Message}")); + return; + } + + var chapterSummary = new YarnL10nChapterSummary + { + ChapterFolder = chapterFolder, + YarnProjectPath = yarnProjectRelativePath, + }; + var issueStartIndex = result.Issues.Count; + + if (project.Localisation == null || project.Localisation.Count == 0) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.NoLocalizationConfigured, + chapterFolder: chapterFolder, + message: "yarnproject 未配置 localisation。")); + result.Chapters.Add(chapterSummary); + return; + } + + var sourceFiles = project.SourceFiles?.ToList() ?? new List(); + if (sourceFiles.Count == 0) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: chapterFolder, + message: "yarnproject 未包含任何 .yarn 源文件。")); + result.Chapters.Add(chapterSummary); + return; + } + + var job = CompilationJob.CreateFromFiles(sourceFiles); + job.CompilationType = CompilationJob.Type.StringsOnly; + var compilationResult = Compiler.Compile(job); + + foreach (var diagnostic in compilationResult.Diagnostics.Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error)) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.CompileError, + chapterFolder: chapterFolder, + message: diagnostic.Message)); + } + + if (compilationResult.ContainsErrors) + { + result.Chapters.Add(chapterSummary); + return; + } + + if (compilationResult.ContainsImplicitStringTags) + { + foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.isImplicitTag)) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.UntaggedLine, + chapterFolder: chapterFolder, + lineId: pair.Key, + yarnFile: pair.Value.fileName, + yarnLineNumber: pair.Value.lineNumber.ToString(), + message: "存在未打 #line: 标签的可本地化行。")); + } + } + + var baseEntries = BuildBaseEntries(project, compilationResult); + chapterSummary.SourceLineCount = baseEntries.Count; + + foreach (var localePair in project.Localisation) + { + var language = localePair.Key; + if (string.Equals(language, project.BaseLanguage, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.IsNullOrEmpty(localePair.Value.Strings)) + { + continue; + } + + if (localePair.Value.Strings.StartsWith("unity:", StringComparison.Ordinal)) + { + continue; + } + + var localeSummary = new YarnL10nLocaleSummary { Language = language }; + chapterSummary.Locales[language] = localeSummary; + + if (!project.TryGetStringsPath(language, out var csvRelativePath) || + string.IsNullOrEmpty(csvRelativePath)) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.CsvMissing, + chapterFolder: chapterFolder, + language: language, + message: $"未找到语言 {language} 的 CSV 路径。")); + continue; + } + + var csvAbsolutePath = ToAbsolutePath(csvRelativePath); + if (!File.Exists(csvAbsolutePath)) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.CsvMissing, + chapterFolder: chapterFolder, + language: language, + csvFile: csvRelativePath, + message: $"本地化 CSV 不存在: {csvRelativePath}")); + continue; + } + + IEnumerable translatedEntries; + try + { + translatedEntries = StringTableEntry.ParseFromCSV(File.ReadAllText(csvAbsolutePath)); + } + catch (Exception ex) + { + AddIssue(result, CreateIssue( + YarnL10nIssueType.InvalidProject, + chapterFolder: chapterFolder, + language: language, + csvFile: csvRelativePath, + message: $"无法解析 CSV: {ex.Message}")); + continue; + } + + var translatedDictionary = translatedEntries + .Where(entry => !string.IsNullOrEmpty(entry.ID)) + .GroupBy(entry => entry.ID) + .ToDictionary(group => group.Key, group => group.First()); + + var baseIds = baseEntries.Keys.ToHashSet(); + var translatedIds = translatedDictionary.Keys.ToHashSet(); + + foreach (var missingId in baseIds.Except(translatedIds).OrderBy(id => id, StringComparer.Ordinal)) + { + var baseEntry = baseEntries[missingId]; + localeSummary.MissingCount++; + AddIssue(result, CreateIssue( + YarnL10nIssueType.MissingLocalization, + chapterFolder: chapterFolder, + language: language, + lineId: missingId, + sourceText: baseEntry.Text, + yarnFile: baseEntry.File, + yarnLineNumber: baseEntry.LineNumber)); + } + + foreach (var orphanId in translatedIds.Except(baseIds).OrderBy(id => id, StringComparer.Ordinal)) + { + var translatedEntry = translatedDictionary[orphanId]; + localeSummary.OrphanCount++; + AddIssue(result, CreateIssue( + YarnL10nIssueType.OrphanLocalization, + chapterFolder: chapterFolder, + language: language, + lineId: orphanId, + locText: translatedEntry.Text, + csvFile: csvRelativePath)); + } + + foreach (var sharedId in baseIds.Intersect(translatedIds)) + { + var baseEntry = baseEntries[sharedId]; + var translatedEntry = translatedDictionary[sharedId]; + if (string.IsNullOrEmpty(baseEntry.Lock) || string.IsNullOrEmpty(translatedEntry.Lock)) + { + continue; + } + + if (baseEntry.Lock == translatedEntry.Lock) + { + continue; + } + + localeSummary.SourceChangedCount++; + AddIssue(result, CreateIssue( + YarnL10nIssueType.SourceTextChanged, + chapterFolder: chapterFolder, + language: language, + lineId: sharedId, + sourceText: baseEntry.Text, + locText: translatedEntry.Text, + yarnFile: baseEntry.File, + yarnLineNumber: baseEntry.LineNumber, + csvFile: csvRelativePath, + message: "原文 Lock 与翻译 CSV 不一致,翻译可能已过期。")); + } + } + + chapterSummary.IssueCount = result.Issues.Count - issueStartIndex; + result.Chapters.Add(chapterSummary); + } + + private static Dictionary BuildBaseEntries(Project project, CompilationResult compilationResult) + { + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.text != null)) + { + var entry = new StringTableEntry + { + ID = pair.Key, + Language = project.BaseLanguage ?? "zh-Hans", + Text = pair.Value.text, + File = pair.Value.fileName, + Node = pair.Value.nodeName, + LineNumber = pair.Value.lineNumber.ToString(), + Lock = YarnImporter.GetHashString(pair.Value.text!, 8), + }; + entries[pair.Key] = entry; + } + + return entries; + } + + private static YarnL10nIssue CreateIssue( + YarnL10nIssueType type, + string chapterFolder = "", + string language = "", + string lineId = "", + string sourceText = "", + string locText = "", + string yarnFile = "", + string yarnLineNumber = "", + string csvFile = "", + string message = "") + { + return new YarnL10nIssue + { + Type = type, + ChapterFolder = chapterFolder ?? string.Empty, + Language = language ?? string.Empty, + LineId = lineId ?? string.Empty, + SourceText = sourceText ?? string.Empty, + LocText = locText ?? string.Empty, + YarnFile = yarnFile ?? string.Empty, + YarnLineNumber = yarnLineNumber ?? string.Empty, + CsvFile = csvFile ?? string.Empty, + Message = message ?? string.Empty, + }; + } + + private static void AddIssue(YarnL10nValidationResult result, YarnL10nIssue issue) + { + result.Issues.Add(issue); + } + + private static void FinalizeResult(YarnL10nValidationResult result) + { + result.TotalIssueCount = result.Issues.Count; + } + + public static string NormalizeProjectRelativePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + var normalized = path.Replace('\\', '/').Trim(); + var projectRoot = GetProjectRootPath().Replace('\\', '/'); + if (Path.IsPathRooted(normalized)) + { + var absolute = Path.GetFullPath(normalized).Replace('\\', '/'); + if (absolute.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)) + { + normalized = absolute.Substring(projectRoot.Length).TrimStart('/'); + } + else + { + normalized = absolute; + } + } + + return normalized.TrimEnd('/'); + } + + public static string ToAbsolutePath(string projectRelativeOrAbsolutePath) + { + if (string.IsNullOrWhiteSpace(projectRelativeOrAbsolutePath)) + { + return GetProjectRootPath(); + } + + if (Path.IsPathRooted(projectRelativeOrAbsolutePath)) + { + return Path.GetFullPath(projectRelativeOrAbsolutePath); + } + + return Path.GetFullPath(Path.Combine(GetProjectRootPath(), projectRelativeOrAbsolutePath)); + } + + private static string GetProjectRootPath() + { + return Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, "..")); + } + } +} diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs.meta b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs.meta new file mode 100644 index 000000000..02b2803f3 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea78060f3c3448d4aa9b19b8b55fb843 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs new file mode 100644 index 000000000..a20abc460 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs @@ -0,0 +1,116 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.YarnLocalizationValidation.Editor +{ + public static class YarnL10nValidatorCli + { + private const string JsonBeginMarker = "YARN_L10N_JSON_BEGIN"; + private const string JsonEndMarker = "YARN_L10N_JSON_END"; + + public static void Run() + { + try + { + var args = Environment.GetCommandLineArgs(); + var path = GetArgumentValue(args, "-yarnL10nPath"); + var scanAll = HasFlag(args, "-yarnL10nScanAll"); + var exportPath = GetArgumentValue(args, "-yarnL10nExport"); + var outputPath = GetArgumentValue(args, "-yarnL10nOutput"); + if (string.IsNullOrWhiteSpace(outputPath)) + { + outputPath = "Build/yarn_l10n_result.json"; + } + + if (string.IsNullOrWhiteSpace(path)) + { + WriteFailure("缺少参数 -yarnL10nPath。", 2); + return; + } + + var validationResult = scanAll + ? YarnL10nValidator.ValidateScanAll(path) + : YarnL10nValidator.ValidateFolder(path); + + var json = JsonConvert.SerializeObject(validationResult, Formatting.Indented); + WriteJson(json, outputPath); + + if (!string.IsNullOrWhiteSpace(exportPath)) + { + var absoluteExportPath = YarnL10nValidator.ToAbsolutePath(exportPath); + YarnL10nReportExporter.ExportToCsv(validationResult, absoluteExportPath); + } + + var exitCode = validationResult.HasBlockingIssues ? 1 : 0; + Debug.Log($"[YarnL10nValidatorCli] Completed with exit code {exitCode}, issues={validationResult.TotalIssueCount}"); + EditorApplication.Exit(exitCode); + } + catch (Exception ex) + { + WriteFailure(ex.ToString(), 3); + } + } + + private static void WriteJson(string json, string outputPath) + { + Console.WriteLine(JsonBeginMarker); + Console.WriteLine(json); + Console.WriteLine(JsonEndMarker); + + if (!string.IsNullOrWhiteSpace(outputPath)) + { + var absoluteOutputPath = YarnL10nValidator.ToAbsolutePath(outputPath); + var directory = Path.GetDirectoryName(absoluteOutputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(absoluteOutputPath, json, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + Debug.Log($"[YarnL10nValidatorCli] Wrote JSON to {absoluteOutputPath}"); + } + } + + private static void WriteFailure(string message, int exitCode) + { + Debug.LogError($"[YarnL10nValidatorCli] {message}"); + Console.Error.WriteLine(message); + EditorApplication.Exit(exitCode); + } + + private static bool HasFlag(string[] args, string flag) + { + foreach (var arg in args) + { + if (string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string GetArgumentValue(string[] args, string key) + { + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase)) + { + return arg.Substring(key.Length + 1).Trim('"'); + } + + if (string.Equals(arg, key, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + return args[i + 1].Trim('"'); + } + } + + return string.Empty; + } + } +} diff --git a/Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs.meta b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs.meta new file mode 100644 index 000000000..31e2f9bb5 --- /dev/null +++ b/Assets/Editor/YarnLocalizationValidation/YarnL10nValidatorCli.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 23836ef03028055439615fda97642c4e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Docs/本地化校验工具.md b/Docs/本地化校验工具.md new file mode 100644 index 000000000..a2577b386 --- /dev/null +++ b/Docs/本地化校验工具.md @@ -0,0 +1,94 @@ +# Yarn 本地化校验工具 + +校验 Yarn 章节文件夹内 **原文(编译器 string table)** 与各语言翻译 CSV 的 line id 是否一致。 + +## 背景 + +典型 Yarn 章节文件夹结构: + +``` +{章节名}/ +├── {章节名}.yarnproject +├── {脚本}.yarn ← 中文原文 + #line:xxxxxxx +├── {章节名}-en.csv ← 英语 +└── {章节名}-ja.csv ← 日语(注意:实际文件名为 -ja,不是 -jp) +``` + +编剧修改 `.yarn` 后,翻译 CSV 容易不同步。本工具报告: + +- **缺少本地化-{语言}**:原文 id 存在,CSV 中没有 +- **找不到原文-{语言}**:CSV 中有 id,原文已不存在 +- **原文已变更-{语言}**(可选):Lock 不一致,翻译可能过期 + +原文 id 来自 **Yarn Spinner 编译器**(与 Inspector「Export Strings and Metadata as CSV」同源),不是手工正则扫 `.yarn`。 + +参考样例:`Assets/Yarn/FP/FP_Day1_mid` + +## Unity Editor 窗口 + +菜单:**AIBIS → Yarn 本地化校验** + +1. 选择章节文件夹(如 `Assets/Yarn/FP/FP_Day1_mid`) +2. 点击 **校验** +3. 可选 **导出 CSV** 报告 +4. 勾选 **扫描子目录** 可批量校验 `Assets/Yarn/` 下所有 `.yarnproject` + +## 命令行 / CI + +Python 脚本 **不独立实现校验**,通过 Unity BatchMode 调用同一套 Editor 逻辑。 + +```bash +# 校验单个章节 +python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid + +# 扫描 Assets/Yarn 下所有章节 +python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all + +# 导出 CSV +python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid --export report.csv + +# CI:存在 blocking issue 时非零退出 +python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all --fail-on-issues +``` + +**环境要求** + +- Unity 2022.3.7f1c1(或设置环境变量 `UNITY_EDITOR_PATH` 指向 `Unity.exe`) +- 首次 BatchMode 可能较慢(导入/编译) +- JSON 结果写入 `Build/yarn_l10n_result.json`,日志在 `Build/yarn_l10n_validate.log` + +**直接调用 Unity(可选)** + +```bash +"C:\Program Files\Unity\Hub\Editor\2022.3.7f1c1\Editor\Unity.exe" ^ + -batchmode -nographics -quit ^ + -projectPath "D:\UnityProject\aibis-dream" ^ + -executeMethod AibisDream.YarnLocalizationValidation.Editor.YarnL10nValidatorCli.Run ^ + -yarnL10nPath "Assets/Yarn/FP/FP_Day1_mid" ^ + -yarnL10nOutput "Build/yarn_l10n_result.json" ^ + -logFile "Build/yarn_l10n_validate.log" +``` + +扫描全部章节时追加 `-yarnL10nScanAll`。 + +## 实现位置 + +``` +Assets/Editor/YarnLocalizationValidation/ +├── YarnL10nIssue.cs +├── YarnL10nValidator.cs +├── YarnL10nValidatorCli.cs +├── YarnL10nValidationWindow.cs +└── YarnL10nReportExporter.cs + +Tools/validate_yarn_l10n.py +``` + +## 与 Yarn Inspector 内置功能的区别 + +| 能力 | Inspector「Update Existing Strings Files」 | 本工具 | +|------|---------------------------------------------|--------| +| 检测缺少/多余 id | 有 | 有 | +| 检测 Lock 变更 | 有(并写回 CSV) | 有(只读报告) | +| 写回 CSV | **会** | **不会** | +| 批量扫描 / 统一报告 | 无 | 有 | diff --git a/Tools/validate_yarn_l10n.py b/Tools/validate_yarn_l10n.py new file mode 100644 index 000000000..d6876bc8a --- /dev/null +++ b/Tools/validate_yarn_l10n.py @@ -0,0 +1,212 @@ +#!/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())