417 lines
18 KiB
Python
417 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
check_i18n.py — 产线平台前端国际化(i18n)检查器
|
||
|
||
框架事实(bricks,决定各违规分类):
|
||
- TextBase(Text/Title*):只有 otext + i18n:true 才走翻译;裸 "text" 不翻译 → W1
|
||
- Menu item label / Button label / Form field label / Tabular title /
|
||
PopupWindow title / UiCode data.text / placeholder / Message.message:
|
||
组件自动 i18n._(),不需要改文件,只需要词条进字典 → 缺词条记 W2
|
||
- 字典链:模块 i18n/<lang>/msg.txt|i18n.json → scripts/merge_i18n.py →
|
||
wwwroot/i18n/<lang>/i18n.json → /i18n_getmsgs 端点 → 前端 I18n.msgs
|
||
- msg.txt 以 '#' 开头的词条被 appPublic.i18n 解析器当注释丢弃 → W6
|
||
- git-ignored 的 wwwroot/<列表名>/ 是 xls2ui 生成物,禁止直接修改(pre-commit 铁律)
|
||
→ 生成物只收集 key,不报 W1(要改改 json/ 源 + 重新生成)
|
||
|
||
违规分类:
|
||
W1 text/otext 硬编码中文且无 i18n 标记(git 跟踪的手写文件才报)
|
||
W2 自动翻译组件的中文 key 不在合并字典(en/jp/ko)
|
||
W3 手写 JS / dspy 内嵌 script 里硬编码中文提示(应 bricks.app.i18n._)
|
||
W4 模块有 UI 但缺 i18n 目录或缺语种
|
||
W5 merge_i18n.py 自动发现覆盖检查(动态调用 discover_modules() 验证)
|
||
W6 msg.txt '#' 开头词条(静默丢弃)
|
||
W7 动态拼接中文(% / + / f-string)作为可见文本 → 需 otext+i18n 传参重构(仅报告)
|
||
|
||
用法:
|
||
python3 scripts/check_i18n.py [--repos-dir ~/work/repos] [--json out.json] [-v]
|
||
退出码: 0=无违规, 1=有违规
|
||
"""
|
||
import os, re, sys, json, subprocess, argparse
|
||
from collections import OrderedDict
|
||
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
APP_DIR = os.path.dirname(SCRIPT_DIR)
|
||
LANGS = ['zh', 'en', 'jp', 'ko']
|
||
CJK = re.compile(r'[\u4e00-\u9fff]')
|
||
|
||
SKIP_PATH = [
|
||
re.compile(r'/i18n/'), re.compile(r'/docs?/'), re.compile(r'\.md$'),
|
||
re.compile(r'node_modules'), re.compile(r'/\.git/'), re.compile(r'/py3/'),
|
||
re.compile(r'/dist/'), re.compile(r'\.bak/'), re.compile(r'\.bak$'),
|
||
re.compile(r'/wwwroot/bricks/'), # 框架软链
|
||
]
|
||
AUTO_I18N_CTX = ('Menu', 'Button', 'Form', 'InlineForm', 'Tabular', 'PopupWindow',
|
||
'Title', 'Title1', 'Title2', 'Title3', 'Title4', 'Error')
|
||
|
||
|
||
def skip(path):
|
||
return any(p.search(path) for p in SKIP_PATH)
|
||
|
||
|
||
def git_repo_root(path):
|
||
d = path if os.path.isdir(path) else os.path.dirname(path)
|
||
try:
|
||
return subprocess.run(['git', '-C', d, 'rev-parse', '--show-toplevel'],
|
||
capture_output=True, text=True, timeout=10).stdout.strip()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
_ignored_cache = {}
|
||
def git_ignored(path, repo):
|
||
key = (repo, path)
|
||
if key in _ignored_cache:
|
||
return _ignored_cache[key]
|
||
try:
|
||
r = subprocess.run(['git', '-C', repo, 'check-ignore', '-q', path],
|
||
capture_output=True, timeout=10)
|
||
v = (r.returncode == 0)
|
||
except Exception:
|
||
v = False
|
||
_ignored_cache[key] = v
|
||
return v
|
||
|
||
|
||
def load_modules(repos_dir):
|
||
mods = []
|
||
build_sh = os.path.join(APP_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 name in m.group(1).split():
|
||
if name not in mods:
|
||
mods.append(name)
|
||
for name in ['pipeline_core', 'pipeline_ops']:
|
||
if name not in mods:
|
||
mods.insert(0, name)
|
||
result = OrderedDict()
|
||
result['app'] = APP_DIR
|
||
for name in mods:
|
||
for cand in (os.path.join(repos_dir, name), os.path.join(APP_DIR, 'pkgs', name),
|
||
os.path.join(APP_DIR, name)):
|
||
if os.path.isdir(cand):
|
||
result[name] = cand
|
||
break
|
||
else:
|
||
result[name] = None
|
||
return result
|
||
|
||
|
||
def iter_files(root, exts):
|
||
for dirpath, dirnames, filenames in os.walk(root):
|
||
dirnames[:] = [d for d in dirnames
|
||
if d not in ('.git', 'py3', 'node_modules', 'dist', '__pycache__', 'i18n')]
|
||
for fn in filenames:
|
||
if fn.endswith(exts):
|
||
p = os.path.join(dirpath, fn)
|
||
if not skip(p):
|
||
yield p
|
||
|
||
|
||
def merged_dict(lang):
|
||
fp = os.path.join(APP_DIR, 'wwwroot', 'i18n', lang, 'i18n.json')
|
||
if os.path.exists(fp):
|
||
try:
|
||
return json.load(open(fp, encoding='utf-8'))
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
|
||
def in_code_data_array(raw, pos):
|
||
"""判断位置是否处于 code 选项 data 数组的 {"value":..,"text":..} 上下文。"""
|
||
s = max(0, pos - 300)
|
||
ctx = raw[s:pos]
|
||
return '"value"' in ctx and ctx.rfind('"data"') >= 0 or \
|
||
re.search(r'\{\s*"value"\s*:[^}]*$', ctx) is not None
|
||
|
||
|
||
def scan_ui(path, keys, viol, tracked):
|
||
raw = open(path, encoding='utf-8', errors='replace').read()
|
||
repo = git_repo_root(path)
|
||
ign = repo and git_ignored(path, repo)
|
||
for m in re.finditer(r'"(label|title|text|otext|placeholder|message|description)"\s*:\s*"([^"]*)"', raw):
|
||
field, val = m.group(1), m.group(2)
|
||
if not CJK.search(val) or val.startswith('{{'):
|
||
continue
|
||
s = max(0, m.start() - 400); e = min(len(raw), m.end() + 400)
|
||
ctx = raw[s:e]
|
||
has_i18n = re.search(r'"i18n"\s*:\s*true', ctx) is not None
|
||
has_otext = '"otext"' in ctx
|
||
if field == 'otext':
|
||
if has_i18n:
|
||
keys.add(val)
|
||
else:
|
||
viol.append((path, m.start(), 'W1', 'otext 无 i18n:true', val, ign))
|
||
elif field == 'text':
|
||
if has_i18n or has_otext:
|
||
keys.add(val)
|
||
elif in_code_data_array(raw, m.start()):
|
||
keys.add(val) # UiCode/Tabular 渲染 code 值自动 i18n._ (input.js:1232)
|
||
else:
|
||
# Text 裸 text 不翻译
|
||
if ign:
|
||
keys.add(val) # 生成物:改 json 源+重新生成,这里只收 key
|
||
else:
|
||
viol.append((path, m.start(), 'W1', 'text 硬编码中文(应 otext+i18n:true)', val, ign))
|
||
else:
|
||
keys.add(val) # label/title/placeholder/message/description 组件自动翻译
|
||
# 内嵌 script 里的中文提示
|
||
for m in re.finditer(r'(show_message|show_error|bricks\.Message|bricks\.Error)\s*\(\s*\{[^}]{0,200}?(title|message)\s*:\s*[\'"]([^\'"]*)[\'"]', raw):
|
||
val = m.group(3)
|
||
if CJK.search(val):
|
||
keys.add(val) # Message.message 自动 i18n(message.js:25),title 走 PopupWindow 自动 i18n
|
||
# 动态拼接
|
||
for m in re.finditer(r'"text"\s*:\s*("[^"]*"\s*[%+][^,}]*)', raw):
|
||
seg = m.group(1)
|
||
if CJK.search(seg) and '{{' not in seg:
|
||
viol.append((path, m.start(), 'W7', '动态拼接中文文本(需 otext+参数重构)', seg[:60], ign))
|
||
|
||
|
||
DSPY_UI_KEYS = re.compile(r"['\"](label|title|otext|placeholder|message|description)['\"]\s*:\s*(['\"])([^'\"]*)\2")
|
||
DSPY_TEXT = re.compile(r"['\"]text['\"]\s*:\s*(?:(['\"])([^'\"]*)\1|(\(?\s*['\"][^'\"]*['\"]\s*[%+][^)\n]*\)?))")
|
||
|
||
def scan_dspy(path, keys, viol):
|
||
raw = open(path, encoding='utf-8', errors='replace').read()
|
||
if 'widgettype' not in raw and 'mdtext' not in raw:
|
||
return
|
||
repo = git_repo_root(path)
|
||
ign = repo and git_ignored(path, repo)
|
||
for m in DSPY_UI_KEYS.finditer(raw):
|
||
field, val = m.group(1), m.group(3)
|
||
if not CJK.search(val) or val.startswith('{{'):
|
||
continue
|
||
s = max(0, m.start() - 500); e = min(len(raw), m.end() + 500)
|
||
ctx = raw[s:e]
|
||
has_i18n = re.search(r"['\"]i18n['\"]\s*:\s*True", ctx) is not None
|
||
if field == 'otext':
|
||
if has_i18n:
|
||
keys.add(val)
|
||
else:
|
||
viol.append((path, m.start(), 'W1', 'dspy otext 无 i18n:True', val, ign))
|
||
else:
|
||
keys.add(val)
|
||
for m in DSPY_TEXT.finditer(raw):
|
||
if m.group(2) is not None:
|
||
val = m.group(2)
|
||
if not CJK.search(val):
|
||
continue
|
||
s = max(0, m.start() - 500); e = min(len(raw), m.end() + 500)
|
||
ctx = raw[s:e]
|
||
if re.search(r"['\"]i18n['\"]\s*:\s*True", ctx) or "'otext'" in ctx or '"otext"' in ctx \
|
||
or in_code_data_array(raw, m.start()):
|
||
keys.add(val)
|
||
elif ign:
|
||
keys.add(val)
|
||
else:
|
||
viol.append((path, m.start(), 'W1', 'dspy text 硬编码中文(应 otext+i18n:True)', val, ign))
|
||
else:
|
||
seg = m.group(3) or ''
|
||
if CJK.search(seg):
|
||
viol.append((path, m.start(), 'W7', 'dspy 动态拼接中文文本', seg[:60], ign))
|
||
for m in re.finditer(r"(?:show_message|show_error)\s*\(\s*\{[^}]{0,200}?(?:title|message)['\"]?\s*[:=]\s*['\"]([^'\"]*)['\"]", raw):
|
||
val = m.group(1)
|
||
if CJK.search(val):
|
||
keys.add(val)
|
||
|
||
|
||
def strip_js_comments(raw):
|
||
"""粗剥离 // 与 /* */ 注释(不处理字符串内含 // 的极端情形,够用)。"""
|
||
out = []
|
||
i, n = 0, len(raw)
|
||
in_s = None
|
||
while i < n:
|
||
c = raw[i]
|
||
if in_s:
|
||
out.append(c)
|
||
if c == '\\' and i + 1 < n:
|
||
out.append(raw[i+1]); i += 2; continue
|
||
if c == in_s:
|
||
in_s = None
|
||
i += 1; continue
|
||
if c in ('"', "'", '`'):
|
||
in_s = c; out.append(c); i += 1; continue
|
||
if c == '/' and i + 1 < n and raw[i+1] == '/':
|
||
while i < n and raw[i] != '\n':
|
||
i += 1
|
||
continue
|
||
if c == '/' and i + 1 < n and raw[i+1] == '*':
|
||
i += 2
|
||
while i + 1 < n and not (raw[i] == '*' and raw[i+1] == '/'):
|
||
i += 1
|
||
i += 2
|
||
continue
|
||
out.append(c); i += 1
|
||
return ''.join(out)
|
||
|
||
|
||
def scan_js(path, keys, viol):
|
||
raw = open(path, encoding='utf-8', errors='replace').read()
|
||
code = strip_js_comments(raw)
|
||
repo = git_repo_root(path)
|
||
ign = repo and git_ignored(path, repo)
|
||
# i18n-aware 文件:定义了 _t()/i18n._ 辅助函数,对象字面量里的中文是「词条 key」
|
||
# 而非硬编码显示文本(如 showcase.js MEDIA_LABEL_KEYS,经 mediaLabel→_t 翻译)
|
||
has_i18n_helper = bool(re.search(r'function\s+_t\s*\(|(?:var|const|let)\s+_t\s*=|i18n\._', code))
|
||
# 所有含中文的字符串字面量:不在 i18n._() 内、不在 Message/show_message 上下文 → W3
|
||
for m in re.finditer(r"(['\"])([^'\"\n]*[\u4e00-\u9fff][^'\"\n]*)\1", code):
|
||
val = m.group(2)
|
||
pre = code[max(0, m.start() - 80):m.start()]
|
||
if 'i18n._' in pre[-30:] or re.search(r'(?:^|[^.\w])_t\(\s*$', pre):
|
||
keys.add(val)
|
||
continue
|
||
ctx = code[max(0, m.start() - 200):m.end()]
|
||
if re.search(r'(show_message|show_error|bricks\.Message|bricks\.Error)\s*\(\s*\{[^}]*$', ctx):
|
||
keys.add(val) # Message title/message 自动翻译
|
||
continue
|
||
if re.search(r'(title|message|label)\s*:\s*$', pre):
|
||
keys.add(val) # 组件 option 自动翻译
|
||
continue
|
||
if has_i18n_helper and re.search(r'[\w\'\"]+\s*:\s*$', pre):
|
||
keys.add(val) # i18n-aware 文件的对象字面量中文值 = 词条 key
|
||
continue
|
||
viol.append((path, m.start(), 'W3', 'js 硬编码中文(应 bricks.app.i18n._ 或组件词条)', val, ign))
|
||
|
||
|
||
def check_infra(modules, merge_py):
|
||
issues = []
|
||
# W5 动态验证:直接调用 merge_i18n.discover_modules(),模块的 i18n 目录
|
||
# 能被自动发现即通过(2026-09-14:merge 已重写为自动发现,旧版按源码文本
|
||
# 扫描 MODULES 清单会把所有模块误报为漏)
|
||
discovered = set()
|
||
try:
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location('merge_i18n_mod', merge_py)
|
||
if spec is not None and spec.loader is not None:
|
||
mmod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mmod)
|
||
discovered = {n for n, _ in mmod.discover_modules()}
|
||
else:
|
||
print(f'!! merge_i18n.py 无法加载: {merge_py}', file=sys.stderr)
|
||
except Exception as e:
|
||
print(f'!! 无法调用 merge_i18n.discover_modules(): {e}', file=sys.stderr)
|
||
for name, path in modules.items():
|
||
if path is None or not os.path.isdir(path):
|
||
continue
|
||
ui_files = list(iter_files(path, ('.ui',)))
|
||
has_ui = bool(ui_files)
|
||
if not has_ui:
|
||
n = 0
|
||
for f in iter_files(path, ('.dspy',)):
|
||
raw = open(f, encoding='utf-8', errors='replace').read(4000)
|
||
if 'widgettype' in raw:
|
||
has_ui = True
|
||
break
|
||
n += 1
|
||
if n > 300:
|
||
break
|
||
i18n_dir = os.path.join(path, 'i18n')
|
||
if has_ui:
|
||
if not os.path.isdir(i18n_dir):
|
||
issues.append((name, 'W4', '有 UI 文件但无 i18n/ 目录'))
|
||
else:
|
||
missing = [l for l in LANGS
|
||
if not os.path.exists(os.path.join(i18n_dir, l, 'msg.txt'))
|
||
and not os.path.exists(os.path.join(i18n_dir, l, 'i18n.json'))]
|
||
if missing:
|
||
issues.append((name, 'W4', 'i18n 缺语种: ' + ','.join(missing)))
|
||
if name != 'app' and name not in discovered:
|
||
issues.append((name, 'W5', 'i18n/ 未被 merge_i18n.discover_modules() 发现'))
|
||
for lang in LANGS:
|
||
fp = os.path.join(path, 'i18n', lang, 'msg.txt')
|
||
if os.path.exists(fp):
|
||
for i, line in enumerate(open(fp, encoding='utf-8', errors='replace'), 1):
|
||
ls = line.strip()
|
||
if ls and ls.startswith('#') and (':' in ls or '=' in ls):
|
||
issues.append((name, 'W6', f'i18n/{lang}/msg.txt:{i} #词条被丢弃: {ls[:50]}'))
|
||
return issues
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument('--repos-dir', default=os.path.expanduser('~/work/repos'))
|
||
ap.add_argument('--json', default=None)
|
||
ap.add_argument('-v', '--verbose', action='store_true')
|
||
args = ap.parse_args()
|
||
|
||
modules = load_modules(args.repos_dir)
|
||
dicts = {l: merged_dict(l) for l in LANGS}
|
||
all_keys = {}
|
||
violations = []
|
||
per_mod = OrderedDict()
|
||
|
||
for name, path in modules.items():
|
||
if path is None or not os.path.isdir(path):
|
||
per_mod[name] = {'status': 'missing'}
|
||
continue
|
||
keys, viols = set(), []
|
||
roots = [path]
|
||
for f in iter_files(path, ('.ui',)):
|
||
scan_ui(f, keys, viols, None)
|
||
for f in iter_files(path, ('.dspy',)):
|
||
scan_dspy(f, keys, viols)
|
||
wr = os.path.join(path, 'wwwroot')
|
||
if os.path.isdir(wr):
|
||
for f in iter_files(wr, ('.js',)):
|
||
scan_js(f, keys, viols)
|
||
for k in keys:
|
||
all_keys.setdefault(k, set()).add(name)
|
||
violations.extend(viols)
|
||
per_mod[name] = {'keys': len(keys), 'violations': len(viols)}
|
||
|
||
missing_dict = {}
|
||
for key, mods in sorted(all_keys.items()):
|
||
for lang in ('en', 'jp', 'ko'):
|
||
if key not in dicts[lang]:
|
||
missing_dict.setdefault(lang, []).append({'key': key, 'modules': sorted(mods)})
|
||
|
||
infra = check_infra(modules, os.path.join(APP_DIR, 'scripts', 'merge_i18n.py'))
|
||
|
||
cnt = {}
|
||
for v in violations:
|
||
cnt[v[2]] = cnt.get(v[2], 0) + 1
|
||
w4 = [i for i in infra if i[1] == 'W4']
|
||
w5 = [i for i in infra if i[1] == 'W5']
|
||
w6 = [i for i in infra if i[1] == 'W6']
|
||
|
||
print('=' * 62)
|
||
print('产线平台 i18n 检查报告')
|
||
print('=' * 62)
|
||
print(f"模块数: {len([1 for p in modules.values() if p])}")
|
||
print(f"抽取可见中文 key(去重): {len(all_keys)}")
|
||
print(f"W1 硬编码未标i18n: {cnt.get('W1',0)} W3 js硬编码: {cnt.get('W3',0)} W7 动态拼接: {cnt.get('W7',0)}")
|
||
print(f"W2 字典缺失: en={len(missing_dict.get('en',[]))} jp={len(missing_dict.get('jp',[]))} ko={len(missing_dict.get('ko',[]))}")
|
||
print(f"W4 缺i18n目录/语种: {len(w4)} W5 merge漏模块: {len(w5)} W6 #词条: {len(w6)}")
|
||
if w5:
|
||
print(f" W5 漏: {[i[0] for i in w5]}")
|
||
if w4:
|
||
print(f" W4: {[(i[0], i[2][:30]) for i in w4]}")
|
||
print('-' * 62)
|
||
for name, st in per_mod.items():
|
||
if 'keys' in st:
|
||
flag = ' <-- !' if st['violations'] else ''
|
||
print(f" {name:24s} keys={st['keys']:4d} viol={st['violations']:3d}{flag}")
|
||
if args.verbose:
|
||
print('-' * 62)
|
||
for v in violations:
|
||
tag = ' [git-ignored]' if v[5] else ''
|
||
print(f" [{v[2]}] {v[0]}@{v[1]}{tag}: {v[3]} | {v[4][:60]}")
|
||
if args.json:
|
||
json.dump({
|
||
'summary': {'keys': len(all_keys), 'viol': cnt,
|
||
'W2': {l: len(missing_dict.get(l, [])) for l in ('en','jp','ko')},
|
||
'W4': len(w4), 'W5': len(w5), 'W6': len(w6)},
|
||
'violations': [{'file': v[0], 'pos': v[1], 'code': v[2], 'why': v[3],
|
||
'text': v[4], 'git_ignored': bool(v[5])} for v in violations],
|
||
'missing_dict': missing_dict,
|
||
'keys': {k: sorted(v) for k, v in sorted(all_keys.items())},
|
||
'infra': [{'module': i[0], 'code': i[1], 'detail': i[2]} for i in infra],
|
||
}, open(args.json, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
||
print(f'JSON 报告: {args.json}')
|
||
total = sum(cnt.values()) + sum(len(v) for v in missing_dict.values()) + len(infra)
|
||
return 1 if total else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|