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`.
This commit is contained in:
dotCooCoo
2026-10-28 23:35:18 -07:00
parent 33ea3f5300
commit 8f377bac81
3 changed files with 71 additions and 13 deletions
+66 -9
View File
@@ -98,6 +98,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};
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<Value> {
use super::distinct_names;
use super::screens::file_part;
use super::screens::safe_file_name;
let st = s.doc()?;
let names: Vec<String> = 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<Value> {
}
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]
+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, 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
),
+4 -3
View File
@@ -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/<stem>`, 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 `<document>_<data set>`. 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 `<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.
- 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.