diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs new file mode 100644 index 000000000..7dfe44ed9 --- /dev/null +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -0,0 +1,450 @@ +//! `action.batch`: play an action's commands on every file in a folder, or on every data set +//! of the open document (File › Automate › Batch / the Actions panel). +//! +//! Public options follow the Batch dialog: source Folder or Data Sets; destination None, +//! Save and Close, or Folder; Override Open / Save / Export. Cited: +//! + +use serde_json::{Value, json}; + +use super::super::*; + +#[cfg(target_arch = "wasm32")] +pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result { + Err(bad("action.batch", "no file system here")) +} + +/// Caps on a batch (folders and MCP payloads are untrusted). +#[cfg(not(target_arch = "wasm32"))] +const MAX_BATCH_FILES: usize = 500; +#[cfg(not(target_arch = "wasm32"))] +const MAX_BATCH_DEPTH: usize = 8; + +/// Shared Batch options for one run. +#[cfg(not(target_arch = "wasm32"))] +struct Run<'a> { + label: &'a str, + steps: &'a [Value], + stop_on_error: bool, + override_save: bool, + override_export: bool, + destination: &'a str, + dest_folder: Option<&'a str>, +} + +/// Play `commands` on each source file or data set. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { + use super::{absolute_path, may_write}; + + const C: &str = "action.batch"; + let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad(C, "missing `commands`"))?; + if cmds.is_empty() { + return Err(bad(C, "`commands` must not be empty")); + } + if cmds.len() > 256 { + return Err(bad(C, "more than 256 commands")); + } + for (i, c) in cmds.iter().enumerate() { + let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + if id.is_empty() { + return Err(bad(C, format!("commands[{i}]: missing `command`"))); + } + if id == "command.batch" || id == "action.batch" { + return Err(bad(C, "batches cannot nest")); + } + if find_command(id).is_none() { + return Err(bad(C, format!("commands[{i}]: unknown command `{id}`"))); + } + } + let source = str_param(p, "source").unwrap_or("folder"); + let destination = str_param(p, "destination").unwrap_or("none"); + if !matches!(destination, "none" | "saveAndClose" | "folder") { + return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`")); + } + let dest_folder = str_param(p, "destinationFolder").map(absolute_path); + if destination == "folder" { + let Some(ref folder) = dest_folder else { + return Err(bad(C, "`destinationFolder` is required when destination is `folder`")); + }; + may_write(folder)?; + std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?; + } + let label = str_param(p, "label").unwrap_or("Batch").to_string(); + let override_open = bool_or(p, "overrideOpen", true); + let run = Run { + label: &label, + steps: cmds, + stop_on_error: bool_or(p, "stopOnError", true), + override_save: bool_or(p, "overrideSave", true), + override_export: bool_or(p, "overrideExport", true), + destination, + dest_folder: dest_folder.as_deref(), + }; + match source { + "folder" => batch_folder(s, C, p, override_open, &run), + "datasets" => batch_datasets(s, C, &run), + _ => Err(bad(C, "`source` must be `folder` or `datasets`")), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { + use super::{absolute_path, may_read}; + + let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; + may_read(&folder)?; + let include_subdirs = bool_or(p, "includeSubdirs", false); + let files = collect_files(&folder, include_subdirs, 0)?; + if files.len() > MAX_BATCH_FILES { + return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files"))); + } + if files.is_empty() { + return Err(bad(cmd, "no readable files in the folder")); + } + let steps = filter_steps(run.steps, override_open, false); + let run = Run { steps: &steps, ..*run }; + let mut processed = Vec::new(); + let mut failed = 0usize; + for path in &files { + let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file"); + let ext = super::extension(path); + match process_one_file(s, &run, path, stem, &ext) { + Ok(row) => processed.push(row), + Err(e) => { + failed += 1; + processed.push(json!({"path": path, "error": e.to_string()})); + if run.stop_on_error { + return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true})); + } + } + } + } + Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { + let st = s.doc()?; + let names: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + if names.is_empty() { + return Err(bad(cmd, "no data sets")); + } + if names.len() > MAX_BATCH_FILES { + return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); + } + let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); + let steps = filter_steps(run.steps, true, false); + let run = Run { steps: &steps, ..*run }; + let mut processed = Vec::new(); + let mut failed = 0usize; + for name in &names { + match process_one_dataset(s, &run, name, &doc_stem) { + Ok(row) => processed.push(row), + Err(e) => { + failed += 1; + processed.push(json!({"dataset": name, "error": e.to_string()})); + if run.stop_on_error { + return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true})); + } + } + } + } + Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) +} + +/// Drop Open (and optionally Close) steps when the batch supplies the file itself. +#[cfg(not(target_arch = "wasm32"))] +fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec { + cmds.iter() + .filter(|c| { + let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + if drop_open && matches!(id, "document.open" | "file.open") { + return false; + } + if drop_close && matches!(id, "file.close" | "document.close") { + return false; + } + true + }) + .cloned() + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result { + s.execute("document.open", &json!({"path": path}))?; + let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext); + let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?; + // Folder sources open a file per pass: close after Save and Close / Folder. + finish_destination(s, run, stem, ext, path, true)?; + Ok(json!({"path": path, "result": batch})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result { + s.execute("dataset.select", &json!({"name": name}))?; + // File + Data Set Name naming when overriding Save/Export (helpx). + let stem = format!("{doc_stem}_{name}"); + let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft"); + let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?; + // Data sets share one open document: write each row out but leave it open for the next. + finish_destination(s, run, &stem, "vectorcraft", "", false)?; + Ok(json!({"dataset": name, "result": batch})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, source_path: &str, close: bool) -> Result<()> { + match run.destination { + "none" => Ok(()), + "saveAndClose" => { + if !source_path.is_empty() { + s.execute("document.save", &json!({"path": source_path}))?; + } else { + s.execute("document.save", &json!({}))?; + } + if close { + s.execute("file.close", &json!({}))?; + } + Ok(()) + } + "folder" => { + let folder = run.dest_folder.ok_or_else(|| bad("action.batch", "missing destination folder"))?; + let out_ext = if ext.is_empty() { "vectorcraft" } else { ext }; + let out = std::path::Path::new(folder).join(format!("{stem}.{out_ext}")); + let out = out.to_string_lossy().into_owned(); + // When `close` is false (data sets), write a copy so the template keeps its path. + write_result(s, &out, out_ext, !close)?; + if close { + s.execute("file.close", &json!({}))?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Save when the extension is a Save format; otherwise export (PNG, JPEG…). +/// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches). +#[cfg(not(target_arch = "wasm32"))] +fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result<()> { + use super::SAVE_FORMATS; + let id = if ext == "drawcraft" { "vectorcraft" } else { ext }; + if SAVE_FORMATS.contains(&id) { + let cmd = if as_copy { "file.saveCopy" } else { "document.save" }; + s.execute(cmd, &json!({"path": path}))?; + } else { + s.execute("document.export", &json!({"path": path, "format": id}))?; + } + Ok(()) +} + +/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on. +#[cfg(not(target_arch = "wasm32"))] +fn rewrite_paths( + steps: &[Value], + override_save: bool, + override_export: bool, + dest_folder: Option<&str>, + stem: &str, + default_ext: &str, +) -> Vec { + let Some(folder) = dest_folder else { + return steps.to_vec(); + }; + steps + .iter() + .map(|c| { + let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + let mut c = c.clone(); + let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) + || (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection")); + if !rewrite { + return c; + } + let params = c.get("params").cloned().unwrap_or(json!({})); + let mut params = params.as_object().cloned().unwrap_or_default(); + let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext); + let ext = if ext.is_empty() { default_ext } else { ext }; + let file = std::path::Path::new(folder).join(format!("{stem}.{ext}")); + params.insert("path".into(), json!(file.to_string_lossy())); + if let Some(o) = c.as_object_mut() { + o.insert("params".into(), Value::Object(params)); + } + c + }) + .collect() +} + +/// Readable files under `folder` (and optionally its subfolders), capped. +#[cfg(not(target_arch = "wasm32"))] +fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result> { + use std::path::PathBuf; + + use super::list_dir; + + if depth > MAX_BATCH_DEPTH { + return Err(bad("action.batch", format!("folders deeper than {MAX_BATCH_DEPTH} levels"))); + } + let (entries, _) = list_dir(folder, MAX_BATCH_FILES.saturating_mul(2))?; + let mut files = Vec::new(); + let mut subdirs = Vec::new(); + for e in entries { + let name = e.get("name").and_then(Value::as_str).unwrap_or(""); + if name.is_empty() || name.starts_with('.') { + continue; + } + let kind = e.get("kind").and_then(Value::as_str).unwrap_or(""); + let path = PathBuf::from(folder).join(name); + let path = path.to_string_lossy().into_owned(); + match kind { + "file" if is_openable(&path) => files.push(path), + "folder" if include_subdirs => subdirs.push(path), + _ => {} + } + } + files.sort(); + for sub in subdirs { + let mut nested = collect_files(&sub, true, depth + 1)?; + files.append(&mut nested); + if files.len() > MAX_BATCH_FILES { + break; + } + } + Ok(files) +} + +#[cfg(not(target_arch = "wasm32"))] +fn is_openable(path: &str) -> bool { + use super::{FORMATS, OPEN_EXTS, extension}; + let ext = extension(path); + if ext.is_empty() { + return false; + } + OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext)) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use serde_json::json; + + fn tmp(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-action-batch-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn write_svg(dir: &std::path::Path, name: &str, fill: &str) -> std::path::PathBuf { + let p = dir.join(name); + std::fs::write( + &p, + format!( + r#" + "# + ), + ) + .unwrap(); + p + } + + #[test] + fn batch_folder_runs_action_and_saves_to_destination() { + let src = tmp("src"); + let dest = tmp("dest"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "b.svg", "#0f0"); + let mut s = Session::new(); + // Need a document so command.batch's has_doc passes after the first open leaves one. + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}], + "label": "Unlock" + }), + ) + .unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(r["failed"], 0); + assert!(dest.join("a.svg").is_file(), "a.svg written"); + assert!(dest.join("b.svg").is_file(), "b.svg written"); + } + + #[test] + fn batch_skips_open_steps_when_override_open() { + let src = tmp("open"); + let dest = tmp("open-out"); + write_svg(&src, "only.svg", "#00f"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "overrideOpen": true, + "commands": [ + {"command": "document.open", "params": {"path": "/no/such/file.svg"}}, + {"command": "object.unlockAll", "params": {}} + ] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(dest.join("only.svg").is_file()); + } + + #[test] + fn batch_datasets_applies_each_row() { + let dest = tmp("sets"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap(); + s.execute("dataset.capture", &json!({})).unwrap(); + s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap(); + s.execute("dataset.capture", &json!({})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}], + "label": "Per set" + }), + ) + .unwrap(); + assert_eq!(r["count"], 2, "{r}"); + assert_eq!(r["failed"], 0, "{r}"); + let written: Vec<_> = std::fs::read_dir(&dest).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect(); + assert_eq!(written.len(), 2, "{written:?}"); + } + + #[test] + fn batch_refuses_nesting_and_empty_commands() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + assert!(s.execute("action.batch", &json!({"commands": []})).is_err()); + assert!( + s.execute( + "action.batch", + &json!({ + "folder": ".", + "commands": [{"command": "command.batch", "params": {"commands": []}}] + }) + ) + .is_err() + ); + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index ca6a0a5b9..205b11cfb 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -1,7 +1,9 @@ //! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open, -//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`. +//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and +//! `action.batch` (File › Automate › Batch / the Actions panel). //! //! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images). +//! - `action_batch`: play an action on every file in a folder or every data set. //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`. @@ -19,6 +21,7 @@ //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. +mod action_batch; mod affinity; mod batch; pub mod dxf; @@ -146,6 +149,15 @@ pub fn specs() -> Vec { has_doc, batch::batch ), + cmd!( + "action.batch", + "Batch…", + ["File", "Automate"], + None, + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + always, + action_batch::action_batch + ), cmd!( query "document.formats", "File Formats", diff --git a/crates/ui-egui/src/dialogs/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs new file mode 100644 index 000000000..b3030b0ef --- /dev/null +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -0,0 +1,125 @@ +//! Actions panel › Batch… / File › Automate › 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(super) 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 { + 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()); + } + let name = a.name.clone(); + app.ui.dialog = Some(Dialog::new( + KIND, + json!({ + "set": set, + "action": action, + "actionName": name, + "source": "folder", + "folder": "", + "includeSubdirs": false, + "destination": "folder", + "destinationFolder": "", + "overrideOpen": true, + "overrideSave": true, + "overrideExport": true, + "stopOnError": true, + }), + )); + Ok(Value::Null) +} + +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)); + } + 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"); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + 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: Vec = a.steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + 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 mut params = json!({ + "commands": commands, + "label": a.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")); + } + if destination == "folder" { + params["destinationFolder"] = json!(d.str("destinationFolder")); + } + let r = app.run("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) +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index c077a3423..27e16e45a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -7,6 +7,7 @@ //! Shortcuts, Workspaces, Find Font) draw their own window. mod about; +pub mod action_batch; pub mod artboard_options; pub mod blend_options; mod brush_art_options; @@ -283,6 +284,7 @@ registry! { DxfOptions: [dxf_options::KIND] => dxf_options::SPEC, PlacementOptions: [placement_options::KIND] => placement_options::SPEC, Package: [package::KIND] => package::SPEC, + ActionBatch: [action_batch::KIND] => action_batch::SPEC, SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f33beaf0c..4bc55b099 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3802,6 +3802,19 @@ axis Both Ambos Softness Suavidade Ambient Light Luz ambiente Light behind the object Luz atrás do objeto + Automate Automatizar + Batch… Lote… + Source Folder: Pasta de origem: + Destination Folder: Pasta de destino: + Include All Subdirectories Incluir todos os subdiretórios + Uses every data set of the open document. Usa todos os conjuntos de dados do documento aberto. + Overrides Substituições + Override Action "Open" Commands Substituir comandos "Abrir" da ação + Override Action "Save" Commands Substituir comandos "Salvar" da ação + Override Action "Export" Commands Substituir comandos "Exportar" da ação + Stop on Errors Parar em erros + Save and Close Salvar e fechar + Play: Reproduzir: Make Graph Dynamic Tornar gráfico dinâmico Registration: Registro: # Dimension tool (linked dimension annotations) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 51d4683c6..6e7037265 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -737,6 +737,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+Alt+Shift+P", "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage", ), + ( + "ui.batchDialog", + "Batch…", + "", + "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides", + ), ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), ( "docInfo.save", @@ -1495,6 +1501,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::package::open(app), + "ui.batchDialog" => { + let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; + let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; + crate::dialogs::action_batch::open(app, set, action) + } "ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p), "file.showPackage" => crate::dialogs::package::show_package(app, p), "docInfo.save" => crate::panels::doc_info::save_report(app, p), @@ -2107,6 +2118,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), + "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), "ui.epsOptionsDialog" => app.session.active().is_some(), "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), @@ -2195,6 +2207,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Export Selection…", "file.exportSelection"), Sep, c("Package…", "ui.packageDialog"), + sub("Automate", vec![c("Batch…", "ui.batchDialog"), todos("Scripts…", "")]), sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]), Sep, c("Document Setup…", "file.documentSetup"), diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index d8446570a..5eb641217 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -51,6 +51,24 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result = pstate(ui.ctx(), "actions-selected"); + let has = selected.and_then(|(s, a)| app.ui.action_sets.get(s).and_then(|set| set.actions.get(a))).is_some(); + if widgets::menu_item(ui, tl!("Batch…"), has, false) + && let Some((s, a)) = selected + && let Err(e) = crate::dialogs::action_batch::open(app, s, a) + { + app.status(e); + } + if widgets::menu_item(ui, tl!("Play Current Selection"), has, false) + && let Some((s, a)) = selected + && let Err(e) = play(app, s, a) + { + app.status(e); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // The selected action as (set, action). Read as the type it is stored as: egui keeps a diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 37b9e486c..8f36f231d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -222,6 +222,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool asset_export::ID => asset_export::menu(app, ui), css_properties::ID => css_properties::menu(app, ui), variables::ID => variables::menu(app, ui), + "actions" => actions::menu(app, ui), _ => return false, } true diff --git a/docs/gaps.md b/docs/gaps.md index d5dcb2e96..3c90413d0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -265,6 +265,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G6 Automation and scripting +- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), image and graph + variable kinds (dataset CSV import/export and Batch are open as PRs). Actions, Variables (text and + visibility) and full agent control over MCP are done. +- **Estimate:** 6–12 h. - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image variable kind and dataset import/export (graph variables land separately). Actions, Variables (text, visibility, graph) and full agent control over MCP are done. - **Estimate:** 8–14 h. diff --git a/docs/mcp.md b/docs/mcp.md index 83f431f1d..e54dbb884 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -986,6 +986,12 @@ already walks Image Trace. Short agent entry points: `symbol.replace`, `symbol.edit`, `symbol.duplicate`, `symbol.selectInstances {name}`. - Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`, `brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.freehand`, `object.expandBrush {ids?}`. +- Variables: `variable.list`, `variable.define {name, kind: text|visibility}`, `variable.bind {variable, ids?}`, + `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.unbind`, `dataset.new` / `dataset.set` / + `dataset.select`. +- Batch: `action.batch {commands, source?: folder|datasets, folder?, destination?: none|saveAndClose|folder, + destinationFolder?, overrideOpen?, overrideSave?, overrideExport?, includeSubdirs?, stopOnError?}` plays an + action on every file in a folder or every data set (File › Automate › Batch…). - Variables: `variable.list`, `variable.define {name, kind: text|visibility|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeGraphDynamic`, `variable.unbind`, `dataset.new` / `dataset.set` / `dataset.select` (graph values are CSV as `graph.setData`). @@ -1795,7 +1801,10 @@ for `edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack`, and to each none, when the step runs. When the document a step runs in has no artboard at that index (for example, after an earlier step deleted the active artboard when it was the last one), the step gets that document's last artboard. In remote mode, MCP's `run_command` calls and each step of the `command_batch` tool get the active artboard too. The -Actions panel plays an action as a `command.batch`. An action recorded in the Actions panel leaves the artboard out, +Actions panel plays an action as a `command.batch`. File › Automate › Batch… and the Actions panel's Batch… run +`action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with +destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. Caps: 500 files or data sets, 8 +folder levels, 256 steps. An action recorded in the Actions panel leaves the artboard out, and its steps of these commands get the artboard that is active when the action plays. A press with the Selection, Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. Choosing the Artboard diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7abe1783..10da19937 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -89,6 +89,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | +| Automation (Actions, Variables, scripting, batch) | 3 | 62% | a scripting surface (Illustrator: JavaScript/ExtendScript), image and graph variables (Batch and dataset CSV open as PRs) | 6–12 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 68% | a scripting surface (Illustrator: JavaScript/ExtendScript); Batch, image variables and dataset CSV open as PRs; graph variables done | 5–10 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 65% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables | 4–8 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 62% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables, dataset import/export | 6–12 | estimated | @@ -338,6 +339,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | +| Automation | 3 | 68% | scripting surface; image/graph variable kinds (Variables: text/visibility done; Batch `action.batch` and dataset CSV open as PRs) | 5–9 | | Automation | 3 | 70% | scripting surface, batch, image variables and dataset CSV (open as PRs); Variables: text, visibility and graph (CSV) bound to objects, dataset rows applied in one undo step | 4–8 | | Automation | 3 | 72% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, CSV import/export, panel and Window › Variables with dialogs; graph kinds open) | 4–7 | | Automation | 3 | 68% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; graph kinds and dataset import/export open) | 5–9 |