Files
aibis-dream/Assets/Editor/YarnLocalizationValidation/YarnL10nValidator.cs
T
dingyuntian 7d7c8893fc feat(editor): 添加 Yarn 本地化校验工具
- 新增 Editor 窗口:AIBIS → Yarn 本地化校验
- 新增 BatchMode CLI:Tools/validate_yarn_l10n.py
- 新增文档:Docs/本地化校验工具.md
- 校验 Yarn 原文 string table 与各语言 CSV 的 line id 一致性
2026-07-04 23:12:21 +08:00

413 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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<StringTableEntry> 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<string, StringTableEntry> BuildBaseEntries(Project project, CompilationResult compilationResult)
{
var entries = new Dictionary<string, StringTableEntry>(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, ".."));
}
}
}