- 新增 Editor 窗口:AIBIS → Yarn 本地化校验 - 新增 BatchMode CLI:Tools/validate_yarn_l10n.py - 新增文档:Docs/本地化校验工具.md - 校验 Yarn 原文 string table 与各语言 CSV 的 line id 一致性
413 lines
16 KiB
C#
413 lines
16 KiB
C#
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, ".."));
|
||
}
|
||
}
|
||
}
|