From 74f5f6e58f8a1b482bbc648179dcac035d58b2b5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:16:24 -0700 Subject: [PATCH 01/10] Actions: Batch's Override Export sends Export for Screens files to the destination folder With Override Export on, action.batch gave each document.export, document.exportSelection and document.exportForScreens step a path in the destination folder. document.exportForScreens reads folder and has no path parameter, so its files still went to the step's own folder, or came back as data when the step named none. assets.export was not overridden at all. Override Export now gives a document.exportForScreens or assets.export step the folder destinationFolder/, in place of the step's own folder or an export preset's. Each source file gets its own folder, named after the file without its extension, and each data set gets one named _. The export names the files in that folder as it does outside Batch. Two data sets, or two source files whose names without the extension differ, no longer write over each other's files when their artboards or assets have the same names. Other export steps are unchanged. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/action_batch.rs | 101 +++++++++++++++++-- crates/engine/src/cmd/fileio/mod.rs | 2 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 4 files changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 7e757eb190..feebde2755 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -248,7 +248,9 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result } /// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the -/// extension of the step's `format` ([`format_extension`]), else `default_ext`. +/// extension of the step's `format` ([`format_extension`]), else `default_ext`. Export for Screens +/// and Export Assets steps get `dest_folder/{stem}` as their `folder`, one folder for each source +/// file or data set, and name the files in it themselves. #[cfg(not(target_arch = "wasm32"))] fn rewrite_paths( steps: &[Value], @@ -267,18 +269,22 @@ fn rewrite_paths( 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")); + || (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection" | "assets.export")); 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 = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { - Some(f) => format_extension(f), - None => default_ext.to_string(), - }; - let file = std::path::Path::new(folder).join(format!("{stem}.{ext}")); - params.insert("path".into(), json!(file.to_string_lossy())); + if matches!(id, "document.exportForScreens" | "assets.export") { + params.insert("folder".into(), json!(std::path::Path::new(folder).join(stem).to_string_lossy())); + } else { + let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { + Some(f) => format_extension(f), + None => default_ext.to_string(), + }; + 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)); } @@ -470,6 +476,85 @@ mod tests { assert!(file_names(&dest).is_empty(), "{r}"); } + /// Override Export writes an Export for Screens step's files into a folder for each source file + /// inside the destination folder, under the names the export gives them, whether the step names + /// a folder of its own, takes one from an export preset or names none. With Override Export off, + /// the files go to the step's folder. + #[test] + fn batch_override_export_sends_export_for_screens_files_to_the_destination_folder() { + let src = tmp("screens-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "b.svg", "#0f0"); + let own = tmp("screens-own"); + let svg = json!([{"format": "svg"}]); + let preset = json!({"name": "Own Folder", "settings": {"formats": svg, "folder": own.to_string_lossy()}}); + let steps = [json!({"folder": own.to_string_lossy(), "formats": svg}), json!({"exportPreset": "Own Folder"}), json!({"formats": svg})]; + for (i, params) in steps.into_iter().enumerate() { + let dest = tmp(&format!("screens-out-{i}")); + let mut s = Session::new(); + s.execute("document.exportPresets.save", &preset).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": params}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{params}: {r}"); + assert_eq!(file_names(&dest), ["a", "b"], "{params}: {r}"); + for stem in ["a", "b"] { + assert_eq!(file_names(&dest.join(stem)), ["Artboard-1.svg"], "{params}: {r}"); + } + assert!(file_names(&own).is_empty(), "{params}: {r}"); + } + let dest = tmp("screens-out-off"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "overrideExport": false, + "commands": [{"command": "document.exportForScreens", "params": {"folder": own.to_string_lossy(), "formats": svg}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&own), ["Artboard-1.svg"], "{r}"); + assert!(file_names(&dest).is_empty(), "{r}"); + } + + /// Override Export writes an Export Assets step's files into a folder for each data set inside + /// the destination folder, under the assets' names. + #[test] + fn batch_override_export_sends_asset_export_files_to_the_destination_folder() { + let own = tmp("assets-own"); + let dest = tmp("assets-out"); + let mut s = with_data_sets(&["a", "b"]); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "assets.export", "params": {"folder": own.to_string_lossy(), "formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_b"], "{r}"); + for stem in ["Untitled_a", "Untitled_b"] { + assert_eq!(file_names(&dest.join(stem)), ["Logo.svg"], "{r}"); + } + assert!(file_names(&own).is_empty(), "{r}"); + } + /// Override Save names each file with the extension of the format the step names, also over /// data sets. #[test] diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index dd3f54e7ee..224f8e7543 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -187,7 +187,7 @@ pub fn specs() -> Vec { "Batch…", [], 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 the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), 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 the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). 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 ), diff --git a/docs/mcp.md b/docs/mcp.md index 265c55663e..e782fd13f0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1154,7 +1154,10 @@ already walks Image Trace. Short agent entry points: Export end the file name of a step with `format` in that `format`, in lower case and without a leading dot, when it is one of the format's extensions (`.svg` and `SVG` give `.svg`, `.JPEG` gives `.jpeg`), and otherwise in the format's first extension (`png8` gives `.png`, `template` gives `.vctemplate`). A step without `format` keeps the - source file's extension (`.vectorcraft` over data sets). + source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or + `assets.export` step the `folder` `destinationFolder/`, in place of the step's own folder or an export + preset's: one folder for each source file, named after the file without its extension, or for each data set, + named `_`. The export names the files in it as it does outside Batch. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b822f3f1e..5b0689db7c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -430,6 +430,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). From 5228beab637323e5ef36424322061ec16b12c8db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 19 Oct 2026 05:24:31 -0700 Subject: [PATCH 02/10] Actions: Batch numbers source files that share a name Over a folder, action.batch named each source file's output after the file without its extension. Two files with the same name, such as a.svg and sub/a.svg with includeSubdirs, got the same Export for Screens folder, Override Save path and Folder destination file. The second file's output replaced the first's, and the batch reported no failure. Batch now adds -2, -3... to the name of each later file whose name was already used, compared without case, as it does for data set names. The numbers follow the order the batch processes the files. With the Folder destination, a.svg and a.ai in one folder now give a.svg and a-2.ai. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/action_batch.rs | 76 +++++++++++++++++++- crates/engine/src/cmd/fileio/mod.rs | 2 +- docs/mcp.md | 4 +- docs/target-app-parity.md | 2 +- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index feebde2755..b7300931a9 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -92,7 +92,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[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}; + use super::{absolute_path, distinct_names, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; may_read(&folder)?; @@ -104,12 +104,15 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } + // Each file's name without its extension, with `-2`, `-3`… after a name an earlier file used + // (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. + let stems = + distinct_names(files.iter().map(|path| std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file").to_string())); 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"); + for (path, stem) in files.iter().zip(&stems) { let ext = super::extension(path); match process_one_file(s, &run, path, stem, &ext) { Ok(row) => processed.push(row), @@ -528,6 +531,73 @@ mod tests { assert!(file_names(&dest).is_empty(), "{r}"); } + /// Two source files with the same name in different subfolders get Export for Screens folders of + /// their own: the second one's folder is numbered, as a repeated data set name is. + #[test] + fn batch_override_export_numbers_export_for_screens_folders_of_files_with_the_same_name() { + let src = tmp("screens-same-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "a.svg", "#0f0"); + let dest = tmp("screens-same-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + for folder in file_names(&dest) { + println!("{folder}/: {:?}", file_names(&dest.join(&folder))); + } + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a-2"], "{r}"); + for (folder, fill) in [("a", "#ff0000"), ("a-2", "#00ff00")] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap(); + assert!(svg.contains(fill), "{folder}: {svg}"); + } + } + + /// Two source files with the same name in different subfolders are saved under names of their + /// own, by Override Save and by the Folder destination: the second one's name is numbered. + #[test] + fn batch_numbers_saved_files_of_source_files_with_the_same_name() { + let src = tmp("save-same-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "a.svg", "#0f0"); + let save = json!([{"command": "file.saveAs", "params": {"format": "svg"}}]); + let unlock = json!([{"command": "object.unlockAll", "params": {}}]); + for (tag, destination, commands) in [("save", "none", save), ("folder", "folder", unlock)] { + let dest = tmp(&format!("save-same-{tag}")); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destination": destination, + "destinationFolder": dest.to_string_lossy(), + "commands": commands + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{tag}: {r}"); + assert_eq!(file_names(&dest), ["a-2.svg", "a.svg"], "{tag}: {r}"); + for (file, fill) in [("a.svg", "#ff0000"), ("a-2.svg", "#00ff00")] { + let svg = std::fs::read_to_string(dest.join(file)).unwrap(); + assert!(svg.contains(fill), "{tag}: {file}: {svg}"); + } + } + } + /// Override Export writes an Export Assets step's files into a folder for each data set inside /// the destination folder, under the assets' names. #[test] diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 224f8e7543..02c2278d57 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -187,7 +187,7 @@ pub fn specs() -> Vec { "Batch…", [], 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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), 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 the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). 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 ), diff --git a/docs/mcp.md b/docs/mcp.md index e782fd13f0..3b83207248 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1157,7 +1157,9 @@ already walks Image Trace. Short agent entry points: source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or `assets.export` step the `folder` `destinationFolder/`, in place of the step's own folder or an export preset's: one folder for each source file, named after the file without its extension, or for each data set, - named `_`. The export names the files in it as it does outside Batch. + named `_`. The export names the files in it as it does outside Batch. When two source files + have the same name without the extension (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), the folder destination + and the overrides add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5b0689db7c..47e0365b91 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -430,7 +430,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). When source files have the same name without the extension, Batch adds `-2`, `-3`… to the folder or file name of each later one. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). From 8f377bac81925619d3093759e0dbfe39cbd6dc1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:35:18 -0700 Subject: [PATCH 03/10] Batch: drop trailing periods from export folder names before numbering A source file named `a..svg` gave the folder `a.` and a data set named `a.` gave `Untitled_a.`. Windows drops a trailing period from a folder name, so these folders were the same as the ones for `a.svg` and the data set `a`, and the second export replaced the first one's files. Batch now passes each name through `safe_file_name` before it numbers repeated names, so the second folder is `a-2` or `Untitled_a-2`. --- crates/engine/src/cmd/fileio/action_batch.rs | 75 +++++++++++++++++--- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 7 +- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 5b58481e8b..b6f921b1aa 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -98,6 +98,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { + use super::screens::safe_file_name; use super::{absolute_path, distinct_names, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; @@ -110,10 +111,13 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } - // Each file's name without its extension, with `-2`, `-3`… after a name an earlier file used - // (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. - let stems = - distinct_names(files.iter().map(|path| std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file").to_string())); + // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the + // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and + // `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. + let stems = distinct_names(files.iter().map(|path| { + let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + if stem.is_empty() { "file".to_string() } else { stem } + })); let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; let mut processed = Vec::new(); @@ -137,7 +141,7 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: #[cfg(not(target_arch = "wasm32"))] fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { use super::distinct_names; - use super::screens::file_part; + use super::screens::safe_file_name; let st = s.doc()?; let names: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); @@ -149,10 +153,11 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { } 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(); // Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder - // destination uses the same name). `file_part` writes the characters Windows does not allow in a - // file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) in the data set's - // name as `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used. - let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name)))); + // destination uses the same name). `safe_file_name` writes the characters Windows does not allow + // in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) as `-` and + // drops trailing periods and spaces, which Windows drops from a folder name (`a.` and `a` give + // one folder), and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used. + let stems = distinct_names(names.iter().map(|name| safe_file_name(&format!("{doc_stem}_{name}")))); let steps = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; let mut processed = Vec::new(); @@ -570,6 +575,58 @@ mod tests { } } + /// A source file whose name without its extension ends in a period (`a..svg`) gets the folder + /// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder + /// name, so `a.` and `a` would be one folder there. + #[test] + fn batch_override_export_numbers_folders_that_differ_by_a_trailing_period() { + let src = tmp("screens-period-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "a..svg", "#0f0"); + let dest = tmp("screens-period-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a-2"], "{r}"); + for folder in ["a", "a-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + } + } + + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets + /// folders of their own: the second one's folder is numbered. + #[test] + fn batch_override_export_numbers_data_set_folders_that_differ_by_a_trailing_period() { + let dest = tmp("assets-period-out"); + let mut s = with_data_sets(&["a", "a."]); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "assets.export", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_a-2"], "{r}"); + for folder in ["Untitled_a", "Untitled_a-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Logo.svg"], "{r}"); + } + } + /// Two source files with the same name in different subfolders are saved under names of their /// own, by Override Save and by the Folder destination: the second one's name is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 8abb6d3b0f..9950df7547 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], 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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). 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 ), diff --git a/docs/mcp.md b/docs/mcp.md index 2c8636b346..a59572ad16 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1519,9 +1519,10 @@ already walks Image Trace. Short agent entry points: source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or `assets.export` step the `folder` `destinationFolder/`, in place of the step's own folder or an export preset's: one folder for each source file, named after the file without its extension, or for each data set, - named `_`. The export names the files in it as it does outside Batch. When two source files - have the same name without the extension (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), the folder destination - and the overrides add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. + named `_`. Both names drop trailing periods and spaces (`a..svg` gives `a`). The export names + the files in it as it does outside Batch. When two source files have the same name without the extension + (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides + add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From 8a7d83e00f29d734dea58b9dc91f0fa4be746391 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:42:05 -0700 Subject: [PATCH 04/10] Batch: number export folders that have the name of a file an earlier source wrote With the Folder destination, `a.svg` is saved as `destinationFolder/a.svg`. A later source `a.svg.svg` then got the Export for Screens folder `a.svg`, which already existed as a file, and its export failed. Batch now numbers a source's name when its folder or any file it writes in the destination folder has the name of a file or folder an earlier source wrote, so that folder is `a.svg-2`. --- crates/engine/src/cmd/fileio/action_batch.rs | 104 +++++++++++++++++-- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 4 +- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index b6f921b1aa..cc160c1595 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -99,7 +99,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::screens::safe_file_name; - use super::{absolute_path, distinct_names, may_read}; + 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)?; @@ -113,13 +113,15 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: } // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and - // `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. - let stems = distinct_names(files.iter().map(|path| { - let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); - if stem.is_empty() { "file".to_string() } else { stem } - })); + // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file + // wrote ([`distinct_stems`]), as data set names are numbered. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; + let stems = distinct_stems(files.iter().map(|path| { + let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let (exts, folder) = outputs(&run, &super::extension(path)); + (if stem.is_empty() { "file".to_string() } else { stem }, exts, folder) + })); let mut processed = Vec::new(); let mut failed = 0usize; for (path, stem) in files.iter().zip(&stems) { @@ -140,7 +142,6 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: #[cfg(not(target_arch = "wasm32"))] fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { - use super::distinct_names; use super::screens::safe_file_name; let st = s.doc()?; @@ -156,10 +157,11 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { // destination uses the same name). `safe_file_name` writes the characters Windows does not allow // in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) as `-` and // drops trailing periods and spaces, which Windows drops from a folder name (`a.` and `a` give - // one folder), and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used. - let stems = distinct_names(names.iter().map(|name| safe_file_name(&format!("{doc_stem}_{name}")))); + // one folder), and `distinct_stems` adds `-2`, `-3`… to a name an earlier data set used. let steps = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; + let (exts, folder) = outputs(&run, "vectorcraft"); + let stems = distinct_stems(names.iter().map(|name| (safe_file_name(&format!("{doc_stem}_{name}")), exts.clone(), folder))); let mut processed = Vec::new(); let mut failed = 0usize; for (name, stem) in names.iter().zip(&stems) { @@ -177,6 +179,62 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) } +/// What one source writes into the destination folder: the extension of each file a Save or +/// Export step or the Folder destination writes there as `{stem}.{ext}`, and whether an Export for +/// Screens or Export Assets step makes the folder `{stem}` there. Follows [`rewrite_paths`] and +/// [`finish_destination`]. +#[cfg(not(target_arch = "wasm32"))] +fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { + let mut exts = Vec::new(); + let mut folder = false; + if run.dest_folder.is_some() { + for c in run.steps { + let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") { + folder = true; + } else if (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) + || (run.override_export && matches!(id, "document.export" | "document.exportSelection")) + { + let format = c.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()); + exts.push(format.map_or_else(|| default_ext.to_string(), format_extension)); + } + } + } + if run.destination == "folder" { + exts.push(if default_ext.is_empty() { "vectorcraft".to_string() } else { default_ext.to_string() }); + } + (exts, folder) +} + +/// The stem of each source, given as (name, extensions, folder) by [`outputs`]: the name, with +/// `-2`, `-3`… after it when an earlier source used it or when one of the files or the folder it +/// writes has the name of a file or folder an earlier source wrote (`a.svg` writes the file +/// `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`). Names are compared +/// without case. +#[cfg(not(target_arch = "wasm32"))] +fn distinct_stems(sources: impl IntoIterator, bool)>) -> Vec { + let mut stems = std::collections::HashSet::new(); + let mut written = std::collections::HashSet::new(); + sources + .into_iter() + .map(|(base, exts, folder)| { + let mut name = base.clone(); + let mut n = 1usize; + loop { + let lower = name.to_lowercase(); + let names: Vec = exts.iter().map(|e| format!("{lower}.{}", e.to_lowercase())).chain(folder.then(|| lower.clone())).collect(); + if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f)) { + stems.insert(lower); + written.extend(names); + return name; + } + n += 1; + name = format!("{base}-{n}"); + } + }) + .collect() +} + /// 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 { @@ -602,6 +660,34 @@ mod tests { } } + /// A source file's Export for Screens folder does not take the name of a file the Folder + /// destination saved for an earlier source: `a.svg` is saved as `a.svg`, so the folder of + /// `a.svg.svg` is numbered. + #[test] + fn batch_override_export_numbers_folders_named_like_a_saved_file() { + let src = tmp("screens-file-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "a.svg.svg", "#0f0"); + let dest = tmp("screens-file-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a.svg", "a.svg-2", "a.svg-2.svg"], "{r}"); + for folder in ["a", "a.svg-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + } + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 9950df7547..0a48051049 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], 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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). 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 ), diff --git a/docs/mcp.md b/docs/mcp.md index a59572ad16..8e4bf30a65 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1522,7 +1522,9 @@ already walks Image Trace. Short agent entry points: named `_`. Both names drop trailing periods and spaces (`a..svg` gives `a`). The export names the files in it as it does outside Batch. When two source files have the same name without the extension (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides - add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. + add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a + later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder + destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From 9a05e8ad36c96d4dadef03e0122803920251ce38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 03:43:25 -0700 Subject: [PATCH 05/10] Batch: do not save over another source file in the destination folder With the destination folder the same as the source folder, `a..svg` gets the name `a` and was saved as `a.svg`, replacing the source `a.svg` before Batch opened it. Batch now keeps the names of the source files in the destination folder for their own output, so `a..svg` is saved as `a-2.svg` and `a.svg` is saved over itself. --- crates/engine/src/cmd/fileio/action_batch.rs | 81 ++++++++++++++++---- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 4 +- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index cc160c1595..abeaa322ce 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -114,14 +114,20 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file - // wrote ([`distinct_stems`]), as data set names are numbered. + // wrote ([`distinct_stems`]), as data set names are numbered. A source file in the destination + // folder itself keeps its name for its own output: no other file's output takes it. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; - let stems = distinct_stems(files.iter().map(|path| { - let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); - let (exts, folder) = outputs(&run, &super::extension(path)); - (if stem.is_empty() { "file".to_string() } else { stem }, exts, folder) - })); + let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); + let reserved = in_dest.iter().flatten().cloned().collect(); + let stems = distinct_stems( + files.iter().zip(&in_dest).map(|(path, own)| { + let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let (exts, makes_folder) = outputs(&run, &super::extension(path)); + (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own.clone()) + }), + reserved, + ); let mut processed = Vec::new(); let mut failed = 0usize; for (path, stem) in files.iter().zip(&stems) { @@ -161,7 +167,10 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { let steps = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; let (exts, folder) = outputs(&run, "vectorcraft"); - let stems = distinct_stems(names.iter().map(|name| (safe_file_name(&format!("{doc_stem}_{name}")), exts.clone(), folder))); + let stems = distinct_stems( + names.iter().map(|name| (safe_file_name(&format!("{doc_stem}_{name}")), exts.clone(), folder, None)), + std::collections::HashSet::new(), + ); let mut processed = Vec::new(); let mut failed = 0usize; for (name, stem) in names.iter().zip(&stems) { @@ -206,24 +215,36 @@ fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { (exts, folder) } -/// The stem of each source, given as (name, extensions, folder) by [`outputs`]: the name, with -/// `-2`, `-3`… after it when an earlier source used it or when one of the files or the folder it -/// writes has the name of a file or folder an earlier source wrote (`a.svg` writes the file -/// `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`). Names are compared -/// without case. +/// The name of the file at `path` in lower case when the file is directly inside `folder`. #[cfg(not(target_arch = "wasm32"))] -fn distinct_stems(sources: impl IntoIterator, bool)>) -> Vec { +fn name_in_folder(path: &str, folder: Option<&str>) -> Option { + let folder = std::fs::canonicalize(folder?).ok()?; + let path = std::path::Path::new(path); + let parent = std::fs::canonicalize(path.parent()?).ok()?; + (parent == folder).then(|| path.file_name().and_then(|n| n.to_str()).map(str::to_lowercase)).flatten() +} + +/// The stem of each source, given as (name, extensions, folder, own file) with [`outputs`]: the +/// name, with `-2`, `-3`… after it when an earlier source used it or when one of the files or the +/// folder it writes has the name of a file or folder an earlier source wrote (`a.svg` writes the +/// file `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`) or a name in +/// `reserved` other than its own file's. Names are compared without case. +#[cfg(not(target_arch = "wasm32"))] +fn distinct_stems( + sources: impl IntoIterator, bool, Option)>, + reserved: std::collections::HashSet, +) -> Vec { let mut stems = std::collections::HashSet::new(); - let mut written = std::collections::HashSet::new(); + let mut written = reserved; sources .into_iter() - .map(|(base, exts, folder)| { + .map(|(base, exts, folder, own)| { let mut name = base.clone(); let mut n = 1usize; loop { let lower = name.to_lowercase(); let names: Vec = exts.iter().map(|e| format!("{lower}.{}", e.to_lowercase())).chain(folder.then(|| lower.clone())).collect(); - if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f)) { + if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f) || own.as_ref() == Some(f)) { stems.insert(lower); written.extend(names); return name; @@ -688,6 +709,34 @@ mod tests { } } + /// With the destination folder the same as the source folder, a file's output does not replace + /// another source file before Batch opens it: `a..svg` would be saved as `a.svg`, so it is saved + /// as `a-2.svg`, and `a.svg` is saved over itself. + #[test] + fn batch_does_not_save_over_another_source_file_in_the_destination_folder() { + let src = tmp("in-place"); + write_svg(&src, "a..svg", "#f00"); + write_svg(&src, "a.svg", "#0f0"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&src), ["a-2.svg", "a..svg", "a.svg"], "{r}"); + for (file, fill) in [("a-2.svg", "#ff0000"), ("a.svg", "#00ff00")] { + let svg = std::fs::read_to_string(src.join(file)).unwrap(); + assert!(svg.contains(fill), "{file}: {svg}"); + } + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 0a48051049..805bed2c54 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], 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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file in destinationFolder), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). 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 ), diff --git a/docs/mcp.md b/docs/mcp.md index 8e4bf30a65..823a7fc189 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1524,7 +1524,9 @@ already walks Image Trace. Short agent entry points: (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder - destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. + destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When + `destinationFolder` holds source files, only a file's own output takes its name: with `a..svg` and `a.svg` saved + in place, `a..svg` is saved as `a-2.svg` and `a.svg` over itself. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From f5d8329cb135720332a26ebb388fe9f35f80ef4d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 04:43:40 -0700 Subject: [PATCH 06/10] Batch: do not export into a subfolder that holds source files With `includeSubdirs` and the destination folder the same as the source folder, the Export for Screens folder of `a.svg` was `a`, and its `Artboard-1.svg` replaced the source `a/Artboard-1.svg` before Batch opened it. Batch now keeps the names of subfolders of the destination folder that hold source files, so that export folder is `a-2`. --- crates/engine/src/cmd/fileio/action_batch.rs | 54 ++++++++++++++++---- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 5 +- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index abeaa322ce..ba46e96d68 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -114,17 +114,19 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file - // wrote ([`distinct_stems`]), as data set names are numbered. A source file in the destination - // folder itself keeps its name for its own output: no other file's output takes it. + // wrote ([`distinct_stems`]), as data set names are numbered. No output takes the name of a + // source file in the destination folder, other than that file's own output, or of a subfolder + // of the destination folder that holds source files. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; - let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); - let reserved = in_dest.iter().flatten().cloned().collect(); + let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); + let reserved = in_dest.iter().flatten().map(|(name, _)| name.clone()).collect(); let stems = distinct_stems( - files.iter().zip(&in_dest).map(|(path, own)| { + files.iter().zip(&in_dest).map(|(path, place)| { let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); let (exts, makes_folder) = outputs(&run, &super::extension(path)); - (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own.clone()) + let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); + (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own) }), reserved, ); @@ -215,13 +217,15 @@ fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { (exts, folder) } -/// The name of the file at `path` in lower case when the file is directly inside `folder`. +/// When the file at `path` is inside `folder`: the name in lower case of the file, when it is +/// directly inside, or of the subfolder of `folder` that holds it, and whether it is the file's own. #[cfg(not(target_arch = "wasm32"))] -fn name_in_folder(path: &str, folder: Option<&str>) -> Option { +fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> { let folder = std::fs::canonicalize(folder?).ok()?; - let path = std::path::Path::new(path); - let parent = std::fs::canonicalize(path.parent()?).ok()?; - (parent == folder).then(|| path.file_name().and_then(|n| n.to_str()).map(str::to_lowercase)).flatten() + let path = std::fs::canonicalize(path).ok()?; + let mut parts = path.strip_prefix(&folder).ok()?.components(); + let first = parts.next()?.as_os_str().to_str()?.to_lowercase(); + Some((first, parts.next().is_none())) } /// The stem of each source, given as (name, extensions, folder, own file) with [`outputs`]: the @@ -737,6 +741,34 @@ mod tests { } } + /// With the destination folder the same as the source folder and `includeSubdirs`, an Export for + /// Screens folder does not take the name of a subfolder that holds source files: the folder of + /// `a.svg` is `a-2`, so `a/Artboard-1.svg` is opened as it was. + #[test] + fn batch_does_not_export_into_a_source_subfolder() { + let src = tmp("in-place-sub"); + std::fs::create_dir_all(src.join("a")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("a"), "Artboard-1.svg", "#0f0"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + let source = std::fs::read_to_string(src.join("a").join("Artboard-1.svg")).unwrap(); + assert!(source.contains("fill=\"#0f0\""), "{r}: {source}"); + let export = std::fs::read_to_string(src.join("a-2").join("Artboard-1.svg")).unwrap(); + assert!(export.contains("#ff0000"), "{r}: {export}"); + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 805bed2c54..c4736ea182 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], 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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file in destinationFolder), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{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.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). 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 ), diff --git a/docs/mcp.md b/docs/mcp.md index 823a7fc189..5105237ba2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1525,8 +1525,9 @@ already walks Image Trace. Short agent entry points: add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When - `destinationFolder` holds source files, only a file's own output takes its name: with `a..svg` and `a.svg` saved - in place, `a..svg` is saved as `a-2.svg` and `a.svg` over itself. + `destinationFolder` holds source files, only a file's own output takes its name, and no export folder takes the + name of a subfolder that holds source files: with `a..svg` and `a.svg` saved in place, `a..svg` is saved as + `a-2.svg` and `a.svg` over itself. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From 82c04ff35afa20f04cd50bf7bd4c34eeef24024f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 20:10:13 -0700 Subject: [PATCH 07/10] Docs: Batch's Override Export names every output collision it numbers The progress-doc bullet now says that folder and file names drop trailing periods and spaces, and that Batch also numbers a name that an earlier output, a source file in the destination folder or, for data sets, an existing file already has. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6d82c456a1..114909cfce 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -547,7 +547,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: when no object is bound to a variable that the data set has a value for, for example after Import Data Sets… defines new variables without binding them, choosing the data set in the Variables panel or stepping to it with the panel's arrows shows a status message, and `dataset.select`, `dataset.next` and `dataset.prev` add the message to the reply as `warning`. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). When source files have the same name without the extension, Batch adds `-2`, `-3`… to the folder or file name of each later one. + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Removes the selected action, command, or action set from the panel"; `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. From a4436388ccf07e15b6247847d772bc2f66bb3e63 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:35:40 -0800 Subject: [PATCH 08/10] Batch: a folder source's file name keeps a backslash on Linux and macOS Output stems for a folder batch went through safe_file_name, which turns `\` and `:` into `-`, so `a\b.svg` on Linux was written as `a-b.svg`. The stem now only loses trailing periods and spaces and gets `-` after a Windows device name, and keeps every other character. --- crates/engine/src/cmd/fileio/action_batch.rs | 30 ++++++++++++++++---- crates/engine/src/cmd/fileio/screens.rs | 8 +++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 0b85ab9dfa..c34bfd8549 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -102,7 +102,6 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { - use super::screens::safe_file_name; use super::{absolute_path, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; @@ -115,9 +114,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } - // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the - // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and - // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file + // Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the + // folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with + // `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file // wrote ([`distinct_stems`]), as data set names are numbered. No output takes the name of a // source file in the destination folder, other than that file's own output, or of a subfolder // of the destination folder that holds source files. @@ -127,10 +126,10 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: let reserved = in_dest.iter().flatten().map(|(name, _)| name.clone()).collect(); let stems = distinct_stems( files.iter().zip(&in_dest).map(|(path, place)| { - let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let stem = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); let (exts, makes_folder) = outputs(&run, &super::extension(path)); let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); - (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own) + (stem, exts, makes_folder, own) }), reserved, ); @@ -183,6 +182,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) } +/// The output stem for a source file's name without its extension: without trailing periods and +/// spaces and with `-` after a Windows device name ([`super::screens::name_without_device`]), and +/// "file" when nothing is left. Other characters stay, so `a\b` and `a:b` keep their names on Linux +/// and macOS. +#[cfg(not(target_arch = "wasm32"))] +fn source_stem(stem: &str) -> String { + let stem = super::screens::name_without_device(stem); + if stem.is_empty() { "file".to_string() } else { stem } +} + /// What one source writes into the destination folder: the extension of each file a Save or /// Export step or the Folder destination writes there as `{stem}.{ext}`, and whether an Export for /// Screens or Export Assets step makes the folder `{stem}` there. Follows [`rewrite_paths`] and @@ -748,6 +757,15 @@ mod tests { assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}"); } + /// A source stem keeps `\`, `:` and the other characters Windows does not allow in a name, which + /// only reach the batch where the file system allows them, and loses trailing periods and spaces. + #[test] + fn source_stem_keeps_characters_the_file_system_allowed() { + for (stem, want) in [("a\\b", "a\\b"), ("a:b", "a:b"), ("a.", "a"), ("a. ", "a"), ("CON", "CON-"), ("...", "file"), ("", "file")] { + assert_eq!(source_stem(stem), want, "{stem:?}"); + } + } + #[test] fn output_path_accepts_only_platform_file_name_separators() { let folder = tmp("output-path"); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 966038b35e..bb5bd0acfe 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -174,7 +174,13 @@ pub(super) fn file_part(s: &str) -> String { /// every platform. Spaces between the device name and the period are dropped (`CON .png` becomes /// `CON-.png`). pub(crate) fn safe_file_name(s: &str) -> String { - let name = file_part(s); + name_without_device(&file_part(s)) +} + +/// [`safe_file_name`] without [`file_part`]: drops trailing periods and spaces and puts `-` after a +/// Windows device name, and keeps every other character, so a source file name with `\` or `:` keeps +/// it where the file system allows it. +pub(super) fn name_without_device(name: &str) -> String { let name = name.trim_end_matches(['.', ' ']); let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len())); // Windows ignores spaces before the extension, so `CON .png` is the CON device too. From c4a8c43dc511faf982b884a73ef64cb963f2d565 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 11:43:07 -0800 Subject: [PATCH 09/10] Batch: a folder source's artboard files do not replace another source file A folder source's stem is picked once the file is open, so the files a save or export writes for each artboard count, as they do for data sets. With the destination folder the same as the source folder, saving a.vectorcraft with separateArtboards wrote a-Artboard-1.vectorcraft over the source file of that name; a.vectorcraft is now saved as a-2. --- crates/engine/src/cmd/fileio/action_batch.rs | 169 +++++++++++-------- docs/mcp.md | 4 +- 2 files changed, 98 insertions(+), 75 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index c34bfd8549..c0dd38ea0f 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -117,27 +117,26 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: // Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the // folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with // `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file - // wrote ([`distinct_stems`]), as data set names are numbered. No output takes the name of a + // wrote ([`StemPicker`]), as data set names are numbered. A file's stem is picked once it is + // open, so its artboard files count ([`dataset_output_paths`]). No output takes the name of a // source file in the destination folder, other than that file's own output, or of a subfolder // of the destination folder that holds source files. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); - let reserved = in_dest.iter().flatten().map(|(name, _)| name.clone()).collect(); - let stems = distinct_stems( - files.iter().zip(&in_dest).map(|(path, place)| { - let stem = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); - let (exts, makes_folder) = outputs(&run, &super::extension(path)); - let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); - (stem, exts, makes_folder, own) - }), - reserved, - ); + let mut picker = + StemPicker { stems: std::collections::HashSet::new(), written: in_dest.iter().flatten().map(|(name, _)| name.clone()).collect() }; let mut processed = Vec::new(); let mut failed = 0usize; - for (path, stem) in files.iter().zip(&stems) { + for (path, place) in files.iter().zip(&in_dest) { let ext = super::extension(path); - match process_one_file(s, &run, path, stem, &ext) { + let base = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); + let pick = |doc: &vectorcraft_doc::Document| { + let outputs = dataset_output_extensions(&run, doc, &ext); + picker.pick(&base, |stem| output_names(run.dest_folder, stem, &outputs, doc), own.as_deref()) + }; + match process_one_file(s, &run, path, &ext, pick) { Ok(row) => processed.push(row), Err(e) => { failed += 1; @@ -192,31 +191,14 @@ fn source_stem(stem: &str) -> String { if stem.is_empty() { "file".to_string() } else { stem } } -/// What one source writes into the destination folder: the extension of each file a Save or -/// Export step or the Folder destination writes there as `{stem}.{ext}`, and whether an Export for -/// Screens or Export Assets step makes the folder `{stem}` there. Follows [`rewrite_paths`] and -/// [`finish_destination`]. +/// The names in lower case of the files and folders that a source with the stem `stem` writes +/// into the destination folder ([`dataset_output_paths`]), artboard files included. #[cfg(not(target_arch = "wasm32"))] -fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { - let mut exts = Vec::new(); - let mut folder = false; - if run.dest_folder.is_some() { - for c in run.steps { - let id = c.get("command").and_then(Value::as_str).unwrap_or(""); - if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") { - folder = true; - } else if (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) - || (run.override_export && matches!(id, "document.export" | "document.exportSelection")) - { - let format = c.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()); - exts.push(format.map_or_else(|| default_ext.to_string(), format_extension)); - } - } - } - if run.destination == "folder" { - exts.push(if default_ext.is_empty() { "vectorcraft".to_string() } else { default_ext.to_string() }); - } - (exts, folder) +fn output_names(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec { + dataset_output_paths(folder, stem, outputs, doc) + .iter() + .filter_map(|path| path.file_name().and_then(|name| name.to_str()).map(str::to_lowercase)) + .collect() } /// When the file at `path` is inside `folder`: the name in lower case of the file, when it is @@ -230,36 +212,35 @@ fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> { Some((first, parts.next().is_none())) } -/// The stem of each source, given as (name, extensions, folder, own file) with [`outputs`]: the -/// name, with `-2`, `-3`… after it when an earlier source used it or when one of the files or the -/// folder it writes has the name of a file or folder an earlier source wrote (`a.svg` writes the -/// file `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`) or a name in -/// `reserved` other than its own file's. Names are compared without case. +/// Picks the stem of each folder source in turn: its name, with `-2`, `-3`… after it when an +/// earlier source used it or when one of the files or the folder it writes has the name of a file +/// or folder an earlier source wrote (`a.svg` writes the file `a.svg`, so the Export for Screens +/// folder of `a.svg.svg` is `a.svg-2`) or a name in `written` from the start (the source files and +/// subfolders in the destination folder) other than its own file's. Names are compared without case. #[cfg(not(target_arch = "wasm32"))] -fn distinct_stems( - sources: impl IntoIterator, bool, Option)>, - reserved: std::collections::HashSet, -) -> Vec { - let mut stems = std::collections::HashSet::new(); - let mut written = reserved; - sources - .into_iter() - .map(|(base, exts, folder, own)| { - let mut name = base.clone(); - let mut n = 1usize; - loop { - let lower = name.to_lowercase(); - let names: Vec = exts.iter().map(|e| format!("{lower}.{}", e.to_lowercase())).chain(folder.then(|| lower.clone())).collect(); - if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f) || own.as_ref() == Some(f)) { - stems.insert(lower); - written.extend(names); - return name; - } - n += 1; - name = format!("{base}-{n}"); +struct StemPicker { + stems: std::collections::HashSet, + written: std::collections::HashSet, +} + +#[cfg(not(target_arch = "wasm32"))] +impl StemPicker { + /// `names` gives the names in lower case of what a stem writes; `own` is the source's own name. + fn pick(&mut self, base: &str, names: impl Fn(&str) -> Vec, own: Option<&str>) -> String { + let mut name = base.to_string(); + let mut n = 1usize; + loop { + let lower = name.to_lowercase(); + let outputs = names(&name); + if !self.stems.contains(&lower) && outputs.iter().all(|f| !self.written.contains(f) || own == Some(f.as_str())) { + self.stems.insert(lower); + self.written.extend(outputs); + return name; } - }) - .collect() + n += 1; + name = format!("{base}-{n}"); + } + } } /// Drop Open (and optionally Close) steps when the batch supplies the file itself. @@ -281,12 +262,19 @@ fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec } #[cfg(not(target_arch = "wasm32"))] -fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result { +fn process_one_file( + s: &mut Session, + run: &Run<'_>, + path: &str, + ext: &str, + stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String, +) -> 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 stem = stem_for(&s.doc()?.doc); + 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)?; + finish_destination(s, run, &stem, ext, path, true)?; Ok(json!({"path": path, "result": batch})) } @@ -356,7 +344,7 @@ fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>, _ => safe_file_name(&format!("{doc_stem}_{safe}")), } }); - let outputs = dataset_output_extensions(run, doc); + let outputs = dataset_output_extensions(run, doc, "vectorcraft"); let mut taken = std::collections::HashSet::new(); let mut reserved = std::collections::HashSet::new(); let mut stems = Vec::with_capacity(bases.len()); @@ -395,12 +383,14 @@ struct DatasetOutput { /// Lists step output extensions and reserves artboard file names for saves with any artboard or /// exports with multiple artboards. Per-artboard names count for vectorcraft, template, AI and AIT /// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports. An Export for Screens or Export -/// Assets step reserves the folder `{stem}` ([`rewrite_paths`]). +/// Assets step reserves the folder `{stem}` ([`rewrite_paths`]). A step without a format and the +/// Folder destination write `default_ext` (`vectorcraft` when it is empty). #[cfg(not(target_arch = "wasm32"))] -fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Vec { +fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, default_ext: &str) -> Vec { + let default_ext = if default_ext.is_empty() { "vectorcraft" } else { default_ext }; let mut outputs = Vec::new(); if run.destination == "folder" { - outputs.push(DatasetOutput { ext: "vectorcraft".into(), artboards: false, joiner: "-", folder: false }); + outputs.push(DatasetOutput { ext: default_ext.into(), artboards: false, joiner: "-", folder: false }); } for step in run.steps { let id = step.get("command").and_then(Value::as_str).unwrap_or(""); @@ -415,10 +405,10 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> let ext = match format { Some(format) if super::format(format).is_some() => format_extension(format), Some(_) => continue, - None => "vectorcraft".to_string(), + None => default_ext.to_string(), }; if safe_extension(&ext) { - let format_id = format.and_then(super::format).map(|f| f.id).unwrap_or("vectorcraft"); + let format_id = super::format(format.unwrap_or(default_ext)).map(|f| f.id).unwrap_or("vectorcraft"); let is_save = matches!(id, "document.save" | "file.save" | "file.saveAs"); let artboards = if is_save { !doc.artboards.is_empty() && matches!(format_id, "vectorcraft" | "template" | "ai" | "ait") @@ -1045,6 +1035,37 @@ mod tests { assert!(export.contains("#ff0000"), "{r}: {export}"); } + /// With the destination folder the same as the source folder, a save's artboard files do not + /// replace another source file: `a.vectorcraft` would also write `a-Artboard-1.vectorcraft`, so + /// it is saved as `a-2`. + #[test] + fn batch_does_not_save_artboard_files_over_another_source_file() { + let src = tmp("in-place-artboards"); + let mut s = Session::new(); + for (name, width) in [("a.vectorcraft", 20), ("a-Artboard-1.vectorcraft", 30)] { + s.execute("file.new", &json!({"width": width, "height": 20, "artboards": 1})).unwrap(); + s.execute("document.save", &json!({"path": src.join(name).to_string_lossy()})).unwrap(); + } + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "document.save", "params": {"separateArtboards": true}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!( + file_names(&src), + ["a-2-Artboard-1.vectorcraft", "a-2.vectorcraft", "a-Artboard-1-Artboard-1.vectorcraft", "a-Artboard-1.vectorcraft", "a.vectorcraft"], + "{r}" + ); + s.execute("document.open", &json!({"path": src.join("a-Artboard-1.vectorcraft").to_string_lossy()})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 30.0, "{r}"); + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 3e8bcf2c19..e57870b8ec 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1673,7 +1673,9 @@ already walks Image Trace. Short agent entry points: destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When `destinationFolder` holds source files, only a file's own output takes its name, and no export folder takes the name of a subfolder that holds source files: with `a..svg` and `a.svg` saved in place, `a..svg` is saved as - `a-2.svg` and `a.svg` over itself. + `a-2.svg` and `a.svg` over itself. A file's artboard files count as its files, as for data sets: with + `a.vectorcraft` and `a-Artboard-1.vectorcraft` saved in place with `separateArtboards`, `a.vectorcraft` is saved + as `a-2`. - Action sets: `ui.actionSet.delete {sets?: [index], ids?: [id]}` deletes the Actions panel's sets at these indexes (0 is the top set) with their actions, by default the current set, and returns `{deleted}`. An index past the last set is an error, and then no set is deleted. `ids` holds the panel's own set ids, which its question passes: the From 973503a2b11b66c378a955ef0e6bbcc0893f7416 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:20:48 -0800 Subject: [PATCH 10/10] Batch: Override Export's folder for Export for Screens no longer warns of one shared file A data set batch whose Override Export gives each data set its own export folder no longer reports that every data set saves over the same file. --- crates/engine/src/cmd/fileio/action_batch.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 3b56a2f597..4798779926 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -120,7 +120,7 @@ const ONE_FILE_WARNING: &str = "each data set saves over the same file, which ke /// `path`; a Save step without a `path` when the document has a file of its own (saved, and not /// converted from an older version or restored by Data Recovery) and the step gives no `format` or /// the document's own; an Export for Screens step whose `folder`, given or from its -/// `exportPreset`, names a folder, whatever the overrides. +/// `exportPreset`, names a folder, when Override Export does not give each data set its own folder. /// Other steps return their data instead of writing a file. pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { if p.get("source").and_then(Value::as_str) != Some("datasets") { @@ -150,8 +150,8 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { "document.export" | "document.exportSelection" => !export && path, // The batch does not point these exports into `destinationFolder`. "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, - // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. - "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), + // Override Export gives Export for Screens a folder for each data set; otherwise its `folder` stays. + "document.exportForScreens" => !export && super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), _ => false, } }) @@ -1965,7 +1965,15 @@ mod tests { assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back"); assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back"); let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]); - assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder"); + assert!( + !with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), + "Override Export gives each data set its own folder" + ); + assert!( + with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false, "commands": screens})), + "without Override Export the step keeps its folder" + ); + assert!(with(json!({"commands": screens})), "without a destination folder the step keeps its folder"); assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]}))); let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]); assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back");