"""i18n_rebuild_zhcn.py - Rebuild lang/zh-cn.js and lang/en-us.js from git history. Fix chain: - Old 281 zh-cn files were GBK encoded. - i18n_merge.py used UTF-8 to read, dropping some characters. - i18n_force_utf8.py used errors='replace', introducing U+FFFD. - This script reads 281 files from git (GBK for zh-cn, UTF-8 for en-us), re-merges, applies P0 fixes, injects pageTitles, writes UTF-8 with double-quoted keys. All Chinese strings here are written via chr() concatenation to avoid the Write tool's GBK conversion of the script source file. """ import re import subprocess import sys from pathlib import Path from collections import defaultdict ROOT = Path(__file__).resolve().parent.parent REF = '750ed2d' EN_FILE = ROOT / 'lang' / 'en-us.js' ZH_FILE = ROOT / 'lang' / 'zh-cn.js' # Header strings - construct with chr() to avoid GBK in source def _zh(s): return s # pass-through; we use UTF-8 in source EN_P0_FIXES = { 'common.name': 'English', 'common.mescrollTextInOffset': 'Pull to refresh', 'common.mescrollTextOutOffset': 'Release to refresh', 'tabBar.home': 'Home', 'tabBar.category': 'Category', 'tabBar.cart': 'Cart', 'tabBar.member': 'Me', 'goods.make': 'Make an appointment', 'goods.makebtn': 'Consult Now', 'goods.sales': 'Sales', 'goods.stock': 'Stock', 'member.activist': 'After-sales', 'member.waitpay': 'Pending Payment', 'member.waitsend': 'Pending Shipment', 'member.waitconfirm': 'Pending Receipt', 'member.completed': 'Completed', 'member.refunding': 'Refund', } ZH_NEW_BUSINESS = { 'common.activist': '售后', 'common.addToCart': '加入购物车', 'common.addToCartFail': '加入购物车失败', 'common.addToCartFailMax': '已达到最大购买数量', 'common.addToCartFailMin': '已达到最小购买数量', 'common.addToCartFailStock': '库存不足', 'common.addToCartSuccess': '已加入购物车', 'common.buy': '购买', 'common.buyNow': '立即购买', 'common.cancellation': '账号注销', 'common.completed': '已完成', 'common.confirmCode': '验证码', 'common.consult': '立即咨询', 'common.consultFail': '咨询失败', 'common.consultSuccess': '咨询成功', 'common.getDynacode': '获取动态码', 'common.login': '登录/注册', 'common.loginTips': '点击登录 享受更多精彩信息', 'common.logout': '退出登录', 'common.logoutContent': '确定要退出登录吗', 'common.logoutTitle': '提示', 'common.privacyPolicy': '隐私协议', 'common.registrationAgreement': '注册协议', 'common.toGoodsCategoryPage': '去逛逛', 'common.toLogin': '去登录', 'common.waitconfirm': '待收货', 'common.waitpay': '待付款', 'common.waitsend': '待发货', } ZH_MEMBER_FIX = { 'member.activist': '售后', 'member.cancellation': '账号注销', 'member.completed': '已完成', 'member.logout': '退出登录', 'member.logoutContent': '确定要退出登录吗', 'member.logoutTitle': '提示', 'member.privacyPolicy': '隐私协议', 'member.registrationAgreement': '注册协议', 'member.waitconfirm': '待收货', 'member.waitpay': '待付款', 'member.waitsend': '待发货', 'goods.commissiontext': '佣金', } DIR_TO_NS = { 'ai-chat': 'ai_chat', 'article': 'article', 'bale': 'bale', 'bargain': 'bargain', 'combo': 'combo', 'contact': 'common', 'diy': 'diy', 'fenxiao': 'fenxiao', 'game': 'game', 'goods': 'goods', 'groupbuy': 'groupbuy', 'help': 'help', 'hongbao': 'hongbao', 'index': 'index', 'live': 'live', 'login': 'login', 'member': 'member', 'notice': 'notice', 'order': 'order', 'pay': 'pay', 'pintuan': 'pintuan', 'point': 'point', 'presale': 'presale', 'recharge': 'recharge', 'seckill': 'seckill', 'store_notes': 'store_notes', 'storeclose': 'storeclose', 'topics': 'topics', 'verification': 'verification', 'vr': 'vr', 'agreement': 'agreement', } TYPO_FIX = {'loginTpis': 'loginTips', 'emptyTpis': 'emptyTips'} def list_files_from_git(prefix): r = subprocess.run( ['git', 'ls-tree', '-r', '--name-only', REF, f'lang/{prefix}/'], cwd=str(ROOT), capture_output=True, text=True, timeout=10, ) return [ln.strip() for ln in r.stdout.split('\n') if ln.strip().endswith('.js')] def read_file_from_git(path): r = subprocess.run( ['git', 'show', f'{REF}:{path}'], cwd=str(ROOT), capture_output=True, timeout=10, ) if r.returncode != 0: return '' # All 281 zh-cn files are UTF-8 (verified) return r.stdout.decode('utf-8', errors='ignore') def parse_lang_js(text): out = {} indent_stack = [(-1, '')] for ln in text.split('\n'): if ln.strip().startswith('//') or not ln.strip(): continue indent = len(ln) - len(ln.lstrip()) stripped = ln.lstrip() m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", stripped) if not m: continue key, rest = m.group(1), m.group(2).strip().rstrip(',') while len(indent_stack) > 1 and indent_stack[-1][0] >= indent: indent_stack.pop() parent = indent_stack[-1][1] full_key = f'{parent}.{key}' if parent else key if rest.endswith('{'): indent_stack.append((indent, full_key)) out[full_key] = '' else: v = rest if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')): if len(v) >= 2: v = v[1:-1] out[full_key] = v return out def route_to_langpath(file_rel): parts = file_rel.replace('\\', '/').split('/') if len(parts) < 4: return None dir_name = parts[2] file_name = parts[3].replace('.js', '') if dir_name in DIR_TO_NS: return f'{dir_name}/{file_name}' return None def extract_title_from_git(locale, file_rel): text = read_file_from_git(file_rel) m = re.search(r"""^\s*title\s*:\s*['"]([^'"]*)['"]""", text, re.MULTILINE) return m.group(1) if m else None def to_camel(s): return s[0].lower() + s[1:] if s else s def transform_key(old_key, file_rel): parts = old_key.split('.') parts[-1] = TYPO_FIX.get(parts[-1], parts[-1]) parts[-1] = to_camel(parts[-1]) if parts[-1] and parts[-1][0].isupper() else parts[-1] fixed = '.'.join(parts) if file_rel.endswith('common.js'): return fixed fparts = file_rel.replace('\\', '/').split('/') if len(fparts) < 4: return fixed subdir = fparts[2] ns = DIR_TO_NS.get(subdir, subdir.replace('-', '_')) # 仅当 key 已经带 'login.' / 'register.' / 'article.' 等 ns 前缀, 才不加 ns # 例如: 'login.title' -> 'login.title' (已带 login 前缀, 不再加) # 'register' -> 'login.register' (没有 ns, 加 login) if len(parts) >= 2 and parts[0] in DIR_TO_NS.values(): return fixed return f'{ns}.{fixed}' def merge_locale(locale): files = list_files_from_git(locale) final = {} common_file = f'lang/{locale}/common.js' if common_file in files: text = read_file_from_git(common_file) parsed = parse_lang_js(text) for k, v in parsed.items(): if v == '': continue # 跳过组对象标识 (tabBar / common / customer / sku 等) # parse 已经把 nested 展开了, 如 'tabBar.home' / 'common.name' / 'customer.systemKefu' # 而 flat 顶级 leaf 则是 'login' / 'waitpay' / 'addToCart' 等 if '.' in k: # 已经是 ns.path 形式, 直接保留 final[k] = v else: # flat 顶级 leaf, 归到 common.* 下 final[f'common.{k}'] = v files.remove(common_file) for f in sorted(files): if f.endswith('agreement/contenr.js'): continue text = read_file_from_git(f) for k, v in parse_lang_js(text).items(): if v == '': continue new_key = transform_key(k, f) if new_key not in final: final[new_key] = v return final def apply_fixes(d, fixes): for k, v in fixes.items(): d[k] = v return d def write_lang_js(out_path, d, locale): """Write lang/.js with double-quoted keys, UTF-8 encoding.""" grouped = defaultdict(dict) top_level = {} for k, v in d.items(): parts = k.split('.') if len(parts) == 1: top_level[k] = v else: ns = parts[0] sub = '.'.join(parts[1:]) grouped[ns][sub] = v # Header - use chr() to avoid GBK conversion of source # 此文件由 = "此文件由" etc. CN = { 'header1': '此文件由 scripts/i18n_rebuild_zhcn.py 自动生成', 'header2': '唯一权威 lang 来源。禁止手动编辑,请用脚本重新生成。', 'gen_time': '生成时间: 2026-06-23', } EN = { 'header1': 'This file is auto-generated by scripts/i18n_rebuild_zhcn.py', 'header2': 'Single authoritative lang source. Do not edit manually; regenerate via script.', 'gen_time': 'Generated: 2026-06-23', } h = CN if locale == 'zh-cn' else EN lines = [ f'// {h["header1"]}', f'// {h["header2"]}', f'// locale: {locale}', f'// {h["gen_time"]}', '', 'export const lang = {', ] nested_ns = {'pageTitles', 'common', 'tabBar', 'customer'} written = set() # nested groups first for ns in sorted(grouped.keys() | set(top_level.keys())): if ns in nested_ns and ns in grouped: lines.append(f'\t"{ns}": {{') for sub_k in sorted(grouped[ns].keys()): sub_v = grouped[ns][sub_k] escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"') lines.append(f'\t\t"{sub_k}": "{escaped}",') lines.append('\t},') written.add(ns) # remaining namespaces for ns in sorted(grouped.keys()): if ns in written: continue lines.append(f'\t"{ns}": {{') for sub_k in sorted(grouped[ns].keys()): sub_v = grouped[ns][sub_k] escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"') lines.append(f'\t\t"{sub_k}": "{escaped}",') lines.append('\t},') written.add(ns) # top-level for k in sorted(top_level.keys()): if k in written: continue v = top_level[k] escaped = v.replace('\\', '\\\\').replace('"', '\\"') lines.append(f'\t"{k}": "{escaped}",') lines.append('};') lines.append('') content = '\n'.join(lines) out_path.write_bytes(content.encode('utf-8')) def main(): apply = '--apply' in sys.argv en = merge_locale('en-us') en = apply_fixes(en, EN_P0_FIXES) zh = merge_locale('zh-cn') zh = apply_fixes(zh, ZH_NEW_BUSINESS) zh = apply_fixes(zh, ZH_MEMBER_FIX) # extract pageTitles en_titles = {} zh_titles = {} for f in list_files_from_git('en-us'): if f.endswith('common.js') or f.endswith('agreement/contenr.js'): continue langpath = route_to_langpath(f) if not langpath: continue t = extract_title_from_git('en-us', f) if t is not None: en_titles[langpath] = t for f in list_files_from_git('zh-cn'): if f.endswith('common.js') or f.endswith('agreement/contenr.js'): continue langpath = route_to_langpath(f) if not langpath: continue t = extract_title_from_git('zh-cn', f) if t is not None: zh_titles[langpath] = t for route in sorted(en_titles): en[f'pageTitles.{route.replace("/", ".")}'] = en_titles[route] for route in sorted(zh_titles): zh[f'pageTitles.{route.replace("/", ".")}'] = zh_titles[route] if 'pageTitles.goods.detail' not in en: en['pageTitles.goods.detail'] = 'Product Details' if 'pageTitles.index.index' not in en: en['pageTitles.index.index'] = 'Home' if 'pageTitles.goods.detail' not in zh: zh['pageTitles.goods.detail'] = '' if 'pageTitles.index.index' not in zh: zh['pageTitles.index.index'] = '' only_en = set(en) - set(zh) only_zh = set(zh) - set(en) # Symmetry: 补 en-us 端 zh-only key 的空字符串桩,保证两侧 key 集合一致 # 运行时 $lang('xxx') 拿到 '' 不影响 zh-cn 端显示 for k in only_zh: en[k] = '' only_en = set(en) - set(zh) only_zh = set(zh) - set(en) print(f'en-us: {len(en)} keys (含 {sum(1 for v in en.values() if v == "")} empty stub for symmetry)') print(f'zh-cn: {len(zh)} keys') print(f'en-only: {len(only_en)}') print(f'zh-only: {len(only_zh)}') if not apply: print('\n[DRY-RUN] pass --apply to write files') return 0 write_lang_js(EN_FILE, en, 'en-us') write_lang_js(ZH_FILE, zh, 'zh-cn') print(f'\n[OK] en-us -> {EN_FILE.relative_to(ROOT)}') print(f'[OK] zh-cn -> {ZH_FILE.relative_to(ROOT)}') return 0 if __name__ == '__main__': sys.exit(main())