mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Merge pull request #2504 from dotCooCoo/fix/batch-data-set-file-names
Actions: Batch keeps files named after data sets inside the destination folder
This commit is contained in:
@@ -127,6 +127,9 @@ 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;
|
||||
|
||||
let st = s.doc()?;
|
||||
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
if names.is_empty() {
|
||||
@@ -136,12 +139,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
|
||||
}
|
||||
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 `/`, `\` and `:` 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))));
|
||||
let steps = filter_steps(run.steps, true, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for name in &names {
|
||||
match process_one_dataset(s, &run, name, &doc_stem) {
|
||||
for (name, stem) in names.iter().zip(&stems) {
|
||||
match process_one_dataset(s, &run, name, stem) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
@@ -184,14 +191,12 @@ fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext:
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
|
||||
s.execute("dataset.select", &json!({"name": name}))?;
|
||||
// File + Data Set Name naming when overriding Save/Export (helpx).
|
||||
let stem = format!("{doc_stem}_{name}");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft");
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Data sets share one open document: write each row out but leave it open for the next.
|
||||
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
|
||||
finish_destination(s, run, stem, "vectorcraft", "", false)?;
|
||||
Ok(json!({"dataset": name, "result": batch}))
|
||||
}
|
||||
|
||||
@@ -434,6 +439,102 @@ mod tests {
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// A new document with an empty data set for each of `names`.
|
||||
fn with_data_sets(names: &[&str]) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
for name in names {
|
||||
s.execute("dataset.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keeps_data_set_files_inside_the_destination_folder() {
|
||||
let root = tmp("set-names-root");
|
||||
let dest = root.join("out");
|
||||
// The folder that `a/../../x` would pass through on its way out of `out`.
|
||||
std::fs::create_dir_all(dest.join("Untitled_a")).unwrap();
|
||||
let mut s = with_data_sets(&["a/../../x", "../y"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_..-y.vectorcraft", "Untitled_a", "Untitled_a-..-..-x.vectorcraft"], "{r}");
|
||||
assert_eq!(file_names(&root), ["out"], "nothing is written outside the destination folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_saves_data_sets_named_with_separators() {
|
||||
let dest = tmp("set-names-sep");
|
||||
let exports = tmp("set-names-sep-export");
|
||||
let mut s = with_data_sets(&["a/b", "c\\d", "e:f"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_a-b.vectorcraft", "Untitled_c-d.vectorcraft", "Untitled_e-f.vectorcraft"], "{r}");
|
||||
// Override Export points each data set's export at the destination folder under the same name.
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": exports.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_gives_each_data_set_a_file_of_its_own() {
|
||||
let dest = tmp("set-names-same");
|
||||
let mut s = with_data_sets(&["a:b", "a-b", "A-B"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_refuses_nesting_and_empty_commands() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -186,7 +186,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.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). 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.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), 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 `:` 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}",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
@@ -1076,14 +1076,18 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) ->
|
||||
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
|
||||
/// (compared without case).
|
||||
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() { fallback } else { base }
|
||||
}))
|
||||
}
|
||||
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
names
|
||||
.into_iter()
|
||||
.map(|(name, fallback)| {
|
||||
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = fallback;
|
||||
}
|
||||
.map(|base| {
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if taken.insert(name.to_lowercase()) {
|
||||
|
||||
@@ -148,9 +148,9 @@ fn safe_name(s: &str) -> Option<String> {
|
||||
(!n.trim_matches('.').is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// Text that goes into a file name (prefix, suffix): path separators and `:` become `-`, so it can't
|
||||
/// start a folder or climb out of the export folder.
|
||||
fn file_part(s: &str) -> String {
|
||||
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
|
||||
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
|
||||
pub(super) fn file_part(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1927,7 +1927,10 @@ earlier step deleted the active artboard when it was the last one), the step get
|
||||
remote mode, MCP's `run_command` calls and each step of the `command_batch` tool get the active artboard too. The
|
||||
Actions panel plays an action as a `command.batch`. The Actions panel menu's Batch… runs
|
||||
`action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with
|
||||
destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. Caps: 500 files or data sets, 8
|
||||
destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. Over data sets, the folder
|
||||
destination and the overrides name each file after the document and the data set (`<document>_<data set>.<ext>`).
|
||||
Batch writes `/`, `\` and `:` in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set
|
||||
used (compared without case). Caps: 500 files or data sets, 8
|
||||
folder levels, 256 steps. An action recorded in the Actions panel leaves the artboard out,
|
||||
and its steps of these commands get the artboard that is active when the action plays. A press with the Selection,
|
||||
Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection
|
||||
|
||||
@@ -414,6 +414,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a variable whose objects are locked or inside a hidden or locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for.
|
||||
- Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does.
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\` and `:` in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used.
|
||||
- Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
|
||||
Reference in New Issue
Block a user