mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 13:54:53 +08:00
feat(bat): 补全工作流校验与调度过滤
新增 parse clear-cache 和 i18n validate,补齐 schedule 的作用域过滤与单轮执行上限,并将列表过滤参数暴露给 bat-api dashboard。同步 RPC、OpenAPI、用户文档和回归测试。 Refs #43
This commit is contained in:
@@ -13,9 +13,9 @@ use bat_infrastructure::{
|
||||
read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state,
|
||||
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
|
||||
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
|
||||
validate_output_root, validate_runtime_state_dir, write_file_atomic,
|
||||
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
|
||||
LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole,
|
||||
validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
|
||||
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
|
||||
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole,
|
||||
OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig,
|
||||
OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource,
|
||||
OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress,
|
||||
@@ -24,10 +24,10 @@ use bat_infrastructure::{
|
||||
PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport,
|
||||
SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus,
|
||||
UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
|
||||
UnityFsTextAssetPatchParams, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE,
|
||||
LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY,
|
||||
MIN_DOWNLOAD_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE,
|
||||
PRIVATE_FILE_MODE,
|
||||
UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE,
|
||||
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE,
|
||||
OFFICIAL_TEXTUNIT_INDEX_FILE, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, PRIVATE_FILE_MODE,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -79,7 +79,8 @@ use translation_query::{
|
||||
update_translation_task_status_report,
|
||||
};
|
||||
use workflow_commands::{
|
||||
run_parse_once, run_publish_localized, run_repack, run_translate_once, run_translation_set,
|
||||
run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once,
|
||||
run_translation_set, run_translation_validate,
|
||||
};
|
||||
|
||||
const EXIT_ERROR: i32 = 1;
|
||||
@@ -192,10 +193,18 @@ fn run() -> anyhow::Result<i32> {
|
||||
run_repeated_workflow(&options, "parse", run_parse_once)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::ParseClearCache => {
|
||||
run_parse_clear_cache(&options)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Translate => {
|
||||
run_repeated_workflow(&options, "translate", run_translate_once)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::TranslationValidate => {
|
||||
run_translation_validate(&options)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::TranslationSet => {
|
||||
run_translation_set(&options)?;
|
||||
Ok(0)
|
||||
@@ -356,6 +365,7 @@ struct CliOptions {
|
||||
schedule_delay: Option<Duration>,
|
||||
schedule_every: Option<Duration>,
|
||||
schedule_count: Option<usize>,
|
||||
schedule_max_runs: Option<usize>,
|
||||
schedule_args: Vec<String>,
|
||||
schedule_clear_args: bool,
|
||||
schedule_clear_every: bool,
|
||||
@@ -438,6 +448,7 @@ impl Default for CliOptions {
|
||||
schedule_delay: None,
|
||||
schedule_every: None,
|
||||
schedule_count: None,
|
||||
schedule_max_runs: None,
|
||||
schedule_args: Vec::new(),
|
||||
schedule_clear_args: false,
|
||||
schedule_clear_every: false,
|
||||
@@ -504,7 +515,9 @@ enum CliCommand {
|
||||
Run,
|
||||
Pull,
|
||||
Parse,
|
||||
ParseClearCache,
|
||||
Translate,
|
||||
TranslationValidate,
|
||||
TranslationSet,
|
||||
Repack,
|
||||
PublishLocalized,
|
||||
@@ -1762,11 +1775,31 @@ fn dispatch_rpc_method(
|
||||
"resource.state",
|
||||
build_resource_state_report(state_dir),
|
||||
),
|
||||
RPC_METHOD_SCHEDULE_LIST => rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_SCHEDULE_LIST,
|
||||
schedule_commands::schedule_list_report(state_dir),
|
||||
),
|
||||
RPC_METHOD_SCHEDULE_LIST => {
|
||||
let params = request
|
||||
.params
|
||||
.as_ref()
|
||||
.filter(|params| !params.is_null())
|
||||
.map(|params| serde_json::from_value(params.clone()))
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
ApiError::new(
|
||||
ErrorCode::RPC_INVALID_PARAMS,
|
||||
RPC_METHOD_SCHEDULE_LIST,
|
||||
format!("params 无效:{error}"),
|
||||
)
|
||||
});
|
||||
let params = match params {
|
||||
Ok(Some(params)) => params,
|
||||
Ok(None) => schedule_commands::ScheduleListRequest::default(),
|
||||
Err(error) => return rpc_envelope_error(request_id, error),
|
||||
};
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_SCHEDULE_LIST,
|
||||
schedule_commands::schedule_list_report_with_request(state_dir, params),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_SCHEDULE_ADD => {
|
||||
let params = match rpc_struct_params::<schedule_commands::ScheduleMutationRequest>(
|
||||
request.params.as_ref(),
|
||||
@@ -6754,6 +6787,16 @@ fn parse_args_with_env(
|
||||
options.schedule_count = Some(value);
|
||||
options.schedule_option_explicit = true;
|
||||
}
|
||||
"--schedule-max-runs" => {
|
||||
let value = next_option_value(&mut args, &flag)?
|
||||
.parse::<usize>()
|
||||
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?;
|
||||
if value == 0 {
|
||||
return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0"));
|
||||
}
|
||||
options.schedule_max_runs = Some(value);
|
||||
options.schedule_option_explicit = true;
|
||||
}
|
||||
"--schedule-arg" => {
|
||||
options
|
||||
.schedule_args
|
||||
@@ -7120,11 +7163,40 @@ fn parse_args_with_env(
|
||||
));
|
||||
}
|
||||
}
|
||||
CliCommand::ParseClearCache => {
|
||||
if options.watch || options.daemon || options.daemon_child {
|
||||
return Err(anyhow::anyhow!("parse clear-cache 只支持单次执行"));
|
||||
}
|
||||
if !options.config.force {
|
||||
return Err(anyhow::anyhow!(
|
||||
"parse clear-cache 是破坏性操作,必须显式指定 --force"
|
||||
));
|
||||
}
|
||||
if options.config.dry_run || options.run_count.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"parse clear-cache 不支持 --dry-run 或 --run-count"
|
||||
));
|
||||
}
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
CliCommand::TranslationValidate => {
|
||||
if options.watch || options.daemon || options.daemon_child {
|
||||
return Err(anyhow::anyhow!("i18n validate 只支持单次执行"));
|
||||
}
|
||||
if options.config.force || options.config.dry_run || options.run_count.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"i18n validate 不支持 --force、--dry-run 或 --run-count"
|
||||
));
|
||||
}
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
CliCommand::TranslationSet | CliCommand::Repack => {
|
||||
if options.watch || options.daemon || options.daemon_child {
|
||||
return Err(anyhow::anyhow!("translation set/repack 只支持单次执行"));
|
||||
}
|
||||
if options.config.force || options.sync_option_explicit {
|
||||
if options.config.force || options.sync_option_explicit || options.run_count.is_some() {
|
||||
return Err(anyhow::anyhow!("translation set/repack 不接受资源同步选项"));
|
||||
}
|
||||
options.progress = false;
|
||||
@@ -7151,6 +7223,11 @@ fn parse_args_with_env(
|
||||
if options.interval_explicit && !matches!(options.command, CliCommand::ScheduleRun) {
|
||||
return Err(anyhow::anyhow!("--interval 只适用于 schedule run 的轮询"));
|
||||
}
|
||||
if options.schedule_max_runs.is_some()
|
||||
&& !matches!(options.command, CliCommand::ScheduleRun)
|
||||
{
|
||||
return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run"));
|
||||
}
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
@@ -7294,6 +7371,7 @@ fn parse_parse_command(
|
||||
"status" => CliCommand::ParseStatus,
|
||||
"text-units" => CliCommand::ParseTextUnits,
|
||||
"errors" => CliCommand::ParseErrors,
|
||||
"clear-cache" => CliCommand::ParseClearCache,
|
||||
"schedule" => {
|
||||
options.schedule_group = Some("parse".to_string());
|
||||
return parse_schedule_command(args, options);
|
||||
@@ -7315,6 +7393,7 @@ fn parse_translation_command(
|
||||
"run" => CliCommand::Translate,
|
||||
"export" => CliCommand::Translate,
|
||||
"set" => CliCommand::TranslationSet,
|
||||
"validate" => CliCommand::TranslationValidate,
|
||||
"publish" => CliCommand::PublishLocalized,
|
||||
"tasks" => CliCommand::TranslationTasks,
|
||||
"handoff" => CliCommand::TranslationHandoff,
|
||||
@@ -7435,10 +7514,12 @@ fn print_usage(binary: &str) {
|
||||
eprintln!(" res pull Pull official resources once or repeatedly");
|
||||
eprintln!(" res schedule Manage resource pull schedules (CLI/RPC/dashboard)");
|
||||
eprintln!(" parse run Parse current official release");
|
||||
eprintln!(" parse clear-cache Clear regenerable parse and translation queue files");
|
||||
eprintln!(" parse repack Repack a UnityFS bundle from a JSON spec");
|
||||
eprintln!(" i18n run Refresh offline translation work");
|
||||
eprintln!(" i18n export Export an editable translation workbench");
|
||||
eprintln!(" i18n set Update one translation workbench entry");
|
||||
eprintln!(" i18n validate Validate workbench against the current official release");
|
||||
eprintln!(" i18n publish Publish a localized release");
|
||||
eprintln!(" i18n schedule Manage translation schedules");
|
||||
eprintln!(" refresh Run one update check, or ask a live daemon to refresh");
|
||||
@@ -7589,6 +7670,7 @@ fn print_usage(binary: &str) {
|
||||
eprintln!(" --schedule-delay <DURATION> Delay first execution from now");
|
||||
eprintln!(" --schedule-every <DURATION> Period between executions");
|
||||
eprintln!(" --schedule-count <N> Bounded execution count");
|
||||
eprintln!(" --schedule-max-runs <N> Maximum plans executed by one schedule run");
|
||||
eprintln!(" --schedule-arg <ARG> Argument passed to scheduled child command");
|
||||
eprintln!(" --schedule-clear-args Clear args during schedule update");
|
||||
eprintln!(" --schedule-clear-every Convert a periodic plan to one-shot");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use crate::app::schedule_commands::read_schedule_file;
|
||||
use crate::app::schedule_commands::{
|
||||
read_schedule_file, schedule_add_report, schedule_list_report_with_request,
|
||||
ScheduleListRequest, ScheduleMutationRequest,
|
||||
};
|
||||
|
||||
fn parse(values: &[&str]) -> anyhow::Result<CliOptions> {
|
||||
parse_args_from(values.iter().map(|value| value.to_string()))
|
||||
@@ -201,6 +204,30 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::Repack);
|
||||
assert_eq!(options.repack_spec, Some(PathBuf::from("/tmp/repack.json")));
|
||||
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"parse",
|
||||
"clear-cache",
|
||||
"--resource-root",
|
||||
"/tmp/official-release",
|
||||
"--force",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::ParseClearCache);
|
||||
assert!(options.config.force);
|
||||
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"validate",
|
||||
"--resource-root",
|
||||
"/tmp/official-release",
|
||||
"--translation-file",
|
||||
"/tmp/workbench.json",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::TranslationValidate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -233,16 +260,47 @@ fn grouped_command_long_aliases_and_schedule_options_are_accepted() {
|
||||
assert_eq!(options.schedule_count, Some(4));
|
||||
assert_eq!(options.schedule_args, vec!["--auto-discover"]);
|
||||
|
||||
let options = parse(&["bat", "translate", "schedule", "run", "--force"]).unwrap();
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"translate",
|
||||
"schedule",
|
||||
"run",
|
||||
"--force",
|
||||
"--schedule-max-runs",
|
||||
"2",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::ScheduleRun);
|
||||
assert_eq!(options.schedule_group.as_deref(), Some("i18n"));
|
||||
assert!(options.config.force);
|
||||
assert_eq!(options.schedule_max_runs, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_workflow_requires_explicit_interval_after_first_run() {
|
||||
assert!(parse(&["bat", "parse", "run", "--run-count", "2"]).is_err());
|
||||
assert!(parse(&["bat", "parse", "run", "--run-count", "0"]).is_err());
|
||||
assert!(parse(&["bat", "parse", "clear-cache"]).is_err());
|
||||
assert!(parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"validate",
|
||||
"--force",
|
||||
"--translation-file",
|
||||
"/tmp/workbench.json"
|
||||
])
|
||||
.is_err());
|
||||
assert!(parse(&[
|
||||
"bat",
|
||||
"res",
|
||||
"schedule",
|
||||
"add",
|
||||
"--schedule-id",
|
||||
"pull",
|
||||
"--schedule-max-runs",
|
||||
"1"
|
||||
])
|
||||
.is_err());
|
||||
assert!(parse(&["bat", "parse", "run", "--watch", "--run-count", "2"]).is_err());
|
||||
assert!(parse(&["bat", "parse", "run", "--interval", "1m"]).is_err());
|
||||
assert!(parse(&[
|
||||
@@ -346,6 +404,59 @@ fn schedule_crud_persists_and_updates_a_workflow_plan() {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_scope_filters_and_protects_cross_workflow_mutations() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path();
|
||||
schedule_add_report(
|
||||
state_dir,
|
||||
ScheduleMutationRequest {
|
||||
id: Some("res-pull".to_string()),
|
||||
group: Some("res".to_string()),
|
||||
action: Some("pull".to_string()),
|
||||
delay_seconds: Some(1),
|
||||
..ScheduleMutationRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
schedule_add_report(
|
||||
state_dir,
|
||||
ScheduleMutationRequest {
|
||||
id: Some("parse-run".to_string()),
|
||||
group: Some("parse".to_string()),
|
||||
action: Some("run".to_string()),
|
||||
delay_seconds: Some(1),
|
||||
..ScheduleMutationRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = schedule_list_report_with_request(
|
||||
state_dir,
|
||||
ScheduleListRequest {
|
||||
group: Some("res".to_string()),
|
||||
..ScheduleListRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report["schedules"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(report["schedules"][0]["id"], "res-pull");
|
||||
|
||||
let cross_workflow_remove = parse(&[
|
||||
"bat",
|
||||
"res",
|
||||
"schedule",
|
||||
"remove",
|
||||
"--state-dir",
|
||||
state_dir.to_str().unwrap(),
|
||||
"--schedule-id",
|
||||
"parse-run",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(run_schedule_remove(&cross_workflow_remove).is_err());
|
||||
assert_eq!(read_schedule_file(state_dir).unwrap().schedules.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_schedule_crud_uses_shared_state_file() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
@@ -383,6 +494,24 @@ fn dispatch_schedule_crud_uses_shared_state_file() {
|
||||
let list = serde_json::to_value(list).unwrap();
|
||||
assert_eq!(list["ok"], true);
|
||||
assert_eq!(list["data"]["schedules"][0]["id"], "rpc-pull");
|
||||
|
||||
let filtered = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"schedule.list",
|
||||
Some(serde_json::json!({
|
||||
"group": "res",
|
||||
"enabled": true
|
||||
})),
|
||||
),
|
||||
state_dir,
|
||||
&control,
|
||||
&test_task_context(),
|
||||
"req-schedule-list-filtered".to_string(),
|
||||
);
|
||||
let filtered = serde_json::to_value(filtered).unwrap();
|
||||
assert_eq!(filtered["ok"], true);
|
||||
assert_eq!(filtered["data"]["query"]["group"], "res");
|
||||
assert_eq!(filtered["data"]["schedules"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -116,18 +116,37 @@ pub(super) struct ScheduleMutationRequest {
|
||||
pub(super) enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub(super) struct ScheduleListRequest {
|
||||
#[serde(default, alias = "schedule_id")]
|
||||
pub(super) id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub(super) struct ScheduleRunRequest {
|
||||
#[serde(default, alias = "schedule_id")]
|
||||
pub(super) id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) force: bool,
|
||||
#[serde(default)]
|
||||
pub(super) max_runs: Option<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let request = ScheduleListRequest {
|
||||
id: options.schedule_id.clone(),
|
||||
group: options.schedule_group.clone(),
|
||||
enabled: options.schedule_enabled,
|
||||
};
|
||||
print_json_value(
|
||||
options.output_format,
|
||||
&schedule_list_report(&options.state_dir)?,
|
||||
&schedule_list_report_with_request(&options.state_dir, request)?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,7 +182,9 @@ pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> {
|
||||
loop {
|
||||
let request = ScheduleRunRequest {
|
||||
id: options.schedule_id.clone(),
|
||||
group: options.schedule_group.clone(),
|
||||
force: options.config.force,
|
||||
max_runs: options.schedule_max_runs,
|
||||
};
|
||||
print_json_value(
|
||||
options.output_format,
|
||||
@@ -176,17 +197,37 @@ pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn schedule_list_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
|
||||
pub(super) fn schedule_list_report_with_request(
|
||||
state_dir: &Path,
|
||||
request: ScheduleListRequest,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let _guard = SCHEDULE_FILE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
|
||||
let file = read_schedule_file(state_dir)?;
|
||||
let group = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?;
|
||||
let schedules = file
|
||||
.schedules
|
||||
.iter()
|
||||
.filter(|entry| request.id.as_deref().is_none_or(|id| id == entry.id))
|
||||
.filter(|entry| group.as_deref().is_none_or(|group| group == entry.group))
|
||||
.filter(|entry| {
|
||||
request
|
||||
.enabled
|
||||
.is_none_or(|enabled| enabled == entry.enabled)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({
|
||||
"command": "schedule-list",
|
||||
"status": "ok",
|
||||
"state_file": schedule_file_path(state_dir),
|
||||
"schedules": file.schedules,
|
||||
"query": request,
|
||||
"schedules": schedules,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -312,11 +353,27 @@ pub(super) fn schedule_remove_report(
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule remove 必须指定 --schedule-id"))?;
|
||||
let mut file = read_schedule_file(state_dir)?;
|
||||
let before = file.schedules.len();
|
||||
file.schedules.retain(|entry| entry.id != id);
|
||||
if file.schedules.len() == before {
|
||||
return Err(anyhow::anyhow!("schedule 不存在:{id}"));
|
||||
let group = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?;
|
||||
let index = file
|
||||
.schedules
|
||||
.iter()
|
||||
.position(|entry| entry.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?;
|
||||
if let Some(group) = group {
|
||||
if file.schedules[index].group != group {
|
||||
return Err(anyhow::anyhow!(
|
||||
"schedule {} 属于 {},不能从 {} 二级命令删除",
|
||||
id,
|
||||
file.schedules[index].group,
|
||||
group
|
||||
));
|
||||
}
|
||||
}
|
||||
file.schedules.remove(index);
|
||||
write_schedule_file(state_dir, &file)?;
|
||||
Ok(serde_json::json!({
|
||||
"command": "schedule-remove",
|
||||
@@ -337,13 +394,37 @@ pub(super) fn schedule_run_report(
|
||||
let now = unix_seconds_now();
|
||||
let selected_id = request.id.as_deref();
|
||||
let mut file = read_schedule_file(state_dir)?;
|
||||
let group = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?;
|
||||
if let (Some(id), Some(group)) = (selected_id, group.as_deref()) {
|
||||
if let Some(entry) = file.schedules.iter().find(|entry| entry.id == id) {
|
||||
if entry.group != group {
|
||||
return Err(anyhow::anyhow!(
|
||||
"schedule {} 属于 {},不能从 {} 二级命令执行",
|
||||
id,
|
||||
entry.group,
|
||||
group
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if request.max_runs == Some(0) {
|
||||
return Err(anyhow::anyhow!("max_runs 必须大于 0"));
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for index in 0..file.schedules.len() {
|
||||
if request.max_runs.is_some_and(|max| results.len() >= max) {
|
||||
break;
|
||||
}
|
||||
let due = {
|
||||
let entry = &file.schedules[index];
|
||||
entry.enabled
|
||||
&& (request.force || entry.next_run_unix_seconds <= now)
|
||||
&& selected_id.is_none_or(|id| id == entry.id)
|
||||
&& group.as_deref().is_none_or(|group| group == entry.group)
|
||||
};
|
||||
if !due {
|
||||
continue;
|
||||
@@ -540,6 +621,12 @@ fn validate_schedule_command_options(
|
||||
if options.schedule_count == Some(0) {
|
||||
return Err(anyhow::anyhow!("--schedule-count 必须大于 0"));
|
||||
}
|
||||
if options.schedule_max_runs == Some(0) {
|
||||
return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0"));
|
||||
}
|
||||
if options.schedule_max_runs.is_some() && !matches!(options.command, CliCommand::ScheduleRun) {
|
||||
return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run"));
|
||||
}
|
||||
if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--schedule-clear-args 只适用于 schedule update"
|
||||
@@ -556,8 +643,8 @@ fn validate_schedule_command_options(
|
||||
fn validate_schedule_action(group: &str, action: &str) -> anyhow::Result<()> {
|
||||
let valid = match group {
|
||||
"res" => matches!(action, "pull" | "refresh" | "verify" | "repair"),
|
||||
"parse" => matches!(action, "run" | "repack"),
|
||||
"i18n" => matches!(action, "run" | "export" | "publish"),
|
||||
"parse" => matches!(action, "run" | "repack" | "clear-cache"),
|
||||
"i18n" => matches!(action, "run" | "export" | "validate" | "publish"),
|
||||
_ => false,
|
||||
};
|
||||
if valid {
|
||||
|
||||
@@ -28,6 +28,38 @@ pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
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)?;
|
||||
@@ -64,6 +96,23 @@ pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -191,3 +240,25 @@ fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, St
|
||||
}
|
||||
Ok((resource_root, release_id))
|
||||
}
|
||||
|
||||
fn remove_regenerable_file(path: &Path) -> anyhow::Result<bool> {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user