mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
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.
280 lines
12 KiB
Rust
280 lines
12 KiB
Rust
//! 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, ¶ms(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));
|
||
}
|
||
}
|