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(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(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(); 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 into) { // {loc:Translate '原文'} 或 {loc:Translate "原文"} foreach (Match m in Regex.Matches(content, @"loc:Translate\s+(['""])(?(?:(?!\1).)*)\1")) into.Add(UnescapeXml(m.Groups["text"].Value)); // {loc:Translate Key='原文'} 属性写法 foreach (Match m in Regex.Matches(content, @"loc:Translate\s+Key=(['""])(?(?:(?!\1).)*)\1")) into.Add(UnescapeXml(m.Groups["text"].Value)); } static void ExtractCSharp(string content, SortedSet into) { // 普通字符串:Translate("原文"...(含 xxx.Translate 与裸调用) foreach (Match m in Regex.Matches(content, @"(?:^|[^.\w])Translate\(\s*""(?(?:[^""\\]|\\.)*)""")) into.Add(UnescapeCSharp(m.Groups["text"].Value)); // 逐字字符串:Translate(@"原文... foreach (Match m in Regex.Matches(content, @"(?:^|[^.\w])Translate\(\s*@""(?(?:[^""]|"""")*)""")) into.Add(m.Groups["text"].Value.Replace("\"\"", "\"")); // L10n.T("原文") / L10n.F("模板{0}"...) — C# 界面文案入口 foreach (Match m in Regex.Matches(content, @"L10n\.(?:T|F)\(\s*""(?(?:[^""\\]|\\.)*)""")) into.Add(UnescapeCSharp(m.Groups["text"].Value)); foreach (Match m in Regex.Matches(content, @"L10n\.(?:T|F)\(\s*@""(?(?:[^""]|"""")*)""")) 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(?[0-9a-fA-F]{4})|x(?[0-9a-fA-F]{2,4})|(?.))", 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; }