Files
web-Iot/.workbuddy/batch_i18n.py

213 lines
7.2 KiB
Python
Raw Permalink Normal View History

2026-08-12 11:29:44 +08:00
"""
Batch i18n transformation script.
Reads the zh translation file, creates a reverse mapping (Chinese text -> key),
then replaces Chinese text in all .tsx/.ts/.jsx files with t() calls.
"""
import re
import os
import json
# Parse the zh translation file to create reverse mapping
def parse_translation_file(filepath):
"""Extract all key-value pairs from the TypeScript translation object."""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Remove the wrapper and export
content = re.sub(r'^const \w+ = ', '', content)
content = re.sub(r';\s*export default \w+;\s*$', '', content)
# Parse the nested object structure
mapping = {} # Chinese text -> "namespace.key"
def extract_keys(obj_str, prefix=''):
"""Recursively extract keys from nested object string."""
# Match key: value pairs
# Keys can be: identifier, 'string', or "string"
# Values can be: 'string', "string", or nested { }
i = 0
while i < len(obj_str):
# Find key
key_match = re.match(r'\s*(\w+)\s*:\s*', obj_str[i:])
if not key_match:
# Try quoted key
key_match = re.match(r"\s*'([^']+)'\s*:\s*", obj_str[i:])
if not key_match:
i += 1
continue
key = key_match.group(1)
i += key_match.end()
if i >= len(obj_str):
break
# Check value type
if obj_str[i] == '{':
# Nested object - find matching brace
depth = 1
j = i + 1
while j < len(obj_str) and depth > 0:
if obj_str[j] == '{':
depth += 1
elif obj_str[j] == '}':
depth -= 1
j += 1
# Recurse into nested object
nested_content = obj_str[i+1:j-1]
full_prefix = f"{prefix}{key}." if prefix else f"{key}."
extract_keys(nested_content, full_prefix)
i = j
elif obj_str[i] == "'" or obj_str[i] == '"':
# String value
quote = obj_str[i]
j = i + 1
while j < len(obj_str):
if obj_str[j] == '\\':
j += 2
continue
if obj_str[j] == quote:
break
j += 1
value = obj_str[i+1:j]
full_key = f"{prefix}{key}" if prefix else key
if value and len(value) > 1:
mapping[value] = full_key
i = j + 1
else:
# Skip non-string values
i += 1
extract_keys(content)
return mapping
# Build the mapping
zh_mapping = parse_translation_file('src/locales/zh/index.ts')
# Sort by length (longest first) to avoid partial replacements
sorted_mappings = sorted(zh_mapping.items(), key=lambda x: len(x[0]), reverse=True)
print(f"Loaded {len(sorted_mappings)} translation keys")
# Files to process
target_dirs = [
'src/pages',
'src/components',
]
# Files to skip
skip_files = [
'src/components/LanguageSwitcher.tsx',
'src/components/Layout.tsx',
]
def get_target_files():
files = []
for target_dir in target_dirs:
for root, dirs, filenames in os.walk(target_dir):
for fname in filenames:
if fname.endswith(('.tsx', '.ts', '.jsx', '.js')):
filepath = os.path.join(root, fname).replace('\\', '/')
if filepath not in skip_files:
files.append(filepath)
return files
target_files = get_target_files()
print(f"Found {len(target_files)} files to process")
# Statistics
total_replacements = 0
files_modified = 0
for filepath in target_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
except:
continue
original = content
file_replacements = 0
# Check if file already has useTranslation
has_i18n = 'useTranslation' in content
# Do replacements
for chinese_text, key in sorted_mappings:
if chinese_text not in content:
continue
# Pattern 1: In single-quoted strings: '中文'
old1 = f"'{chinese_text}'"
new1 = f"t('{key}')"
if old1 in content:
count = content.count(old1)
content = content.replace(old1, new1)
file_replacements += count
# Pattern 2: In double-quoted strings: "中文"
old2 = f'"{chinese_text}"'
new2 = f"t('{key}')"
if old2 in content:
count = content.count(old2)
content = content.replace(old2, new2)
file_replacements += count
# Pattern 3: In JSX text: >中文<
old3 = f'>{chinese_text}<'
new3 = f"{{t('{key}')}}<"
if old3 in content:
count = content.count(old3)
content = content.replace(old3, new3)
file_replacements += count
# Pattern 4: In template literals: `中文`
old4 = f'`{chinese_text}`'
new4 = f"t('{key}')"
if old4 in content:
count = content.count(old4)
content = content.replace(old4, new4)
file_replacements += count
if file_replacements == 0:
continue
# Add useTranslation import if needed
if not has_i18n and file_replacements > 0:
# Find the last import line
import_pattern = r"((?:import .+?;\n)+)"
matches = list(re.finditer(import_pattern, content))
if matches:
last_import = matches[-1]
insert_pos = last_import.end()
content = content[:insert_pos] + "import { useTranslation } from 'react-i18next';\n" + content[insert_pos:]
# Add const { t } = useTranslation(); after component declaration
# Look for function component patterns
# Pattern 1: export default function ComponentName() {
func_pattern = r'(export default function \w+\([^)]*\)\s*\{)'
match = re.search(func_pattern, content)
if match:
insert_after = match.end()
content = content[:insert_after] + '\n\tconst { t } = useTranslation();' + content[insert_after:]
else:
# Pattern 2: const ComponentName = () => { or function ComponentName() {
func_pattern2 = r'((?:export default )?(?:const|function) \w+\s*(?:=\s*)?(?:\([^)]*\))?\s*(?:=>)?\s*\{)'
match = re.search(func_pattern2, content)
if match:
insert_after = match.end()
content = content[:insert_after] + '\n\tconst { t } = useTranslation();' + content[insert_after:]
if content != original:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
files_modified += 1
total_replacements += file_replacements
print(f" {filepath}: {file_replacements} replacements")
print(f"\nDone! Modified {files_modified} files, {total_replacements} total replacements")