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.
This commit is contained in:
dotCooCoo
2026-11-03 11:43:07 -08:00
parent a4436388cc
commit c4a8c43dc5
2 changed files with 98 additions and 75 deletions
+95 -74
View File
@@ -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<Option<(String, bool)>> = 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<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)
fn output_names(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec<String> {
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<Item = (String, Vec<String>, bool, Option<String>)>,
reserved: std::collections::HashSet<String>,
) -> Vec<String> {
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<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) || 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<String>,
written: std::collections::HashSet<String>,
}
#[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<String>, 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<Value>
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result<Value> {
fn process_one_file(
s: &mut Session,
run: &Run<'_>,
path: &str,
ext: &str,
stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String,
) -> Result<Value> {
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<DatasetOutput> {
fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, default_ext: &str) -> Vec<DatasetOutput> {
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]
+3 -1
View File
@@ -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