234 lines
9.6 KiB
Python
234 lines
9.6 KiB
Python
#!/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()
|