use super::*; pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> { let (resource_root, release_id) = current_official_release(options)?; let parse_config = OfficialParseConfig::new(&resource_root, options.config.unzip_command.clone()) .with_force(options.config.force); let parse_report = OfficialParseCacheService::new() .run(&parse_config) .map_err(anyhow::Error::msg)?; let queue_report = write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?; let data = serde_json::json!({ "official_release_id": release_id, "resource_root": resource_root, "forced": options.config.force, "parse": parse_report, "translation_queue": queue_report, }); print_report( options.output_format, &CommandReport { command: "parse", status: "completed", message: "官方资源解析已执行", data, }, ) } pub(super) fn run_parse_clear_cache(options: &CliOptions) -> anyhow::Result<()> { let (resource_root, release_id) = current_official_release(options)?; let artifact_names = [ OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, CROWDIN_TEXTUNIT_QUEUE_FILE, ]; let mut removed = Vec::new(); for name in artifact_names { let path = resource_root.join(name); if remove_regenerable_file(&path)? { removed.push(path); } } let data = serde_json::json!({ "official_release_id": release_id, "resource_root": resource_root, "removed": removed, "translation_task_repository_preserved": true, }); print_report( options.output_format, &CommandReport { command: "parse-clear-cache", status: "cleared", message: "当前官方 release 的可再生解析缓存和翻译队列已清理", data, }, ) } pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> { let (resource_root, release_id) = current_official_release(options)?; let queue = write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?; let exported = options .translation_file .as_ref() .map(|path| { export_translation_workbench(&resource_root, release_id.clone(), path).map( |workbench| { serde_json::json!({ "path": path, "entry_count": workbench.entries.len(), }) }, ) }) .transpose()?; let data = serde_json::json!({ "official_release_id": release_id, "resource_root": resource_root, "queue": queue, "workbench": exported, "provider": "offline", "note": "当前 translate 只生成/刷新离线队列和可编辑工作台,不调用外部翻译 provider", }); print_report( options.output_format, &CommandReport { command: "translate", status: "queued", message: "翻译离线队列已刷新", data, }, ) } pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<()> { let path = options .translation_file .as_ref() .ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?; let (resource_root, release_id) = current_official_release(options)?; let workbench = read_translation_workbench(path)?; let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?; let data = serde_json::json!({ "official_release_id": release_id, "resource_root": resource_root, "translation_file": path, "validation": validation, }); print_json_value(options.output_format, &data) } pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { let path = options .translation_file .as_ref() .ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-file"))?; let text = match (&options.translation_text, &options.translation_text_file) { (Some(_), Some(_)) => { return Err(anyhow::anyhow!( "--translated-text 与 --translated-file 只能指定一个" )) } (Some(text), None) => text.clone(), (None, Some(path)) => String::from_utf8( read_file_no_symlink(path, "翻译文本文件") .map_err(anyhow::Error::msg)? .ok_or_else(|| anyhow::anyhow!("翻译文本文件不存在:{}", path.display()))?, )?, (None, None) => { return Err(anyhow::anyhow!( "translation-set 必须指定 --translated-text 或 --translated-file" )) } }; let entry_id = options .translation_id .as_deref() .ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?; let entry = set_translation(path, entry_id, text)?; let data = serde_json::json!({ "translation_file": path, "entry": entry, }); print_report( options.output_format, &CommandReport { command: "translation-set", status: "updated", message: "翻译工作台条目已更新", data, }, ) } pub(super) fn run_translation_get(options: &CliOptions) -> anyhow::Result<()> { let path = options .translation_file .as_ref() .ok_or_else(|| anyhow::anyhow!("i18n get 必须指定 --translation-file"))?; let entry_id = options .translation_id .as_deref() .ok_or_else(|| anyhow::anyhow!("i18n get 必须指定 --translation-id"))?; let entry = get_translation_entry(path, entry_id)?; let data = serde_json::json!({ "translation_file": path, "entry": entry, }); print_report( options.output_format, &CommandReport { command: "translation-get", status: "ok", message: "翻译工作台条目已读取", data, }, ) } pub(super) fn run_translation_unset(options: &CliOptions) -> anyhow::Result<()> { let path = options .translation_file .as_ref() .ok_or_else(|| anyhow::anyhow!("i18n unset 必须指定 --translation-file"))?; let entry_id = options .translation_id .as_deref() .ok_or_else(|| anyhow::anyhow!("i18n unset 必须指定 --translation-id"))?; let entry = unset_translation(path, entry_id)?; let data = serde_json::json!({ "translation_file": path, "entry": entry, }); print_report( options.output_format, &CommandReport { command: "translation-unset", status: "updated", message: "翻译工作台条目已恢复为未审核", data, }, ) } pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Result<()> { let task_id = options .query_task_id .as_deref() .ok_or_else(|| anyhow::anyhow!("i18n task update 必须指定 --task-id"))?; let status = options .query_task_status .as_deref() .ok_or_else(|| anyhow::anyhow!("i18n task update 必须指定 --task-status"))?; let mut params = serde_json::Map::from_iter([ ("task_id".to_string(), serde_json::json!(task_id)), ("status".to_string(), serde_json::json!(status)), ]); if let Some(reason) = options.translation_failure_reason.as_deref() { params.insert("failure_reason".to_string(), serde_json::json!(reason)); } if let Some(provider_run_id) = options.translation_provider_run_id.as_deref() { params.insert( "provider_run_id".to_string(), serde_json::json!(provider_run_id), ); } let report = update_translation_task_status_report( &options.state_dir, Some(&serde_json::Value::Object(params)), )?; print_json_value(options.output_format, &report) } pub(super) fn run_translation_proofread(options: &CliOptions) -> anyhow::Result<()> { if daemon_rpc_available(&options.state_dir) && options.resource_root.is_none() && !options.output_explicit { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_TRANSLATION_PROOFREAD, None)?; print_json_value(options.output_format, &report)?; return Ok(()); } let (_, official_release_id) = current_official_release(options)?; let report = bat_infrastructure::mark_localized_manual_proofreading( &options.config.localized_output_root, &official_release_id, )?; print_report(options.output_format, &report) } pub(super) fn run_translation_worker(options: &CliOptions) -> anyhow::Result<()> { let resource_root = options .resource_root .clone() .map(|path| lexical_absolute(&path).map_err(anyhow::Error::msg)) .transpose()? .unwrap_or(active_official_resource_root(&options.config.output_root)?); let provider = options .translation_provider .as_deref() .unwrap_or(TranslationProviderKind::Mock.as_str()); let provider = TranslationProviderKind::parse(provider) .ok_or_else(|| anyhow::anyhow!("i18n worker run 的 provider 无效:{provider}"))?; let config = TranslationWorkerConfig { provider, fixture_path: options.translation_fixture.clone(), concurrency: options.worker_concurrency, max_attempts: options.worker_max_attempts, lease_seconds: options.worker_lease_seconds, retry_backoff: options.worker_retry_backoff, max_tasks: options.worker_max_tasks, worker_id: options .worker_id .clone() .unwrap_or_else(|| format!("bat-worker-{}", std::process::id())), }; config.validate()?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; let report = runtime.block_on(bat_infrastructure::run_translation_worker_at( &resource_root, &config, ))?; print_report(options.output_format, &report) } pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> { let spec = options .repack_spec .as_ref() .ok_or_else(|| anyhow::anyhow!("repack 必须指定 --repack-spec"))?; let report = repack_bundle(spec)?; print_report(options.output_format, &report) } pub(super) fn publish_localized_report( options: &CliOptions, ) -> anyhow::Result { let (resource_root, official_release_id) = current_official_release(options)?; let workbench = if options.translation_from_worker { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; runtime.block_on(completed_worker_translation_workbench( &resource_root, &official_release_id, ))? } else { let translation_file = options .translation_file .as_ref() .ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?; read_translation_workbench(translation_file)? }; if workbench.official_release_id != official_release_id { return Err(anyhow::anyhow!( "翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出", workbench.official_release_id, official_release_id )); } let expected_root = lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?; if workbench.official_resource_root != expected_root { return Err(anyhow::anyhow!( "翻译工作台资源根目录与当前 release 不一致;请重新导出" )); } let operations = localized_patch_operations(&resource_root, &workbench)?; let localized_release_id = options.localized_release_id.clone().or_else(|| { options .config .force .then(|| format!("{}-manual-{}", official_release_id, unix_seconds_now())) }); let mut config = LocalizedPatchConfig::new( resource_root, options.config.localized_output_root.clone(), official_release_id, Vec::new(), ) .with_operations(operations) .with_force(options.config.force); if let Some(release_id) = localized_release_id { config = config.with_localized_release_id(release_id); } LocalizedPatchService::new().publish(&config) } pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> { let report = publish_localized_report(options)?; print_report(options.output_format, &report) } pub(super) fn localized_rollback_report( options: &CliOptions, ) -> anyhow::Result { LocalizedPatchService::new().rollback( &options.config.localized_output_root, options.localized_release_id.as_deref(), ) } pub(super) fn run_localized_rollback(options: &CliOptions) -> anyhow::Result<()> { let report = localized_rollback_report(options)?; print_report(options.output_format, &report) } pub(super) fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> { let state = read_version_state(&options.config.version_state_path())?; let resource_root = if let Some(resource_root) = options.resource_root.clone() { lexical_absolute(&resource_root).map_err(anyhow::Error::msg)? } else { state .as_ref() .and_then(|state| state.current_completed_version.as_ref()) .map(|record| record.resource_root.clone()) .unwrap_or(active_official_resource_root(&options.config.output_root)?) }; let release_id = state .as_ref() .and_then(|state| state.current_completed_version.as_ref()) .filter(|_| options.resource_root.is_none()) .map(|record| record.id.clone()) .or_else(|| { resource_root .file_name() .and_then(|name| name.to_str()) .map(str::to_string) }) .ok_or_else(|| anyhow::anyhow!("无法从当前官方资源根目录确定 release id"))?; if read_download_manifest_at(&resource_root) .map_err(anyhow::Error::msg)? .is_none() { return Err(anyhow::anyhow!( "当前官方 release 缺少官方下载 manifest:{}", resource_root.display() )); } Ok((resource_root, release_id)) } fn remove_regenerable_file(path: &Path) -> anyhow::Result { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(error.into()), }; if metadata.file_type().is_symlink() { return Err(anyhow::anyhow!( "拒绝删除符号链接形式的可再生文件:{}", path.display() )); } if !metadata.is_file() { return Err(anyhow::anyhow!( "可再生缓存路径不是普通文件:{}", path.display() )); } fs::remove_file(path)?; Ok(true) }