From 8f377bac81925619d3093759e0dbfe39cbd6dc1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:35:18 -0700 Subject: [PATCH] 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 5b58481e8..b6f921b1a 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 8abb6d3b0..9950df754 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 2c8636b34..a59572ad1 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.