pipeline-app/scripts/merge_i18n.py

234 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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/<mod> 或同级仓库目录)
4. pkgs/ 下任何带 i18n/ 目录的其它模块(兜底扫描)
每模块读 i18n/<lang>/msg.txt 与 i18n/<lang>/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()