diff --git a/app/helper/locale.py b/app/helper/locale.py index d7e0fd09..dce7f18e 100644 --- a/app/helper/locale.py +++ b/app/helper/locale.py @@ -218,9 +218,31 @@ class LocaleHelper: for pattern, target in cls._load_pattern_matchers(locale): matched = pattern.fullmatch(text) if matched: - return cls._format(target, matched.groupdict()) + return cls._format( + target, + cls._build_pattern_values(locale, matched.groupdict()), + ) return None + @classmethod + def _build_pattern_values(cls, locale: str, values: dict[str, str]) -> dict[str, str]: + """ + 为动态模板补充可选的占位值翻译。 + """ + pattern_values = dict(values) + catalog = cls._load_catalog(locale) + default_catalog = ( + cls._load_catalog(cls.DEFAULT_LOCALE) + if locale != cls.DEFAULT_LOCALE + else catalog + ) + for name, value in values.items(): + translated = cls._lookup_message(catalog, value) + if translated is None and locale != cls.DEFAULT_LOCALE: + translated = cls._lookup_message(default_catalog, value) + pattern_values[f"{name}_i18n"] = translated or value + return pattern_values + @staticmethod @lru_cache(maxsize=16) def _load_pattern_matchers(locale: str) -> list[tuple[re.Pattern[str], str]]: diff --git a/app/locales/en-US.json b/app/locales/en-US.json index aabe3bec..875a818f 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -338,6 +338,9 @@ "工作流": "Workflow", "正在运行": "Running", "等待": "Waiting", + "执行完成": "completed", + "执行中": "running", + "执行失败": "failed", "同步CookieCloud站点": "Sync CookieCloud Sites", "同步媒体服务器": "Sync Media Servers", "订阅元数据更新": "Update Subscription Metadata", @@ -1087,18 +1090,46 @@ "source": "数据表 {name} 清理处理完成", "target": "Data table {name} cleanup completed" }, + { + "source": "{name} 开始执行 ...", + "target": "Starting {name_i18n} ..." + }, { "source": "开始刷新站点资源,共 {count} 个站点 ...", "target": "Starting site resource refresh, {count} sites ..." }, + { + "source": "正在刷新站点资源({index}/{total}){name} ...", + "target": "Refreshing site resources ({index}/{total}) {name} ..." + }, { "source": "开始同步媒体服务器,共 {count} 个 ...", "target": "Starting media server sync, {count} servers ..." }, + { + "source": "正在同步媒体服务器({index}/{total}){name} ...", + "target": "Syncing media server ({index}/{total}) {name} ..." + }, { "source": "媒体服务器 {name} 无可同步媒体库", "target": "Media server {name} has no libraries to sync" }, + { + "source": "正在同步 {server} 媒体库 {library}({finished}/{total})", + "target": "Syncing {server} library {library} ({finished}/{total})" + }, + { + "source": "正在同步 {server} 媒体库 {library}", + "target": "Syncing {server} library {library}" + }, + { + "source": "{server} 媒体库({index}/{total}){library} 同步完成", + "target": "{server} library ({index}/{total}) {library} sync completed" + }, + { + "source": "媒体服务器({index}/{total}){name} 同步完成", + "target": "Media server ({index}/{total}) {name} sync completed" + }, { "source": "开始刷新站点数据,共 {count} 个站点 ...", "target": "Starting site data refresh, {count} sites ..." @@ -1131,6 +1162,10 @@ "source": "开始刷新推荐缓存,共 {count} 个数据分页 ...", "target": "Starting recommendation cache refresh, {count} data pages ..." }, + { + "source": "正在刷新推荐缓存({finished}/{total})...", + "target": "Refreshing recommendation cache ({finished}/{total}) ..." + }, { "source": "正在缓存推荐海报({index}/{total})...", "target": "Caching recommendation posters ({index}/{total}) ..." @@ -1227,6 +1262,10 @@ "source": "资源预处理完成,开始匹配 {count} 个订阅 ...", "target": "Resource preprocessing completed, matching {count} subscriptions ..." }, + { + "source": "正在匹配订阅({index}/{total}){name} ...", + "target": "Matching subscription ({index}/{total}) {name} ..." + }, { "source": "开始更新订阅元数据,共 {count} 个订阅 ...", "target": "Starting subscription metadata update, {count} subscriptions ..." @@ -1263,6 +1302,26 @@ "source": "订阅日历({index}/{total})预缓存完成", "target": "Subscription calendar ({index}/{total}) precache completed" }, + { + "source": "开始执行工作流 {name} ...", + "target": "Starting workflow {name} ..." + }, + { + "source": "工作流动作({finished}/{total}){name} {status}", + "target": "Workflow action ({finished}/{total}) {name} {status_i18n}" + }, + { + "source": "工作流 {name} 执行完成", + "target": "Workflow {name} completed" + }, + { + "source": "{name} 执行完成", + "target": "{name_i18n} completed" + }, + { + "source": "{name} 执行失败", + "target": "{name_i18n} failed" + }, { "source": "{path} 不存在", "target": "{path} does not exist" diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index a4fe6e3a..5dad4d6a 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -338,6 +338,9 @@ "工作流": "工作流", "正在运行": "正在執行", "等待": "等待", + "执行完成": "執行完成", + "执行中": "執行中", + "执行失败": "執行失敗", "同步CookieCloud站点": "同步 CookieCloud 站點", "同步媒体服务器": "同步媒體伺服器", "订阅元数据更新": "訂閱元資料更新", @@ -1087,18 +1090,46 @@ "source": "数据表 {name} 清理处理完成", "target": "資料表 {name} 清理處理完成" }, + { + "source": "{name} 开始执行 ...", + "target": "{name_i18n} 開始執行 ..." + }, { "source": "开始刷新站点资源,共 {count} 个站点 ...", "target": "開始重新整理站點資源,共 {count} 個站點 ..." }, + { + "source": "正在刷新站点资源({index}/{total}){name} ...", + "target": "正在重新整理站點資源({index}/{total}){name} ..." + }, { "source": "开始同步媒体服务器,共 {count} 个 ...", "target": "開始同步媒體伺服器,共 {count} 個 ..." }, + { + "source": "正在同步媒体服务器({index}/{total}){name} ...", + "target": "正在同步媒體伺服器({index}/{total}){name} ..." + }, { "source": "媒体服务器 {name} 无可同步媒体库", "target": "媒體伺服器 {name} 無可同步媒體庫" }, + { + "source": "正在同步 {server} 媒体库 {library}({finished}/{total})", + "target": "正在同步 {server} 媒體庫 {library}({finished}/{total})" + }, + { + "source": "正在同步 {server} 媒体库 {library}", + "target": "正在同步 {server} 媒體庫 {library}" + }, + { + "source": "{server} 媒体库({index}/{total}){library} 同步完成", + "target": "{server} 媒體庫({index}/{total}){library} 同步完成" + }, + { + "source": "媒体服务器({index}/{total}){name} 同步完成", + "target": "媒體伺服器({index}/{total}){name} 同步完成" + }, { "source": "开始刷新站点数据,共 {count} 个站点 ...", "target": "開始重新整理站點資料,共 {count} 個站點 ..." @@ -1131,6 +1162,10 @@ "source": "开始刷新推荐缓存,共 {count} 个数据分页 ...", "target": "開始重新整理推薦快取,共 {count} 個資料分頁 ..." }, + { + "source": "正在刷新推荐缓存({finished}/{total})...", + "target": "正在重新整理推薦快取({finished}/{total})..." + }, { "source": "正在缓存推荐海报({index}/{total})...", "target": "正在快取推薦海報({index}/{total})..." @@ -1227,6 +1262,10 @@ "source": "资源预处理完成,开始匹配 {count} 个订阅 ...", "target": "資源預處理完成,開始匹配 {count} 個訂閱 ..." }, + { + "source": "正在匹配订阅({index}/{total}){name} ...", + "target": "正在匹配訂閱({index}/{total}){name} ..." + }, { "source": "开始更新订阅元数据,共 {count} 个订阅 ...", "target": "開始更新訂閱元資料,共 {count} 個訂閱 ..." @@ -1263,6 +1302,26 @@ "source": "订阅日历({index}/{total})预缓存完成", "target": "訂閱日曆({index}/{total})預快取完成" }, + { + "source": "开始执行工作流 {name} ...", + "target": "開始執行工作流 {name} ..." + }, + { + "source": "工作流动作({finished}/{total}){name} {status}", + "target": "工作流動作({finished}/{total}){name} {status_i18n}" + }, + { + "source": "工作流 {name} 执行完成", + "target": "工作流 {name} 執行完成" + }, + { + "source": "{name} 执行完成", + "target": "{name_i18n} 執行完成" + }, + { + "source": "{name} 执行失败", + "target": "{name_i18n} 執行失敗" + }, { "source": "{path} 不存在", "target": "{path} 不存在" diff --git a/tests/test_locale_helper.py b/tests/test_locale_helper.py index b1240204..06f79a80 100644 --- a/tests/test_locale_helper.py +++ b/tests/test_locale_helper.py @@ -53,6 +53,62 @@ def _sample_message_expression(value: ast.AST) -> list[str]: return [] +def _sample_progress_text_expression(value: ast.AST) -> list[str]: + """从进度 text 表达式中生成用于翻译校验的样本文本。""" + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return [value.value] + if isinstance(value, ast.JoinedStr): + samples = [""] + placeholder_index = 0 + for item in value.values: + if isinstance(item, ast.Constant) and isinstance(item.value, str): + choices = [item.value] + elif isinstance(item, ast.FormattedValue): + choices = [ + sample + for sample in _sample_progress_text_expression(item.value) + if _has_chinese(sample) + ] + if not choices: + placeholder_index += 1 + choices = [f"样例{placeholder_index}"] + else: + placeholder_index += 1 + choices = [f"样例{placeholder_index}"] + samples = [prefix + choice for prefix in samples for choice in choices] + return [sample for sample in samples if _has_chinese(sample)] + if isinstance(value, ast.IfExp): + return ( + _sample_progress_text_expression(value.body) + + _sample_progress_text_expression(value.orelse) + ) + if isinstance(value, ast.BoolOp): + samples = [] + for item in value.values: + samples.extend(_sample_progress_text_expression(item)) + return samples + if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add): + left_samples = _sample_progress_text_expression(value.left) + right_samples = _sample_progress_text_expression(value.right) + if left_samples and right_samples: + return [left + right for left in left_samples for right in right_samples] + if left_samples: + return [f"{left}样例" for left in left_samples] + if right_samples: + return [f"样例{right}" for right in right_samples] + return [] + + +def _is_progress_text_call(node: ast.Call) -> bool: + """判断调用是否可能写入前端展示的进度文本。""" + func = ast.unparse(node.func) + return ( + func.endswith("progress_callback") + or func.endswith(".update") + or func.endswith(".end") + ) + + def test_locale_helper_normalizes_supported_aliases(): """语言别名应规范化为项目支持的语言标识。""" assert LocaleHelper.normalize_locale("en") == "en-US" @@ -262,6 +318,55 @@ def test_schedule_info_auto_fills_i18n_display_fields(): assert schedule.progress_detail.error_i18n == "Background service does not exist" +def test_scheduler_progress_patterns_translate_dynamic_texts(): + """定时任务进度动态模板应翻译固定词并保留业务变量。""" + assert ( + LocaleHelper.translate_text("同步媒体服务器 开始执行 ...", locale="en-US") + == "Starting Sync Media Servers ..." + ) + assert ( + LocaleHelper.translate_text("同步媒体服务器 执行完成", locale="en-US") + == "Sync Media Servers completed" + ) + assert ( + LocaleHelper.translate_text("同步媒体服务器 执行失败", locale="zh-TW") + == "同步媒體伺服器 執行失敗" + ) + assert ( + LocaleHelper.translate_text("工作流动作(1/3)动作A 执行完成", locale="en-US") + == "Workflow action (1/3) 动作A completed" + ) + + +def test_scheduler_progress_texts_have_english_translations(): + """定时任务相关进度文案应有英文翻译,避免前端切英文后回退中文。""" + untranslated = [] + progress_paths = [ + Path("app/scheduler.py"), + Path("app/chain/torrents.py"), + Path("app/chain/mediaserver.py"), + Path("app/chain/site.py"), + Path("app/chain/recommend.py"), + Path("app/chain/transfer.py"), + Path("app/chain/subscribe.py"), + Path("app/chain/workflow.py"), + ] + for path in progress_paths: + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not _is_progress_text_call(node): + continue + for keyword in node.keywords: + if keyword.arg != "text": + continue + for message in _sample_progress_text_expression(keyword.value): + translated = LocaleHelper.translate_text(message, locale="en-US") + if translated == message: + untranslated.append(f"{path}:{keyword.value.lineno}:{message}") + + assert untranslated == [] + + def test_api_endpoint_literal_messages_have_english_translations(): """接口直接返回的中文 message 应有英文翻译,避免前端切英文后回退中文。""" untranslated = []