#!/usr/bin/env python3 """ Merge i18n translations from all pipeline modules into wwwroot/i18n. 模块自动发现(不再手工维护清单——2026-09-12 i18n 治理:手工清单漏了 17 个模块, 词条永远进不了字典): 1. 主应用自身 i18n/ 2. pipeline_core / pipeline_ops(本地或 pkgs/) 3. build.sh clone 清单里的全部业务模块(pkgs/ 或同级仓库目录) 4. pkgs/ 下任何带 i18n/ 目录的其它模块(兜底扫描) 每模块读 i18n//msg.txt 与 i18n//i18n.json。 合并规则(2026-09-17 i18n 治理,修复三类机制缺陷): 1. 模块源权威:键存在于模块源时,模块值覆盖旧产物值——msg.txt 后来修正的 翻译能进产物(旧逻辑以产物为基底先到先得,产物 stale 永不更新)。 2. 真翻译优先:任何语言的「身份映射」(值==键,如 Submit: Submit)不遮蔽 真翻译。高优先级模块的身份映射遇到低优先级模块的真翻译时让位 (旧逻辑 app 的 Submit: Submit 永远压住 bricks 的 Submit: 提交, 中文界面按钮显示英文的根因)。 3. 产物孤儿键保留:只在旧产物、不在任何模块源的键(历史遗留/已删模块) 原样保留,不丢失。 模块间优先级仍按 discover_modules() 顺序先到先赢(真翻译之间不互相覆盖)。 附带产出审计报告 wwwroot/i18n/MERGE_REPORT.md:身份映射清单、缺翻清单, 让词条欠账可持续发现(旧机制下缺翻只能靠人肉发现)。 Usage: python scripts/merge_i18n.py """ import os, json, re, sys from collections import OrderedDict SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(SCRIPT_DIR) WWWROOT = os.path.join(ROOT_DIR, 'wwwroot') I18N_OUT = os.path.join(WWWROOT, 'i18n') LANGS = ['zh', 'en', 'ko', 'jp'] def discover_modules(): """返回 [(name, i18n_dir), ...],顺序即合并优先级(主应用最先)。""" mods = [] def add(name, i18n_dir): if os.path.isdir(i18n_dir) and not any(n == name for n, _ in mods): mods.append((name, i18n_dir)) # 1. 主应用 add('app', os.path.join(ROOT_DIR, 'i18n')) # 2/3. build.sh clone 清单 + 本地业务模块 names = ['pipeline_core', 'pipeline_ops'] build_sh = os.path.join(ROOT_DIR, 'build.sh') if os.path.exists(build_sh): content = open(build_sh, encoding='utf-8').read() for m in re.finditer(r'^for mod in ([^;]+); do\s*$', content, re.M): for n in m.group(1).split(): if n not in names: names.append(n) repos_parent = os.path.dirname(ROOT_DIR) for n in names: for base in (os.path.join(ROOT_DIR, 'pkgs'), ROOT_DIR, repos_parent, os.path.join(ROOT_DIR, n)): cand = os.path.join(base, n, 'i18n') if not base.endswith(n) else os.path.join(base, 'i18n') if os.path.isdir(cand): add(n, cand) break # 4. 兜底:pkgs/ 下所有带 i18n/ 的目录 pkgs = os.path.join(ROOT_DIR, 'pkgs') if os.path.isdir(pkgs): for n in sorted(os.listdir(pkgs)): add(n, os.path.join(pkgs, n, 'i18n')) return mods def parse_msg_txt(filepath): """Parse msg.txt: 行格式 'key: value'(主)或 'key=value'(兼容)。 ⚠ 解析陷阱(2026-09-17 实锤修复): 旧逻辑先判 '=' 再判 ':'——当键本身含冒号 且值含 '='(如 `支付状态(0待支付/1已支付/2已退款): Payment Status (0=Pending/...)`) 时,按首个 '=' 错切 → 产生损坏键、真翻译丢失。必须优先按首个 ': ' 切分, 无 ': ' 才回退 '='。 """ result = OrderedDict() if not os.path.exists(filepath): return result with open(filepath, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue if ': ' in line: key, val = line.split(': ', 1) result[key.strip()] = val.strip() elif ':' in line: key, val = line.split(':', 1) result[key.strip()] = val.strip() elif '=' in line: key, val = line.split('=', 1) result[key.strip()] = val.strip() return result def _is_identity(k, v): """身份映射: 值==键(占位/未翻译)。""" return v == k def _collect_sources(modules, lang): """按优先级顺序收集 [(mod_name, k, v), ...],msg.txt 先于同模块 i18n.json。""" entries = [] for mod_name, mod_i18n_dir in modules: lang_dir = os.path.join(mod_i18n_dir, lang) if not os.path.isdir(lang_dir): continue msgs = parse_msg_txt(os.path.join(lang_dir, 'msg.txt')) for k, v in msgs.items(): entries.append((mod_name, k, v)) mod_i18n = os.path.join(lang_dir, 'i18n.json') if os.path.exists(mod_i18n): try: with open(mod_i18n, 'r', encoding='utf-8') as f: extra = json.load(f, object_pairs_hook=OrderedDict) except Exception as e: print(f' !! {mod_name}/{lang}/i18n.json parse error: {e}', file=sys.stderr) continue for k, v in extra.items(): entries.append((mod_name, k, v)) return entries def merge_lang(entries, lang): """合并一个语言: 真翻译优先 + 模块源权威 + 先到先得。 返回 (merged OrderedDict, stats dict)。""" merged = OrderedDict() src_mod = {} # k -> 提供最终值的模块 shadowed = [] # 身份映射被真翻译覆盖的记录 for mod_name, k, v in entries: if k not in merged: merged[k] = v src_mod[k] = mod_name else: cur = merged[k] if _is_identity(k, cur) and not _is_identity(k, v): # 规则2: 已有值是身份映射,新值是真翻译 → 让位 shadowed.append((k, src_mod[k], mod_name, v)) merged[k] = v src_mod[k] = mod_name # 其余情况先到先得(真翻译之间不互相覆盖) return merged, {'shadowed': shadowed, 'src_mod': src_mod} def _report(lang, merged, stats, orphans): """单语言审计段。""" lines = [f'## {lang}', ''] lines.append(f'- 键总数: {len(merged)} (模块源 {len(merged) - len(orphans)} + 产物孤儿 {len(orphans)})') ident = [k for k, v in merged.items() if _is_identity(k, v)] lines.append(f'- 身份映射(值==键, 疑似未翻译): {len(ident)}') if stats['shadowed']: lines.append(f"- 身份映射被低优先级模块真翻译纠正: {len(stats['shadowed'])}") for k, old_m, new_m, v in stats['shadowed'][:20]: lines.append(f' - `{k}`: {old_m}(身份) → {new_m} `{v}`') if orphans: lines.append(f'- 产物孤儿键(仅存于旧产物, 已保留): {len(orphans)}') lines.append('') if lang == 'zh': # zh 身份映射中纯英文键 = 中文界面显示英文, 列全 import re as _re en_ident = [k for k in ident if _re.fullmatch(r'[A-Za-z0-9 ._\-\'/()]+', k)] if en_ident: lines.append(f'### zh 英文身份映射({len(en_ident)}, 中文界面将显示英文):') for k in en_ident: lines.append(f'- `{k}` (来源: {stats["src_mod"].get(k, "orphan")})') lines.append('') return '\n'.join(lines) def merge(): modules = discover_modules() print(f'Discovered {len(modules)} i18n sources: {[n for n, _ in modules]}') os.makedirs(I18N_OUT, exist_ok=True) report = ['# i18n 合并审计报告', '', f'生成: merge_i18n.py ({__import__("time").strftime("%Y-%m-%d %H:%M")})', f'模块优先级: {" > ".join(n for n, _ in modules)}', ''] for lang in LANGS: out_dir = os.path.join(I18N_OUT, lang) os.makedirs(out_dir, exist_ok=True) i18n_file = os.path.join(out_dir, 'i18n.json') # 旧产物(仅为保留孤儿键) old = OrderedDict() if os.path.exists(i18n_file): with open(i18n_file, 'r', encoding='utf-8') as f: old = json.load(f, object_pairs_hook=OrderedDict) entries = _collect_sources(modules, lang) merged, stats = merge_lang(entries, lang) # 规则3: 产物孤儿键保留(追加在末尾) # 例外: 含': '的孤儿键是旧解析器(先'='后':')产生的损坏键,丢弃并报告 orphans = [] corrupt = [] for k in old: if k in merged: continue if ': ' in k: corrupt.append(k) continue orphans.append(k) for k in orphans: merged[k] = old[k] if corrupt: print(f' {lang}: dropped {len(corrupt)} corrupt orphan keys (parser artifacts): {corrupt[:3]}') # 规则1 已内含: 模块源的值直接构建 merged, 旧产物同键值被自然覆盖(stale 修复) with open(i18n_file, 'w', encoding='utf-8') as f: json.dump(merged, f, ensure_ascii=False, indent=2) print(f' {lang}: {len(merged)} keys (orphans kept: {len(orphans)}, ' f'identity-shadowed fixed: {len(stats["shadowed"])})') report.append(_report(lang, merged, stats, orphans)) with open(os.path.join(I18N_OUT, 'MERGE_REPORT.md'), 'w', encoding='utf-8') as f: f.write('\n'.join(report)) print(f'\nMerged i18n for {len(modules)} modules -> {I18N_OUT}') print(f'Audit report -> {os.path.join(I18N_OUT, "MERGE_REPORT.md")}') if __name__ == '__main__': merge()