| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- using System.Text;
- using System.Text.Json;
- using System.Text.Json.Nodes;
- using System.Text.RegularExpressions;
- // ============================================================================
- // 多语言原文提取器:扫描源码中所有 Translate 调用点,把"中文原文"合并进
- // Localization\zh-CN.json,保证界面上用到的文本不会漏配翻译。
- //
- // 扫描的调用点:
- // 1. XAML 标记扩展 {loc:Translate '原文'} / {loc:Translate "原文"} / Key='原文'
- // 2. C# 方法调用 xxx.Translate("原文") / xxx.Translate("原文", "ctx")
- // (含 @"..." 逐字字符串)
- //
- // 合并策略(只增不删,防止误删运行时动态文本):
- // - 新原文 → 追加进 translations(value = 原文)
- // - 已有原文 → 保留不动
- // - 疑似废弃 → 保留,仅打印提醒(人工到 LocalizationTool 里确认后再删)
- //
- // 另外校验其他语言包(en-US.json 等)相对 zh-CN 缺失的 key,打印清单供翻译。
- // ============================================================================
- var root = args.Length > 0 ? Path.GetFullPath(args[0]) : FindRepoRoot();
- if (!Directory.Exists(root))
- {
- Console.Error.WriteLine($"[LocalizationExtractor] 仓库根目录不存在: {root}");
- return 1;
- }
- // 语言包母本:ExE\win-x64\Runtime\Localization(程序运行时加载的目录,翻译在此维护)。
- // 仓库根 Localization\ 仅是历史快照,仅作兜底。
- var localizationDir = Path.Combine(root, "ExE", "win-x64", "Runtime", "Localization");
- if (!Directory.Exists(localizationDir))
- localizationDir = Path.Combine(root, "Localization");
- var zhFile = Path.Combine(localizationDir, "zh-CN.json");
- if (!File.Exists(zhFile))
- {
- Console.Error.WriteLine($"[LocalizationExtractor] 找不到 {zhFile}");
- return 1;
- }
- // 默认扫描除工具/输出目录外的所有源码工程目录
- var excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
- { "bin", "obj", ".vs", "packages", "lib", "ExE", "EXE", "Tools", ".git" };
- var scanDirs = Directory.EnumerateDirectories(root)
- .Where(d => !excluded.Contains(Path.GetFileName(d)))
- .ToList();
- var originals = new SortedSet<string>(StringComparer.Ordinal);
- foreach (var dir in scanDirs)
- {
- foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
- {
- var ext = Path.GetExtension(file).ToLowerInvariant();
- if (ext != ".xaml" && ext != ".cs") continue;
- if (IsUnderExcludedDir(file)) continue;
- string content;
- try { content = File.ReadAllText(file); }
- catch { continue; } // 文件被占用等,跳过即可
- if (ext == ".xaml") ExtractXaml(content, originals);
- else ExtractCSharp(content, originals);
- }
- }
- bool IsUnderExcludedDir(string file)
- {
- var rel = Path.GetRelativePath(root, file);
- return rel.Split(Path.DirectorySeparatorChar)
- .Any(seg => excluded.Contains(seg));
- }
- // ----------------------------------------------------------------------------
- // 读取 zh-CN.json 并合并
- // ----------------------------------------------------------------------------
- var jsonOptions = new JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
- };
- var node = JsonNode.Parse(File.ReadAllText(zhFile))!.AsObject();
- var translations = node["translations"]!.AsObject();
- var added = new List<string>();
- foreach (var text in originals)
- if (!translations.ContainsKey(text))
- {
- translations[text] = text; // 中文包 value = 原文
- added.Add(text);
- }
- var removedCandidates = translations.Select(t => t.Key).Where(k => !originals.Contains(k)).ToList();
- if (added.Count > 0)
- File.WriteAllText(zhFile, node.ToJsonString(jsonOptions), new UTF8Encoding(false));
- // ----------------------------------------------------------------------------
- // 其他语言包缺失校验(只报告,不改写——翻译走 LocalizationTool / AI 流程)
- // ----------------------------------------------------------------------------
- var zhKeys = translations.Select(t => t.Key).ToHashSet(StringComparer.Ordinal);
- var missingReport = new StringBuilder();
- foreach (var packFile in Directory.EnumerateFiles(localizationDir, "*.json"))
- {
- var name = Path.GetFileName(packFile);
- if (name.Equals("zh-CN.json", StringComparison.OrdinalIgnoreCase)) continue;
- JsonObject pack;
- try { pack = JsonNode.Parse(File.ReadAllText(packFile))!["translations"]!.AsObject(); }
- catch { continue; }
- var packKeys = pack.Select(p => p.Key).ToHashSet(StringComparer.Ordinal);
- var missing = zhKeys.Where(k => !packKeys.Contains(k)).ToList();
- if (missing.Count > 0)
- missingReport.AppendLine($" {name} 缺失 {missing.Count} 条: {string.Join(" | ", missing.Take(10))}{(missing.Count > 10 ? " ..." : "")}");
- }
- // ----------------------------------------------------------------------------
- // 汇总输出
- // ----------------------------------------------------------------------------
- Console.WriteLine($"[LocalizationExtractor] 扫描 {scanDirs.Count} 个工程目录,共提取原文 {originals.Count} 条");
- if (added.Count > 0)
- {
- Console.WriteLine($"[LocalizationExtractor] zh-CN.json 新增 {added.Count} 条");
- }
- if (removedCandidates.Count > 0)
- {
- Console.WriteLine($"[LocalizationExtractor] 提示: {removedCandidates.Count} 条原文未在源码中找到(已保留,确认废弃请手动删除):");
- foreach (var k in removedCandidates.Take(10))
- Console.WriteLine($" - {k}");
- if (removedCandidates.Count > 10) Console.WriteLine(" ...");
- }
- if (missingReport.Length > 0)
- {
- Console.WriteLine("[LocalizationExtractor] 其他语言包缺失词条:");
- Console.Write(missingReport);
- }
- return 0;
- // ----------------------------------------------------------------------------
- // 提取逻辑
- // ----------------------------------------------------------------------------
- static void ExtractXaml(string content, SortedSet<string> into)
- {
- // {loc:Translate '原文'} 或 {loc:Translate "原文"}
- foreach (Match m in Regex.Matches(content, @"loc:Translate\s+(['""])(?<text>(?:(?!\1).)*)\1"))
- into.Add(UnescapeXml(m.Groups["text"].Value));
- // {loc:Translate Key='原文'} 属性写法
- foreach (Match m in Regex.Matches(content, @"loc:Translate\s+Key=(['""])(?<text>(?:(?!\1).)*)\1"))
- into.Add(UnescapeXml(m.Groups["text"].Value));
- }
- static void ExtractCSharp(string content, SortedSet<string> into)
- {
- // 普通字符串:Translate("原文"...(含 xxx.Translate 与裸调用)
- foreach (Match m in Regex.Matches(content, @"(?:^|[^.\w])Translate\(\s*""(?<text>(?:[^""\\]|\\.)*)"""))
- into.Add(UnescapeCSharp(m.Groups["text"].Value));
- // 逐字字符串:Translate(@"原文...
- foreach (Match m in Regex.Matches(content, @"(?:^|[^.\w])Translate\(\s*@""(?<text>(?:[^""]|"""")*)"""))
- into.Add(m.Groups["text"].Value.Replace("\"\"", "\""));
- // L10n.T("原文") / L10n.F("模板{0}"...) — C# 界面文案入口
- foreach (Match m in Regex.Matches(content, @"L10n\.(?:T|F)\(\s*""(?<text>(?:[^""\\]|\\.)*)"""))
- into.Add(UnescapeCSharp(m.Groups["text"].Value));
- foreach (Match m in Regex.Matches(content, @"L10n\.(?:T|F)\(\s*@""(?<text>(?:[^""]|"""")*)"""))
- into.Add(m.Groups["text"].Value.Replace("\"\"", "\""));
- }
- static string UnescapeXml(string s) => s
- .Replace("<", "<").Replace(">", ">")
- .Replace(""", "\"").Replace("'", "'")
- .Replace("&", "&");
- static string UnescapeCSharp(string s) => Regex.Replace(s, @"\\(?:u(?<u>[0-9a-fA-F]{4})|x(?<x>[0-9a-fA-F]{2,4})|(?<c>.))",
- m =>
- {
- if (m.Groups["u"].Success) return ((char)Convert.ToInt32(m.Groups["u"].Value, 16)).ToString();
- if (m.Groups["x"].Success) return ((char)Convert.ToInt32(m.Groups["x"].Value, 16)).ToString();
- return m.Groups["c"].Value switch
- {
- "n" => "\n", "t" => "\t", "r" => "\r", "0" => "\0",
- "a" => "\a", "b" => "\b", "f" => "\f", "v" => "\v",
- var c => c, // \\ \" \' 等
- };
- });
- static string FindRepoRoot()
- {
- // 从当前目录向上找包含 TeamAAS.sln 的目录
- var dir = new DirectoryInfo(Environment.CurrentDirectory);
- while (dir != null && !File.Exists(Path.Combine(dir.FullName, "TeamAAS.sln")))
- dir = dir.Parent;
- return dir?.FullName ?? Environment.CurrentDirectory;
- }
|