Files
xianyan/scripts/check_translation_coverage.py
Developer 5a083bdbab feat: 新增多模块后端管理、数据同步工具与鸿蒙路由适配
本次提交新增了以下核心内容:
1. 后端管理模块:包含字体同步、插件元数据、插件用户设置、稍后读消息/共享列表的控制器、模型、验证器与多语言配置
2. Flutter数据同步模块:统一的事件总线与兼容层,替代分散的StreamController
3. 鸿蒙端路由适配:完整的路由定义、构建器与占位组件
4. 后端API接口:字体同步与插件更新的服务端API,支持自动建表与跨域请求
5. 鸿蒙权限校验脚本:用于校验module.json5与string.json的权限声明一致性
2026-06-01 05:50:13 +08:00

468 lines
14 KiB
Python

#!/usr/bin/env python3
# ============================================================
# 闲言APP — 多语言翻译覆盖率检测脚本
# 创建时间: 2026-06-01
# 更新时间: 2026-06-01
# 作用: CI/CD中自动检测14个语言文件的翻译覆盖率
# 上次更新: 初始创建
# ============================================================
import os
import sys
import re
import io
import json
import argparse
from pathlib import Path
from collections import OrderedDict
from datetime import datetime
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if sys.stderr.encoding != "utf-8":
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
LANG_DIR = PROJECT_ROOT / "lib" / "l10n" / "languages"
BASE_LANG_FILE = "zh_cn.dart"
LANGUAGE_MAP = OrderedDict([
("zh_cn.dart", "zh_CN"),
("zh_tw.dart", "zh_TW"),
("en.dart", "en"),
("ja.dart", "ja"),
("ko.dart", "ko"),
("de.dart", "de"),
("fr.dart", "fr"),
("es.dart", "es"),
("it.dart", "it"),
("pt.dart", "pt"),
("ru.dart", "ru"),
("ar.dart", "ar"),
("bn.dart", "bn"),
("hi.dart", "hi"),
])
def parse_dart_lang_file(filepath):
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
sections = OrderedDict()
field_paths = {}
section_stack = []
current_top = None
eq_pos = content.find("= T(")
if eq_pos == -1:
return sections, field_paths, content
i = eq_pos + 4
depth = 1
while i < len(content) and depth > 0:
while i < len(content) and content[i] in " \t\n\r,":
i += 1
if i >= len(content):
break
m = re.match(r"(\w+):\s*\w+\s*\(", content[i:])
if m:
name = m.group(1)
if depth == 1:
current_top = name
section_stack = [name]
sections[current_top] = OrderedDict()
else:
section_stack.append(name)
i += m.end()
depth += 1
continue
m = re.match(r"(\w+):\s*'((?:[^'\\]|\\.)*)'", content[i:])
if m and current_top is not None:
fname = m.group(1)
fval = m.group(2)
sections[current_top][fname] = fval
field_paths[(current_top, fname)] = list(section_stack)
i += m.end()
continue
if content[i] == ")":
depth -= 1
if depth == 1:
current_top = None
section_stack = []
elif depth > 1 and len(section_stack) > 1:
section_stack.pop()
i += 1
continue
if content[i] == "(":
depth += 1
i += 1
continue
i += 1
return sections, field_paths, content
def find_matching_paren(content, open_pos):
depth = 1
i = open_pos + 1
in_string = False
escape_next = False
while i < len(content) and depth > 0:
c = content[i]
if escape_next:
escape_next = False
i += 1
continue
if c == "\\":
escape_next = True
i += 1
continue
if in_string:
if c == "'":
in_string = False
i += 1
continue
if c == "'":
in_string = True
i += 1
continue
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def find_constructor_ranges(content):
ranges = {}
section_stack = []
current_top = None
eq_pos = content.find("= T(")
if eq_pos == -1:
return ranges
i = eq_pos + 4
depth = 1
while i < len(content) and depth > 0:
while i < len(content) and content[i] in " \t\n\r,":
i += 1
if i >= len(content):
break
m = re.match(r"(\w+):\s*\w+\s*\(", content[i:])
if m:
name = m.group(1)
if depth == 1:
current_top = name
section_stack = [name]
else:
section_stack.append(name)
path = ".".join(section_stack)
line_start = content.rfind("\n", 0, i) + 1
indent_match = re.match(r"(\s+)", content[line_start:i])
indent = (indent_match.group(1) if indent_match else " ") + " "
open_pos = i + m.end() - 1
close_pos = find_matching_paren(content, open_pos)
if close_pos != -1:
ranges[path] = {
"open_pos": open_pos,
"close_pos": close_pos,
"indent": indent,
}
i += m.end()
depth += 1
continue
m = re.match(r"(\w+):\s*'((?:[^'\\]|\\.)*)'", content[i:])
if m:
i += m.end()
continue
if content[i] == ")":
depth -= 1
if depth == 1:
current_top = None
section_stack = []
elif depth > 1 and len(section_stack) > 1:
section_stack.pop()
i += 1
continue
if content[i] == "(":
depth += 1
i += 1
continue
i += 1
return ranges
def check_coverage(base_sections, target_sections):
missing = []
empty = []
extra = []
for section, fields in base_sections.items():
target_fields = target_sections.get(section, OrderedDict())
for field, value in fields.items():
if field not in target_fields:
missing.append((section, field))
elif not target_fields[field] and value:
empty.append((section, field))
for section, fields in target_sections.items():
if section not in base_sections:
for field in fields:
extra.append((section, field))
continue
for field in fields:
if field not in base_sections.get(section, {}):
extra.append((section, field))
return missing, empty, extra
def count_total_keys(sections):
return sum(len(fields) for fields in sections.values())
def generate_text_report(results, threshold):
lines = []
lines.append("=" * 72)
lines.append(" 闲言APP 多语言翻译覆盖率报告")
lines.append(f" 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("=" * 72)
lines.append("")
header = f"{'语言':<8} {'总键数':>6} {'已翻译':>6} {'缺失':>6} {'空值':>6} {'覆盖率':>8}"
lines.append(header)
lines.append("-" * len(header))
below_threshold = []
sorted_results = sorted(results, key=lambda x: x["coverage"], reverse=True)
for r in sorted_results:
mark = "" if r["coverage"] >= threshold else ""
line = (
f"{r['lang']:<8} {r['total']:>6} {r['translated']:>6} "
f"{r['missing_count']:>6} {r['empty_count']:>6} {r['coverage']:>7.1f}%{mark}"
)
lines.append(line)
if r["coverage"] < threshold and r["lang"] != "zh_CN":
below_threshold.append(r["lang"])
lines.append("")
if below_threshold:
lines.append(f"⚠️ 以下语言覆盖率低于阈值 {threshold}%: {', '.join(below_threshold)}")
else:
lines.append(f"✅ 所有语言覆盖率均达到阈值 {threshold}%")
lines.append("")
lines.append("=" * 72)
lines.append(" 覆盖率排名")
lines.append("=" * 72)
for idx, r in enumerate(sorted_results, 1):
medal = "🥇" if idx == 1 else "🥈" if idx == 2 else "🥉" if idx == 3 else " "
lines.append(f" {medal} #{idx:<2} {r['lang']:<8} {r['coverage']:.1f}%")
lines.append("")
has_details = any(r["missing"] or r["empty"] for r in results)
if has_details:
lines.append("=" * 72)
lines.append(" 缺失/空值键详情")
lines.append("=" * 72)
for r in sorted_results:
if r["missing"] or r["empty"]:
lines.append(f"")
lines.append(f"── {r['lang']} ({r['file']}) ──")
if r["missing"]:
lines.append(f" 缺失键 ({len(r['missing'])}):")
for section, field in r["missing"]:
lines.append(f" - {section}.{field}")
if r["empty"]:
lines.append(f" 空值键 ({len(r['empty'])}):")
for section, field in r["empty"]:
lines.append(f" - {section}.{field}")
lines.append("")
return "\n".join(lines), below_threshold
def generate_json_report(results, threshold):
below_threshold = [
r["lang"] for r in results if r["coverage"] < threshold and r["lang"] != "zh_CN"
]
report = {
"generated_at": datetime.now().isoformat(),
"threshold": threshold,
"below_threshold": below_threshold,
"ranking": [
{"rank": idx, "lang": r["lang"], "coverage": round(r["coverage"], 1)}
for idx, r in enumerate(sorted(results, key=lambda x: x["coverage"], reverse=True), 1)
],
"languages": {},
}
for r in sorted(results, key=lambda x: x["coverage"], reverse=True):
report["languages"][r["lang"]] = {
"total": r["total"],
"translated": r["translated"],
"missing_count": r["missing_count"],
"empty_count": r["empty_count"],
"extra_count": r["extra_count"],
"coverage": round(r["coverage"], 1),
"missing_keys": [f"{s}.{f}" for s, f in r["missing"]],
"empty_keys": [f"{s}.{f}" for s, f in r["empty"]],
}
return json.dumps(report, ensure_ascii=False, indent=2), below_threshold
def fix_missing_keys(target_filepath, missing_keys, base_sections, base_field_paths, target_content):
constructor_ranges = find_constructor_ranges(target_content)
keys_by_path = OrderedDict()
for section, field in missing_keys:
path_stack = base_field_paths.get((section, field), [section])
path = ".".join(path_stack)
if path not in keys_by_path:
keys_by_path[path] = []
keys_by_path[path].append((field, base_sections[section][field]))
insertions = []
fixed_count = 0
skipped_count = 0
for path, fields in keys_by_path.items():
if path not in constructor_ranges:
skipped_count += len(fields)
continue
range_info = constructor_ranges[path]
close_pos = range_info["close_pos"]
indent = range_info["indent"]
insert_text = ""
for field, value in fields:
insert_text += f"{indent}// TODO: translate\n"
insert_text += f"{indent}{field}: '{value}',\n"
fixed_count += 1
insertions.append((close_pos, insert_text))
insertions.sort(key=lambda x: x[0], reverse=True)
for pos, text in insertions:
target_content = target_content[:pos] + text + target_content[pos:]
with open(target_filepath, "w", encoding="utf-8") as f:
f.write(target_content)
return fixed_count, skipped_count
def main():
parser = argparse.ArgumentParser(description="闲言APP 多语言翻译覆盖率检测")
parser.add_argument(
"--threshold", type=float, default=80,
help="覆盖率阈值(百分比),低于此值返回非零退出码 (默认: 80)",
)
parser.add_argument(
"--json", action="store_true", dest="json_output",
help="输出JSON格式报告",
)
parser.add_argument(
"--fix", action="store_true",
help="自动填充缺失的键(用基准语言值+TODO标记)",
)
args = parser.parse_args()
if not LANG_DIR.exists():
print(f"❌ 语言文件目录不存在: {LANG_DIR}", file=sys.stderr)
sys.exit(1)
base_filepath = LANG_DIR / BASE_LANG_FILE
if not base_filepath.exists():
print(f"❌ 基准语言文件不存在: {base_filepath}", file=sys.stderr)
sys.exit(1)
base_sections, base_field_paths, _ = parse_dart_lang_file(base_filepath)
base_total = count_total_keys(base_sections)
if base_total == 0:
print("❌ 基准语言文件解析失败,未找到任何翻译键", file=sys.stderr)
sys.exit(1)
print(f"📊 基准语言(zh_CN)共 {base_total} 个翻译键,共 {len(base_sections)} 个模块\n")
results = []
for filename, lang_id in LANGUAGE_MAP.items():
filepath = LANG_DIR / filename
if not filepath.exists():
print(f"⚠️ 语言文件不存在: {filename}", file=sys.stderr)
continue
target_sections, _, target_content = parse_dart_lang_file(filepath)
missing, empty, extra = check_coverage(base_sections, target_sections)
translated = base_total - len(missing) - len(empty)
coverage = (translated / base_total * 100) if base_total > 0 else 0
results.append({
"lang": lang_id,
"file": filename,
"total": base_total,
"translated": translated,
"missing_count": len(missing),
"empty_count": len(empty),
"extra_count": len(extra),
"coverage": coverage,
"missing": missing,
"empty": empty,
"extra": extra,
})
if args.fix and lang_id != "zh_CN" and missing:
fixed, skipped = fix_missing_keys(
filepath, missing, base_sections, base_field_paths, target_content,
)
if fixed > 0:
print(f" 🔧 {lang_id}: 已填充 {fixed} 个缺失键")
if skipped > 0:
print(f" ⚠️ {lang_id}: {skipped} 个缺失键无法自动填充(目标构造函数不存在)")
if args.json_output:
report, below = generate_json_report(results, args.threshold)
else:
report, below = generate_text_report(results, args.threshold)
print(report)
if below:
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()