Program.cs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. using System.Text;
  2. using System.Text.Json;
  3. using System.Text.Json.Nodes;
  4. using System.Text.RegularExpressions;
  5. // ============================================================================
  6. // 多语言原文提取器:扫描源码中所有 Translate 调用点,把"中文原文"合并进
  7. // Localization\zh-CN.json,保证界面上用到的文本不会漏配翻译。
  8. //
  9. // 扫描的调用点:
  10. // 1. XAML 标记扩展 {loc:Translate '原文'} / {loc:Translate "原文"} / Key='原文'
  11. // 2. C# 方法调用 xxx.Translate("原文") / xxx.Translate("原文", "ctx")
  12. // (含 @"..." 逐字字符串)
  13. //
  14. // 合并策略(只增不删,防止误删运行时动态文本):
  15. // - 新原文 → 追加进 translations(value = 原文)
  16. // - 已有原文 → 保留不动
  17. // - 疑似废弃 → 保留,仅打印提醒(人工到 LocalizationTool 里确认后再删)
  18. //
  19. // 另外校验其他语言包(en-US.json 等)相对 zh-CN 缺失的 key,打印清单供翻译。
  20. // ============================================================================
  21. var root = args.Length > 0 ? Path.GetFullPath(args[0]) : FindRepoRoot();
  22. if (!Directory.Exists(root))
  23. {
  24. Console.Error.WriteLine($"[LocalizationExtractor] 仓库根目录不存在: {root}");
  25. return 1;
  26. }
  27. // 语言包母本:ExE\win-x64\Runtime\Localization(程序运行时加载的目录,翻译在此维护)。
  28. // 仓库根 Localization\ 仅是历史快照,仅作兜底。
  29. var localizationDir = Path.Combine(root, "ExE", "win-x64", "Runtime", "Localization");
  30. if (!Directory.Exists(localizationDir))
  31. localizationDir = Path.Combine(root, "Localization");
  32. var zhFile = Path.Combine(localizationDir, "zh-CN.json");
  33. if (!File.Exists(zhFile))
  34. {
  35. Console.Error.WriteLine($"[LocalizationExtractor] 找不到 {zhFile}");
  36. return 1;
  37. }
  38. // 默认扫描除工具/输出目录外的所有源码工程目录
  39. var excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
  40. { "bin", "obj", ".vs", "packages", "lib", "ExE", "EXE", "Tools", ".git" };
  41. var scanDirs = Directory.EnumerateDirectories(root)
  42. .Where(d => !excluded.Contains(Path.GetFileName(d)))
  43. .ToList();
  44. var originals = new SortedSet<string>(StringComparer.Ordinal);
  45. foreach (var dir in scanDirs)
  46. {
  47. foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
  48. {
  49. var ext = Path.GetExtension(file).ToLowerInvariant();
  50. if (ext != ".xaml" && ext != ".cs") continue;
  51. if (IsUnderExcludedDir(file)) continue;
  52. string content;
  53. try { content = File.ReadAllText(file); }
  54. catch { continue; } // 文件被占用等,跳过即可
  55. if (ext == ".xaml") ExtractXaml(content, originals);
  56. else ExtractCSharp(content, originals);
  57. }
  58. }
  59. bool IsUnderExcludedDir(string file)
  60. {
  61. var rel = Path.GetRelativePath(root, file);
  62. return rel.Split(Path.DirectorySeparatorChar)
  63. .Any(seg => excluded.Contains(seg));
  64. }
  65. // ----------------------------------------------------------------------------
  66. // 读取 zh-CN.json 并合并
  67. // ----------------------------------------------------------------------------
  68. var jsonOptions = new JsonSerializerOptions
  69. {
  70. WriteIndented = true,
  71. Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
  72. };
  73. var node = JsonNode.Parse(File.ReadAllText(zhFile))!.AsObject();
  74. var translations = node["translations"]!.AsObject();
  75. var added = new List<string>();
  76. foreach (var text in originals)
  77. if (!translations.ContainsKey(text))
  78. {
  79. translations[text] = text; // 中文包 value = 原文
  80. added.Add(text);
  81. }
  82. var removedCandidates = translations.Select(t => t.Key).Where(k => !originals.Contains(k)).ToList();
  83. if (added.Count > 0)
  84. File.WriteAllText(zhFile, node.ToJsonString(jsonOptions), new UTF8Encoding(false));
  85. // ----------------------------------------------------------------------------
  86. // 其他语言包缺失校验(只报告,不改写——翻译走 LocalizationTool / AI 流程)
  87. // ----------------------------------------------------------------------------
  88. var zhKeys = translations.Select(t => t.Key).ToHashSet(StringComparer.Ordinal);
  89. var missingReport = new StringBuilder();
  90. foreach (var packFile in Directory.EnumerateFiles(localizationDir, "*.json"))
  91. {
  92. var name = Path.GetFileName(packFile);
  93. if (name.Equals("zh-CN.json", StringComparison.OrdinalIgnoreCase)) continue;
  94. JsonObject pack;
  95. try { pack = JsonNode.Parse(File.ReadAllText(packFile))!["translations"]!.AsObject(); }
  96. catch { continue; }
  97. var packKeys = pack.Select(p => p.Key).ToHashSet(StringComparer.Ordinal);
  98. var missing = zhKeys.Where(k => !packKeys.Contains(k)).ToList();
  99. if (missing.Count > 0)
  100. missingReport.AppendLine($" {name} 缺失 {missing.Count} 条: {string.Join(" | ", missing.Take(10))}{(missing.Count > 10 ? " ..." : "")}");
  101. }
  102. // ----------------------------------------------------------------------------
  103. // 汇总输出
  104. // ----------------------------------------------------------------------------
  105. Console.WriteLine($"[LocalizationExtractor] 扫描 {scanDirs.Count} 个工程目录,共提取原文 {originals.Count} 条");
  106. if (added.Count > 0)
  107. {
  108. Console.WriteLine($"[LocalizationExtractor] zh-CN.json 新增 {added.Count} 条");
  109. }
  110. if (removedCandidates.Count > 0)
  111. {
  112. Console.WriteLine($"[LocalizationExtractor] 提示: {removedCandidates.Count} 条原文未在源码中找到(已保留,确认废弃请手动删除):");
  113. foreach (var k in removedCandidates.Take(10))
  114. Console.WriteLine($" - {k}");
  115. if (removedCandidates.Count > 10) Console.WriteLine(" ...");
  116. }
  117. if (missingReport.Length > 0)
  118. {
  119. Console.WriteLine("[LocalizationExtractor] 其他语言包缺失词条:");
  120. Console.Write(missingReport);
  121. }
  122. return 0;
  123. // ----------------------------------------------------------------------------
  124. // 提取逻辑
  125. // ----------------------------------------------------------------------------
  126. static void ExtractXaml(string content, SortedSet<string> into)
  127. {
  128. // {loc:Translate '原文'} 或 {loc:Translate "原文"}
  129. foreach (Match m in Regex.Matches(content, @"loc:Translate\s+(['""])(?<text>(?:(?!\1).)*)\1"))
  130. into.Add(UnescapeXml(m.Groups["text"].Value));
  131. // {loc:Translate Key='原文'} 属性写法
  132. foreach (Match m in Regex.Matches(content, @"loc:Translate\s+Key=(['""])(?<text>(?:(?!\1).)*)\1"))
  133. into.Add(UnescapeXml(m.Groups["text"].Value));
  134. }
  135. static void ExtractCSharp(string content, SortedSet<string> into)
  136. {
  137. // 普通字符串:Translate("原文"...(含 xxx.Translate 与裸调用)
  138. foreach (Match m in Regex.Matches(content, @"(?:^|[^.\w])Translate\(\s*""(?<text>(?:[^""\\]|\\.)*)"""))
  139. into.Add(UnescapeCSharp(m.Groups["text"].Value));
  140. // 逐字字符串:Translate(@"原文...
  141. foreach (Match m in Regex.Matches(content, @"(?:^|[^.\w])Translate\(\s*@""(?<text>(?:[^""]|"""")*)"""))
  142. into.Add(m.Groups["text"].Value.Replace("\"\"", "\""));
  143. // L10n.T("原文") / L10n.F("模板{0}"...) — C# 界面文案入口
  144. foreach (Match m in Regex.Matches(content, @"L10n\.(?:T|F)\(\s*""(?<text>(?:[^""\\]|\\.)*)"""))
  145. into.Add(UnescapeCSharp(m.Groups["text"].Value));
  146. foreach (Match m in Regex.Matches(content, @"L10n\.(?:T|F)\(\s*@""(?<text>(?:[^""]|"""")*)"""))
  147. into.Add(m.Groups["text"].Value.Replace("\"\"", "\""));
  148. }
  149. static string UnescapeXml(string s) => s
  150. .Replace("&lt;", "<").Replace("&gt;", ">")
  151. .Replace("&quot;", "\"").Replace("&apos;", "'")
  152. .Replace("&amp;", "&");
  153. static string UnescapeCSharp(string s) => Regex.Replace(s, @"\\(?:u(?<u>[0-9a-fA-F]{4})|x(?<x>[0-9a-fA-F]{2,4})|(?<c>.))",
  154. m =>
  155. {
  156. if (m.Groups["u"].Success) return ((char)Convert.ToInt32(m.Groups["u"].Value, 16)).ToString();
  157. if (m.Groups["x"].Success) return ((char)Convert.ToInt32(m.Groups["x"].Value, 16)).ToString();
  158. return m.Groups["c"].Value switch
  159. {
  160. "n" => "\n", "t" => "\t", "r" => "\r", "0" => "\0",
  161. "a" => "\a", "b" => "\b", "f" => "\f", "v" => "\v",
  162. var c => c, // \\ \" \' 等
  163. };
  164. });
  165. static string FindRepoRoot()
  166. {
  167. // 从当前目录向上找包含 TeamAAS.sln 的目录
  168. var dir = new DirectoryInfo(Environment.CurrentDirectory);
  169. while (dir != null && !File.Exists(Path.Combine(dir.FullName, "TeamAAS.sln")))
  170. dir = dir.Parent;
  171. return dir?.FullName ?? Environment.CurrentDirectory;
  172. }