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`.
This commit is contained in:
dotCooCoo
2026-10-29 02:42:05 -07:00
parent 8f377bac81
commit 8a7d83e00f
3 changed files with 99 additions and 11 deletions
+95 -9
View File
@@ -99,7 +99,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
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<Value> {
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<Value> {
// 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<Value> {
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<String>, 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<Item = (String, Vec<String>, bool)>) -> Vec<String> {
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<String> = 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<Value> {
@@ -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]
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+3 -1
View File
@@ -1522,7 +1522,9 @@ already walks Image Trace. Short agent entry points:
named `<document>_<data set>`. 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.