Files
vectorcraft/crates/ui-egui/src/dialogs/action_batch.rs
T
dotCooCoo 2ad0268388 Actions: Batch over data sets keeps Save and Close and warns when each data set saves over one file
Batch… with a Data Sets source offers None, Save and Close and Folder again.
Save and Close saves the open document over its own file after each data
set. When every data set saves over the same file, the dialog shows a
warning and `action.batch` returns `warning`: with Save and Close, or with
a recorded step that writes a file and that the batch does not point into
the destination folder. A Save step without a path counts when it writes
the document's own file; Save As, Save a Copy, Save as Template and Export
steps count when they give a path; Export for Screens counts when its
folder, given or from its preset, names a folder. Save and Close over the
data sets of a document that was never saved is refused with "the
document has never been saved" before a data set is selected.
2026-11-08 23:50:28 +07:00

280 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Actions panel menu › Batch…: play the selected action on every file in a
//! folder, or on every data set of the open document (`action.batch`).
use serde_json::{Value, json};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "actionBatch";
const LABEL: f32 = 130.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Batch").into(), body, confirm, ok: Some("OK"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
/// Open the Batch dialog for the selected action (`set`, `action` indexes into `action_sets`).
pub fn open(app: &mut VectorcraftApp, set: usize, action: usize) -> Result<Value, String> {
let a = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).ok_or("no such action")?;
if a.steps.is_empty() {
return Err("the action has no steps".into());
}
if a.steps.iter().enumerate().all(|(i, _)| a.excluded_steps.contains(&i)) {
return Err("the action has no included steps".into());
}
if crate::panels::actions::has_stop(&a.included_commands()) {
return Err("Batch cannot play an action with a stop".into());
}
let name = a.name.clone();
app.ui.dialog = Some(Dialog::new(KIND, initial_fields(set, action, &name)));
Ok(Value::Null)
}
fn initial_fields(set: usize, action: usize, name: &str) -> Value {
json!({
"set": set,
"action": action,
"actionName": name,
"source": "folder",
"datasetFileNaming": "fileNumber",
"folder": "",
"includeSubdirs": false,
"destination": "folder",
"destinationFolder": "",
"overrideOpen": true,
"overrideSave": true,
"overrideExport": true,
"stopOnError": true,
})
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(d.str("actionName")).color(t.text_strong));
ui.add_space(8.0);
form::choice(ui, d, "source", "Play:", (LABEL, 200.0), &[("folder", "Folder"), ("datasets", "Data Sets")]);
let source = d.str("source");
if source == "folder" {
widgets::label_row(ui, tl!("Source Folder:"), LABEL, |ui| {
form::text(ui, d, "folder", 200.0);
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
crate::picks::folder_field(app, d, "folder");
}
});
form::check(ui, d, "includeSubdirs", "Include All Subdirectories");
} else {
ui.label(egui::RichText::new(tl!("Uses every data set of the open document.")).size(11.5).color(t.text_dim));
form::choice(
ui,
d,
"datasetFileNaming",
"File Name:",
(LABEL, 200.0),
&[("fileNumber", "File + Number"), ("fileDataSetName", "File + Data Set Name"), ("dataSetName", "Data Set Name")],
);
}
ui.add_space(8.0);
form::choice(ui, d, "destination", "Destination:", (LABEL, 200.0), &[("none", "None"), ("saveAndClose", "Save and Close"), ("folder", "Folder")]);
if d.str("destination") == "folder" {
widgets::label_row(ui, tl!("Destination Folder:"), LABEL, |ui| {
form::text(ui, d, "destinationFolder", 200.0);
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
crate::picks::folder_field(app, d, "destinationFolder");
}
});
}
ui.add_space(6.0);
widgets::subheader(ui, tl!("Overrides"));
form::check(ui, d, "overrideOpen", "Override Action \"Open\" Commands");
form::check(ui, d, "overrideSave", "Override Action \"Save\" Commands");
form::check(ui, d, "overrideExport", "Override Action \"Export\" Commands");
form::check(ui, d, "stopOnError", "Stop on Errors");
if overwrites_one_file(app, d) {
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Each data set saves over the same file, which keeps only the last data set.")).size(11.5).color(t.warning));
}
false
}
/// Whether the batch the dialog would run saves every data set over the same file
/// ([`vectorcraft_engine::cmd::fileio::batch_overwrites_one_file`]).
fn overwrites_one_file(app: &VectorcraftApp, d: &Dialog) -> bool {
let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
let steps = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).map_or(&[][..], |a| &a.steps[..]);
let commands = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
vectorcraft_engine::cmd::fileio::batch_overwrites_one_file(&app.session, &params(d, "", commands))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
let a = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).ok_or("no such action")?.clone();
let commands = a.included_commands();
if commands.is_empty() {
return Err("the action has no included steps".into());
}
if crate::panels::actions::has_stop(&commands) {
return Err("Batch cannot play an action with a stop".into());
}
let source = d.str("source");
if source == "folder" && d.str("folder").trim().is_empty() {
return Err("choose a source folder".into());
}
let destination = d.str("destination");
if destination == "folder" && d.str("destinationFolder").trim().is_empty() {
return Err("choose a destination folder".into());
}
if source == "datasets" && app.session.active().is_none() {
return Err("open a document with data sets".into());
}
let params = params(d, &a.name, commands);
let r = app.replay("action.batch", params)?;
app.ui.dialog = None;
let count = r["count"].as_u64().unwrap_or(0);
let failed = r["failed"].as_u64().unwrap_or(0);
if failed > 0 {
app.status(format!("Batch: {count} processed, {failed} failed"));
} else {
app.status(format!("Batch: {count} processed"));
}
Ok(r)
}
fn params(d: &Dialog, action_name: &str, commands: Vec<Value>) -> Value {
let source = d.str("source");
let destination = d.str("destination");
let mut params = json!({
"commands": commands,
"label": action_name,
"source": source,
"destination": destination,
"overrideOpen": d.bool("overrideOpen"),
"overrideSave": d.bool("overrideSave"),
"overrideExport": d.bool("overrideExport"),
"stopOnError": d.bool("stopOnError"),
"includeSubdirs": d.bool("includeSubdirs"),
});
if source == "folder" {
params["folder"] = json!(d.str("folder"));
} else if source == "datasets" {
params["datasetFileNaming"] = json!(d.str("datasetFileNaming"));
}
if destination == "folder" {
params["destinationFolder"] = json!(d.str("destinationFolder"));
}
params
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn shown(source: &str) -> Vec<String> {
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
d.fields["source"] = json!(source);
render(&mut d)
}
/// Draws `d` once and returns the texts it shows.
fn render(d: &mut Dialog) -> Vec<String> {
render_in(&mut VectorcraftApp::new(Session::new(), Default::default()), d)
}
/// Draws `d` once over `app` and returns the texts it shows.
fn render_in(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec<String> {
fn collect(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, out)),
_ => {}
}
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut frame = ctx.run_ui(Default::default(), |ui| {
body(app, ui, d);
});
frame.textures_delta.clear();
let mut labels = vec![];
frame.shapes.iter().for_each(|shape| collect(&shape.shape, &mut labels));
labels
}
#[test]
fn file_name_choice_shows_only_for_data_sets() {
assert!(shown("datasets").iter().any(|label| label == "File Name:"));
assert!(shown("datasets").iter().any(|label| label == "File + Number"));
assert!(!shown("folder").iter().any(|label| label == "File Name:"));
}
/// Batch plays an action recorded with a key its command no longer reads, as Play does.
#[test]
fn an_action_with_a_key_no_longer_read_still_plays_in_a_batch() {
use crate::panels::actions::{Action, ActionSet};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("dataset.new", json!({"name": "a"})).unwrap();
let old = json!({"x": 10, "y": 10, "width": 20, "height": 20, "cornerStyle": "round"});
assert!(app.run("shape.rectangle", old.clone()).is_err());
app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Draw", vec![("shape.rectangle".into(), old)])])];
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Draw"));
d.fields["source"] = json!("datasets");
d.fields["destination"] = json!("none");
let r = confirm(&mut app, &d).unwrap();
assert_eq!((r["count"].clone(), r["failed"].clone()), (json!(1), json!(0)), "{r}");
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1);
assert!(!app.session.replaying());
}
#[test]
fn data_set_naming_params_follow_dialog_choice() {
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
assert_eq!(d.str("datasetFileNaming"), "fileNumber");
d.fields["source"] = json!("datasets");
for choice in ["fileNumber", "fileDataSetName", "dataSetName"] {
d.fields["datasetFileNaming"] = json!(choice);
assert_eq!(params(&d, "Test", vec![])["datasetFileNaming"], choice);
}
d.fields["source"] = json!("folder");
assert!(params(&d, "Test", vec![]).get("datasetFileNaming").is_none());
}
const ONE_FILE: &str = "Each data set saves over the same file, which keeps only the last data set.";
#[test]
fn data_sets_offer_save_and_close_with_a_warning() {
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
d.fields["destination"] = json!("saveAndClose");
let labels = render(&mut d);
assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}");
assert!(!labels.iter().any(|label| label == ONE_FILE), "a folder source opens a file per pass: {labels:?}");
d.fields["source"] = json!("datasets");
let labels = render(&mut d);
assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}");
assert!(labels.iter().any(|label| label == ONE_FILE), "{labels:?}");
assert_eq!(params(&d, "Test", vec![])["destination"], "saveAndClose");
d.fields["destination"] = json!("folder");
assert!(!render(&mut d).iter().any(|label| label == ONE_FILE));
}
#[test]
fn data_sets_warn_about_a_recorded_save_left_in_place() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.ui.action_sets[0].actions[0].steps.push(("document.save".into(), json!({"path": "/x/a.vectorcraft"})));
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
d.fields["source"] = json!("datasets");
d.fields["destinationFolder"] = json!("/out");
assert!(!render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE), "Override Save points the save into the folder");
d.fields["overrideSave"] = json!(false);
assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE));
d.fields["overrideSave"] = json!(true);
d.fields["destination"] = json!("none");
assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE));
}
}