From 8a7d83e00f29d734dea58b9dc91f0fa4be746391 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:42:05 -0700 Subject: [PATCH] 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 b6f921b1a..cc160c159 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 9950df754..0a4805104 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 a59572ad1..8e4bf30a6 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.