mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 23:31:23 +00:00
2061 lines
95 KiB
Rust
2061 lines
95 KiB
Rust
//! `action.batch`: play an action's commands on every file in a folder, or on every data set
|
|
//! of the open document (the Actions panel menu's Batch…).
|
|
//!
|
|
//! Public options follow the Batch dialog: source Folder or Data Sets; destination None,
|
|
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
|
|
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
|
|
|
|
use serde_json::Value;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use serde_json::json;
|
|
|
|
use super::super::{Result, Session, bad};
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use super::super::{bool_or, find_command, str_param};
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result<Value> {
|
|
Err(bad("action.batch", "no file system here"))
|
|
}
|
|
|
|
/// Caps on a batch (folders and MCP payloads are untrusted).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const MAX_BATCH_FILES: usize = 500;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const MAX_BATCH_DEPTH: usize = 8;
|
|
|
|
/// Shared Batch options for one run.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
struct Run<'a> {
|
|
label: &'a str,
|
|
steps: &'a [Value],
|
|
stop_on_error: bool,
|
|
override_save: bool,
|
|
override_export: bool,
|
|
destination: &'a str,
|
|
dest_folder: Option<&'a str>,
|
|
dataset_file_naming: &'a str,
|
|
}
|
|
|
|
/// Play `commands` on each source file or data set.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
|
use super::{absolute_path, may_write};
|
|
|
|
const C: &str = "action.batch";
|
|
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad(C, "missing `commands`"))?;
|
|
if cmds.is_empty() {
|
|
return Err(bad(C, "`commands` must not be empty"));
|
|
}
|
|
if cmds.len() > 256 {
|
|
return Err(bad(C, "more than 256 commands"));
|
|
}
|
|
for (i, c) in cmds.iter().enumerate() {
|
|
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
|
if id.is_empty() {
|
|
return Err(bad(C, format!("commands[{i}]: missing `command`")));
|
|
}
|
|
if id == "command.batch" || id == "action.batch" {
|
|
return Err(bad(C, "batches cannot nest"));
|
|
}
|
|
if find_command(id).is_none() {
|
|
return Err(bad(
|
|
C,
|
|
match super::super::params::nearest_command(id) {
|
|
Some(near) => format!("commands[{i}]: unknown command `{id}`; did you mean `{near}`?"),
|
|
None => format!("commands[{i}]: unknown command `{id}`"),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
let source = str_param(p, "source").unwrap_or("folder");
|
|
let destination = str_param(p, "destination").unwrap_or("none");
|
|
if !matches!(destination, "none" | "saveAndClose" | "folder") {
|
|
return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`"));
|
|
}
|
|
let dest_folder = str_param(p, "destinationFolder").map(absolute_path);
|
|
if destination == "folder" {
|
|
let Some(ref folder) = dest_folder else {
|
|
return Err(bad(C, "`destinationFolder` is required when destination is `folder`"));
|
|
};
|
|
may_write(folder)?;
|
|
std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?;
|
|
}
|
|
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
|
let override_open = bool_or(p, "overrideOpen", true);
|
|
let run = Run {
|
|
label: &label,
|
|
steps: cmds,
|
|
stop_on_error: bool_or(p, "stopOnError", true),
|
|
override_save: bool_or(p, "overrideSave", true),
|
|
override_export: bool_or(p, "overrideExport", true),
|
|
destination,
|
|
dest_folder: dest_folder.as_deref(),
|
|
dataset_file_naming: p.get("datasetFileNaming").map_or("fileDataSetName", |v| v.as_str().unwrap_or("")),
|
|
};
|
|
match source {
|
|
"folder" => batch_folder(s, C, p, override_open, &run),
|
|
"datasets" => {
|
|
let one_file = batch_overwrites_one_file(s, p);
|
|
let mut r = batch_datasets(s, C, &run)?;
|
|
if one_file {
|
|
r["warning"] = json!(ONE_FILE_WARNING);
|
|
}
|
|
Ok(r)
|
|
}
|
|
_ => Err(bad(C, "`source` must be `folder` or `datasets`")),
|
|
}
|
|
}
|
|
|
|
/// The `warning` of a Data Sets batch for which [`batch_overwrites_one_file`] holds.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const ONE_FILE_WARNING: &str = "each data set saves over the same file, which keeps only the last data set";
|
|
|
|
/// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and
|
|
/// writes each data set to the same file. Save and Close saves the open document over its own file
|
|
/// after every data set, unless a Save step that Override Save points into `destinationFolder`
|
|
/// first makes each data set's file the document's own. A step that the batch does not point into `destinationFolder` writes the
|
|
/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export,
|
|
/// Export Selection, PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a
|
|
/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not
|
|
/// converted from an older version or restored by Data Recovery) and the step gives no `format` or
|
|
/// the document's own; an Export for Screens step whose `folder`, given or from its
|
|
/// `exportPreset`, names a folder, when Override Export does not give each data set its own folder.
|
|
/// Other steps return their data instead of writing a file.
|
|
pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool {
|
|
if p.get("source").and_then(Value::as_str) != Some("datasets") {
|
|
return false;
|
|
}
|
|
let folder = p.get("destinationFolder").and_then(Value::as_str).is_some();
|
|
let redirected = |key: &str| folder && p.get(key).and_then(Value::as_bool).unwrap_or(true);
|
|
let (save, export) = (redirected("overrideSave"), redirected("overrideExport"));
|
|
let steps = p.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
|
if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") && !saves_move_to_each_file(steps, save) {
|
|
return true;
|
|
}
|
|
// The format of the open document's own file, which a Save step without a `path` writes.
|
|
let own_format = s.active().filter(|st| st.path.is_some() && !st.converted && !st.recovered).map(|st| st.format);
|
|
let names = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty());
|
|
steps.iter().any(|c| {
|
|
let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default()));
|
|
let path = names(params.get("path"));
|
|
let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
|
|
None => own_format.is_some(),
|
|
Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)),
|
|
};
|
|
match c.get("command").and_then(Value::as_str).unwrap_or("") {
|
|
"document.save" | "file.save" => !save && (path || own_file()),
|
|
"file.saveAs" => !save && path,
|
|
"file.saveCopy" | "file.saveAsTemplate" => path,
|
|
"document.export" | "document.exportSelection" => !export && path,
|
|
// The batch does not point these exports into `destinationFolder`.
|
|
"document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path,
|
|
// Override Export gives Export for Screens a folder for each data set; otherwise its `folder` stays.
|
|
"document.exportForScreens" => !export && super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))),
|
|
_ => false,
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Whether `steps` hold a Save, Save As or `document.save` step that Override Save points at each
|
|
/// data set's own file in the destination folder (`redirected`). Such a step makes that file the
|
|
/// document's own, so Save and Close then saves each data set to its own file.
|
|
fn saves_move_to_each_file(steps: &[Value], redirected: bool) -> bool {
|
|
redirected && steps.iter().any(|c| retargeting_save(c, true))
|
|
}
|
|
|
|
/// Whether step `c` is a Save, Save As or `document.save` that writes a file (its own `path`, or one
|
|
/// in the destination folder when `redirected`) and makes that file the document's own: a save in
|
|
/// the native template format writes a copy and leaves the document's file as it was.
|
|
fn retargeting_save(c: &Value, redirected: bool) -> bool {
|
|
if !matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs")) {
|
|
return false;
|
|
}
|
|
let params = c.get("params");
|
|
let path = params.and_then(|p| p.get("path")).and_then(Value::as_str).filter(|p| !p.is_empty());
|
|
let format_ok = match params.and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()) {
|
|
Some(f) => super::format(f).is_some_and(|f| f.id != "template"),
|
|
// Override Save gives the step a native file name; else its own path names the format.
|
|
None if redirected => true,
|
|
None => path.and_then(super::format_for_name).is_none_or(|f| f.id != "template"),
|
|
};
|
|
format_ok && (redirected || path.is_some())
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
|
|
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)?;
|
|
let include_subdirs = bool_or(p, "includeSubdirs", false);
|
|
let files = collect_files(&folder, include_subdirs, 0)?;
|
|
if files.len() > MAX_BATCH_FILES {
|
|
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files")));
|
|
}
|
|
if files.is_empty() {
|
|
return Err(bad(cmd, "no readable files in the folder"));
|
|
}
|
|
// 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 ([`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 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, place) in files.iter().zip(&in_dest) {
|
|
let ext = super::extension(path);
|
|
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;
|
|
processed.push(json!({"path": path, "error": e.to_string()}));
|
|
if run.stop_on_error {
|
|
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
|
let st = s.doc()?;
|
|
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
|
if names.is_empty() {
|
|
return Err(bad(cmd, "no data sets"));
|
|
}
|
|
if names.len() > MAX_BATCH_FILES {
|
|
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
|
|
}
|
|
let redirected = run.override_save && run.dest_folder.is_some();
|
|
// A recorded save with a file to write gives the document a file of its own before Save and Close.
|
|
if run.destination == "saveAndClose" && !run.steps.iter().any(|c| retargeting_save(c, redirected)) {
|
|
if st.path.is_none() {
|
|
return Err(bad(cmd, "the document has never been saved"));
|
|
}
|
|
// `save_plan` writes no file for a document converted from an older version or restored by
|
|
// Data Recovery.
|
|
if st.converted || st.recovered {
|
|
return Err(bad(cmd, "save the document with Save As before Save and Close"));
|
|
}
|
|
}
|
|
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();
|
|
let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?;
|
|
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, stem) in names.iter().zip(&stems) {
|
|
match process_one_dataset(s, &run, name, stem) {
|
|
Ok(row) => processed.push(row),
|
|
Err(e) => {
|
|
failed += 1;
|
|
processed.push(json!({"dataset": name, "error": e.to_string()}));
|
|
if run.stop_on_error {
|
|
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
|
|
}
|
|
|
|
/// The output stem for a source file's name without its extension: without trailing periods and
|
|
/// spaces and with `-` after a Windows device name ([`super::screens::name_without_device`]), and
|
|
/// "file" when nothing is left. Other characters stay, so `a\b` and `a:b` keep their names on Linux
|
|
/// and macOS.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn source_stem(stem: &str) -> String {
|
|
let stem = super::screens::name_without_device(stem);
|
|
if stem.is_empty() { "file".to_string() } else { stem }
|
|
}
|
|
|
|
/// 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 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
|
|
/// directly inside, or of the subfolder of `folder` that holds it, and whether it is the file's own.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> {
|
|
let folder = std::fs::canonicalize(folder?).ok()?;
|
|
let path = std::fs::canonicalize(path).ok()?;
|
|
let mut parts = path.strip_prefix(&folder).ok()?.components();
|
|
let first = parts.next()?.as_os_str().to_str()?.to_lowercase();
|
|
Some((first, parts.next().is_none()))
|
|
}
|
|
|
|
/// 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"))]
|
|
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;
|
|
}
|
|
n += 1;
|
|
name = format!("{base}-{n}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
cmds.iter()
|
|
.filter(|c| {
|
|
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
|
if drop_open && matches!(id, "document.open" | "file.open") {
|
|
return false;
|
|
}
|
|
if drop_close && matches!(id, "file.close" | "document.close") {
|
|
return false;
|
|
}
|
|
true
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
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 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)?;
|
|
Ok(json!({"path": path, "result": batch}))
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
|
|
s.execute("dataset.select", &json!({"name": name}))?;
|
|
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)?;
|
|
Ok(json!({"dataset": name, "result": batch}))
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, source_path: &str, close: bool) -> Result<()> {
|
|
match run.destination {
|
|
"none" => Ok(()),
|
|
"saveAndClose" => {
|
|
if !source_path.is_empty() {
|
|
s.execute("document.save", &json!({"path": source_path}))?;
|
|
} else {
|
|
s.execute("document.save", &json!({}))?;
|
|
}
|
|
if close {
|
|
s.execute("file.close", &json!({}))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
"folder" => {
|
|
let folder = run.dest_folder.ok_or_else(|| bad("action.batch", "missing destination folder"))?;
|
|
let out_ext = if ext.is_empty() { "vectorcraft" } else { ext };
|
|
let out = output_path(folder, stem, out_ext).ok_or_else(|| bad("action.batch", "unsafe destination file name"))?;
|
|
let out = out.to_string_lossy().into_owned();
|
|
// When `close` is false (data sets), write a copy so the template keeps its path.
|
|
write_result(s, &out, out_ext, !close)?;
|
|
if close {
|
|
s.execute("file.close", &json!({}))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Builds the three data set naming forms, replaces unsafe name characters, and adds `-2`, `-3`
|
|
/// suffixes when a stem was used earlier or its output already exists in a folder the batch may
|
|
/// read and write.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Result<Vec<String>> {
|
|
use super::screens::{file_part, safe_file_name};
|
|
|
|
if !matches!(naming, "fileNumber" | "fileDataSetName" | "dataSetName") {
|
|
return Err(bad("action.batch", "`datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName`"));
|
|
}
|
|
let doc_stem = safe_file_name(doc_stem);
|
|
let doc_stem = if doc_stem.is_empty() || matches!(doc_stem.as_str(), "." | "..") { "Untitled" } else { &doc_stem };
|
|
let bases = names.iter().enumerate().map(|(i, name)| {
|
|
let safe = file_part(name);
|
|
let safe_data_set = safe_file_name(&safe);
|
|
let ordinal = format!("{doc_stem}{:03}", i + 1);
|
|
match naming {
|
|
"fileNumber" => ordinal,
|
|
"dataSetName" if !safe_data_set.is_empty() => safe_data_set,
|
|
"dataSetName" => ordinal,
|
|
// Without trailing periods and spaces, which Windows drops from a folder name, so the
|
|
// Export for Screens folders of `Set.` and `Set` are numbered apart.
|
|
_ => safe_file_name(&format!("{doc_stem}_{safe}")),
|
|
}
|
|
});
|
|
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());
|
|
for base in bases {
|
|
let mut candidate = base.clone();
|
|
let mut suffix = 2;
|
|
while taken.contains(&candidate.to_lowercase())
|
|
|| dataset_output_paths(run.dest_folder, &candidate, &outputs, doc)
|
|
.iter()
|
|
.any(|path| reserved.contains(&path.to_string_lossy().to_lowercase()))
|
|
|| dataset_output_exists(run.dest_folder, &candidate, &outputs, doc)
|
|
{
|
|
if suffix > 10_000 {
|
|
return Err(bad("action.batch", "no free data set file name in destination folder"));
|
|
}
|
|
candidate = format!("{base}-{suffix}");
|
|
suffix += 1;
|
|
}
|
|
taken.insert(candidate.to_lowercase());
|
|
reserved
|
|
.extend(dataset_output_paths(run.dest_folder, &candidate, &outputs, doc).into_iter().map(|path| path.to_string_lossy().to_lowercase()));
|
|
stems.push(candidate);
|
|
}
|
|
Ok(stems)
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
struct DatasetOutput {
|
|
ext: String,
|
|
artboards: bool,
|
|
joiner: &'static str,
|
|
/// An Export for Screens or Export Assets step writes into the folder `{stem}` instead.
|
|
folder: bool,
|
|
}
|
|
|
|
/// 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`]). 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, 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: 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("");
|
|
if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") {
|
|
outputs.push(DatasetOutput { ext: String::new(), artboards: false, joiner: "-", folder: true });
|
|
continue;
|
|
}
|
|
let writes = (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|
|
|| (run.override_export && matches!(id, "document.export" | "document.exportSelection"));
|
|
if writes {
|
|
let format = step.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty());
|
|
let ext = match format {
|
|
Some(format) if super::format(format).is_some() => format_extension(format),
|
|
Some(_) => continue,
|
|
None => default_ext.to_string(),
|
|
};
|
|
if safe_extension(&ext) {
|
|
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")
|
|
} else {
|
|
doc.artboards.len() > 1
|
|
&& id == "document.export"
|
|
&& matches!(
|
|
format_id,
|
|
"svg"
|
|
| "svgz"
|
|
| "eps"
|
|
| "png"
|
|
| "jpg"
|
|
| "webp"
|
|
| "gif"
|
|
| "png8"
|
|
| "tiff"
|
|
| "bmp"
|
|
| "tga"
|
|
| "psd"
|
|
| "dxf"
|
|
| "emf"
|
|
| "wmf"
|
|
| "ai"
|
|
)
|
|
};
|
|
outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" }, folder: false });
|
|
}
|
|
}
|
|
}
|
|
outputs
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn dataset_output_exists(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> bool {
|
|
let Some(folder) = folder else { return false };
|
|
if super::may_read(folder).is_err() || super::may_write(folder).is_err() {
|
|
return false;
|
|
}
|
|
dataset_output_paths(Some(folder), stem, outputs, doc).iter().any(|path| {
|
|
let path_text = path.to_string_lossy();
|
|
super::may_read(&path_text).is_ok() && super::may_write(&path_text).is_ok() && path.exists()
|
|
})
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn dataset_output_paths(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec<std::path::PathBuf> {
|
|
let Some(folder) = folder else { return Vec::new() };
|
|
let boards: Vec<_> = (0..doc.artboards.len()).collect();
|
|
let mut parts = super::artboard_file_names(doc, &boards);
|
|
for board in boards {
|
|
parts.extend(super::artboard_file_names(doc, &[board]));
|
|
}
|
|
let mut paths = Vec::new();
|
|
for output in outputs {
|
|
if output.folder {
|
|
paths.extend(output_folder(folder, stem));
|
|
continue;
|
|
}
|
|
if !safe_extension(&output.ext) || super::format(&output.ext).is_none() {
|
|
continue;
|
|
}
|
|
paths.extend(output_path(folder, stem, &output.ext));
|
|
if output.artboards {
|
|
for part in &parts {
|
|
paths.extend(output_path(folder, &format!("{stem}{}{part}", output.joiner), &output.ext));
|
|
}
|
|
}
|
|
}
|
|
paths
|
|
}
|
|
|
|
/// Returns whether an extension is safe to use in an output file name.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn safe_extension(ext: &str) -> bool {
|
|
!ext.is_empty() && !ext.contains(['/', '\\', ':']) && !ext.contains("..")
|
|
}
|
|
|
|
/// Returns a path inside the destination folder for a safe stem and extension.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn output_path(folder: &str, stem: &str, ext: &str) -> Option<std::path::PathBuf> {
|
|
use std::path::Path;
|
|
if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(std::path::is_separator) || !safe_extension(ext) {
|
|
return None;
|
|
}
|
|
let name = format!("{stem}.{ext}");
|
|
if name.contains(std::path::is_separator) {
|
|
return None;
|
|
}
|
|
let path = Path::new(folder).join(name);
|
|
(path.parent() == Some(Path::new(folder))).then_some(path)
|
|
}
|
|
|
|
/// Returns the folder `stem` directly inside the destination folder, where an Export for Screens or
|
|
/// Export Assets step writes, for a safe stem.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn output_folder(folder: &str, stem: &str) -> Option<std::path::PathBuf> {
|
|
use std::path::Path;
|
|
if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(std::path::is_separator) {
|
|
return None;
|
|
}
|
|
let path = Path::new(folder).join(stem);
|
|
(path.parent() == Some(Path::new(folder))).then_some(path)
|
|
}
|
|
|
|
/// Save when the extension is a Save format; otherwise export (PNG, JPEG…).
|
|
/// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result<()> {
|
|
use super::SAVE_FORMATS;
|
|
let id = if ext == "drawcraft" { "vectorcraft" } else { ext };
|
|
if SAVE_FORMATS.contains(&id) {
|
|
let cmd = if as_copy { "file.saveCopy" } else { "document.save" };
|
|
s.execute(cmd, &json!({"path": path}))?;
|
|
} else {
|
|
s.execute("document.export", &json!({"path": path, "format": id}))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
|
|
/// extension of the step's `format` ([`format_extension`]), else `default_ext`. Export for Screens
|
|
/// and Export Assets steps get `dest_folder/{stem}` as their `folder`, one folder for each source
|
|
/// file or data set, and name the files in it themselves.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn rewrite_paths(
|
|
steps: &[Value],
|
|
override_save: bool,
|
|
override_export: bool,
|
|
dest_folder: Option<&str>,
|
|
stem: &str,
|
|
default_ext: &str,
|
|
) -> Vec<Value> {
|
|
let Some(folder) = dest_folder else {
|
|
return steps.to_vec();
|
|
};
|
|
steps
|
|
.iter()
|
|
.map(|c| {
|
|
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
|
let mut c = c.clone();
|
|
let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|
|
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection" | "assets.export"));
|
|
if !rewrite {
|
|
return c;
|
|
}
|
|
let params = c.get("params").cloned().unwrap_or(json!({}));
|
|
let mut params = params.as_object().cloned().unwrap_or_default();
|
|
if matches!(id, "document.exportForScreens" | "assets.export") {
|
|
let Some(dir) = output_folder(folder, stem) else { return c };
|
|
params.insert("folder".into(), json!(dir.to_string_lossy()));
|
|
} else {
|
|
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
|
|
Some(f) => format_extension(f),
|
|
None => default_ext.to_string(),
|
|
};
|
|
if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) {
|
|
return c;
|
|
}
|
|
let Some(file) = output_path(folder, stem, &ext) else { return c };
|
|
params.insert("path".into(), json!(file.to_string_lossy()));
|
|
}
|
|
if let Some(o) = c.as_object_mut() {
|
|
o.insert("params".into(), Value::Object(params));
|
|
}
|
|
c
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The extension of the file a step writes in `format` (an id or extension, as [`super::format`]
|
|
/// reads it: any case, a leading dot allowed): the extension as written, in lower case and without
|
|
/// the dot, when it is one of the format's (`SVG` → `svg`, `.JPEG` → `jpeg`), else the format's
|
|
/// first (`png8` → `png`, `template` → `vctemplate`). An unknown format (`xyz`) keeps its spelling,
|
|
/// and the step fails with its command's error.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn format_extension(format: &str) -> String {
|
|
let written = format.trim_start_matches('.').to_ascii_lowercase();
|
|
match super::format(format) {
|
|
Some(f) if f.extensions.contains(&written.as_str()) => written,
|
|
Some(f) => f.extensions.first().map_or(written, |e| (*e).to_string()),
|
|
None => format.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Readable files under `folder` (and optionally its subfolders), capped.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
|
|
use std::path::PathBuf;
|
|
|
|
use super::list_dir;
|
|
|
|
if depth > MAX_BATCH_DEPTH {
|
|
return Err(bad("action.batch", format!("folders deeper than {MAX_BATCH_DEPTH} levels")));
|
|
}
|
|
let (entries, _) = list_dir(folder, MAX_BATCH_FILES.saturating_mul(2))?;
|
|
let mut files = Vec::new();
|
|
let mut subdirs = Vec::new();
|
|
for e in entries {
|
|
let name = e.get("name").and_then(Value::as_str).unwrap_or("");
|
|
if name.is_empty() || name.starts_with('.') {
|
|
continue;
|
|
}
|
|
let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
|
|
let path = PathBuf::from(folder).join(name);
|
|
let path = path.to_string_lossy().into_owned();
|
|
match kind {
|
|
"file" if is_openable(&path) => files.push(path),
|
|
"folder" if include_subdirs => subdirs.push(path),
|
|
_ => {}
|
|
}
|
|
}
|
|
files.sort();
|
|
for sub in subdirs {
|
|
let mut nested = collect_files(&sub, true, depth + 1)?;
|
|
files.append(&mut nested);
|
|
if files.len() > MAX_BATCH_FILES {
|
|
break;
|
|
}
|
|
}
|
|
Ok(files)
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn is_openable(path: &str) -> bool {
|
|
use super::{FORMATS, extension, open_extensions};
|
|
let ext = extension(path);
|
|
if ext.is_empty() {
|
|
return false;
|
|
}
|
|
open_extensions().iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn tmp(tag: &str) -> std::path::PathBuf {
|
|
let d = std::env::temp_dir().join(format!("vc-action-batch-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn write_svg(dir: &std::path::Path, name: &str, fill: &str) -> std::path::PathBuf {
|
|
let p = dir.join(name);
|
|
std::fs::write(
|
|
&p,
|
|
format!(
|
|
r#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" width="100" height="80">
|
|
<rect x="10" y="10" width="40" height="30" fill="{fill}"/></svg>"#
|
|
),
|
|
)
|
|
.unwrap();
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn batch_folder_runs_action_and_saves_to_destination() {
|
|
let src = tmp("src");
|
|
let dest = tmp("dest");
|
|
write_svg(&src, "a.svg", "#f00");
|
|
write_svg(&src, "b.svg", "#0f0");
|
|
let mut s = Session::new();
|
|
// Need a document so command.batch's has_doc passes after the first open leaves one.
|
|
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"destination": "folder",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "object.unlockAll", "params": {}}],
|
|
"label": "Unlock"
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["count"], 2);
|
|
assert_eq!(r["failed"], 0);
|
|
assert!(dest.join("a.svg").is_file(), "a.svg written");
|
|
assert!(dest.join("b.svg").is_file(), "b.svg written");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn batch_folder_keeps_backslash_in_source_file_name() {
|
|
let src = tmp("backslash-src");
|
|
let dest = tmp("backslash-dest");
|
|
write_svg(&src, "a\\b.svg", "#f00");
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "folder",
|
|
"folder": src.to_string_lossy(),
|
|
"destination": "folder",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"stopOnError": true,
|
|
"commands": [{"command": "object.unlockAll", "params": {}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["count"], 1, "{r}");
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(r["stopped"], false, "{r}");
|
|
assert!(dest.join("a\\b.svg").is_file(), "{r}");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn batch_folder_override_export_keeps_backslash_in_source_file_name() {
|
|
let src = tmp("backslash-export-src");
|
|
let dest = tmp("backslash-export-dest");
|
|
write_svg(&src, "a\\b.svg", "#f00");
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "folder",
|
|
"folder": src.to_string_lossy(),
|
|
"destination": "folder",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"stopOnError": true,
|
|
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["count"], 1, "{r}");
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(r["stopped"], false, "{r}");
|
|
assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}");
|
|
}
|
|
|
|
/// A source stem keeps `\`, `:` and the other characters Windows does not allow in a name, which
|
|
/// only reach the batch where the file system allows them, and loses trailing periods and spaces.
|
|
#[test]
|
|
fn source_stem_keeps_characters_the_file_system_allowed() {
|
|
for (stem, want) in [("a\\b", "a\\b"), ("a:b", "a:b"), ("a.", "a"), ("a. ", "a"), ("CON", "CON-"), ("...", "file"), ("", "file")] {
|
|
assert_eq!(source_stem(stem), want, "{stem:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn output_path_accepts_only_platform_file_name_separators() {
|
|
let folder = tmp("output-path");
|
|
let folder = folder.to_string_lossy();
|
|
let backslash = output_path(&folder, "a\\b", "svg");
|
|
#[cfg(windows)]
|
|
assert!(backslash.is_none());
|
|
#[cfg(not(windows))]
|
|
assert_eq!(backslash, Some(std::path::Path::new(folder.as_ref()).join("a\\b.svg")));
|
|
for stem in ["a/b", ".", "..", ""] {
|
|
assert!(output_path(&folder, stem, "svg").is_none(), "{stem:?}");
|
|
}
|
|
#[cfg(windows)]
|
|
assert_eq!(std::path::Path::new("a\\b.svg").file_stem(), Some(std::ffi::OsStr::new("b")));
|
|
}
|
|
|
|
#[test]
|
|
fn batch_skips_open_steps_when_override_open() {
|
|
let src = tmp("open");
|
|
let dest = tmp("open-out");
|
|
write_svg(&src, "only.svg", "#00f");
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"destination": "folder",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"overrideOpen": true,
|
|
"commands": [
|
|
{"command": "document.open", "params": {"path": "/no/such/file.svg"}},
|
|
{"command": "object.unlockAll", "params": {}}
|
|
]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert!(dest.join("only.svg").is_file());
|
|
}
|
|
|
|
/// Override Export names each file with the extension of the format the step names, however
|
|
/// the step spells it; a format given by one of its extensions keeps that extension.
|
|
#[test]
|
|
fn batch_override_export_names_files_with_the_format_s_extension() {
|
|
let src = tmp("ext-src");
|
|
write_svg(&src, "a.svg", "#f00");
|
|
let cases = [(".svg", "a.svg"), ("SVG", "a.svg"), ("svg", "a.svg"), ("png8", "a.png"), ("template", "a.vctemplate"), (".JPEG", "a.jpeg")];
|
|
for (i, (format, want)) in cases.into_iter().enumerate() {
|
|
let dest = tmp(&format!("ext-out-{i}"));
|
|
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.export", "params": {"format": format}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{format}: {r}");
|
|
assert_eq!(file_names(&dest), [want], "{format}: {r}");
|
|
}
|
|
// An unknown format keeps its spelling, and the step fails with the export's error.
|
|
let dest = tmp("ext-out-unknown");
|
|
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.export", "params": {"format": "xyz"}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 1, "{r}");
|
|
assert!(r["processed"][0]["error"].as_str().is_some_and(|e| e.contains("unknown format `xyz`")), "{r}");
|
|
assert!(file_names(&dest).is_empty(), "{r}");
|
|
}
|
|
|
|
/// Override Export writes an Export for Screens step's files into a folder for each source file
|
|
/// inside the destination folder, under the names the export gives them, whether the step names
|
|
/// a folder of its own, takes one from an export preset or names none. With Override Export off,
|
|
/// the files go to the step's folder.
|
|
#[test]
|
|
fn batch_override_export_sends_export_for_screens_files_to_the_destination_folder() {
|
|
let src = tmp("screens-src");
|
|
write_svg(&src, "a.svg", "#f00");
|
|
write_svg(&src, "b.svg", "#0f0");
|
|
let own = tmp("screens-own");
|
|
let svg = json!([{"format": "svg"}]);
|
|
let preset = json!({"name": "Own Folder", "settings": {"formats": svg, "folder": own.to_string_lossy()}});
|
|
let steps = [json!({"folder": own.to_string_lossy(), "formats": svg}), json!({"exportPreset": "Own Folder"}), json!({"formats": svg})];
|
|
for (i, params) in steps.into_iter().enumerate() {
|
|
let dest = tmp(&format!("screens-out-{i}"));
|
|
let mut s = Session::new();
|
|
s.execute("document.exportPresets.save", &preset).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.exportForScreens", "params": params}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{params}: {r}");
|
|
assert_eq!(file_names(&dest), ["a", "b"], "{params}: {r}");
|
|
for stem in ["a", "b"] {
|
|
assert_eq!(file_names(&dest.join(stem)), ["Artboard-1.svg"], "{params}: {r}");
|
|
}
|
|
assert!(file_names(&own).is_empty(), "{params}: {r}");
|
|
}
|
|
let dest = tmp("screens-out-off");
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"overrideExport": false,
|
|
"commands": [{"command": "document.exportForScreens", "params": {"folder": own.to_string_lossy(), "formats": svg}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&own), ["Artboard-1.svg"], "{r}");
|
|
assert!(file_names(&dest).is_empty(), "{r}");
|
|
}
|
|
|
|
/// Two source files with the same name in different subfolders get Export for Screens folders of
|
|
/// their own: the second one's folder is numbered, as a repeated data set name is.
|
|
#[test]
|
|
fn batch_override_export_numbers_export_for_screens_folders_of_files_with_the_same_name() {
|
|
let src = tmp("screens-same-src");
|
|
std::fs::create_dir_all(src.join("sub")).unwrap();
|
|
write_svg(&src, "a.svg", "#f00");
|
|
write_svg(&src.join("sub"), "a.svg", "#0f0");
|
|
let dest = tmp("screens-same-out");
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"includeSubdirs": true,
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
for folder in file_names(&dest) {
|
|
println!("{folder}/: {:?}", file_names(&dest.join(&folder)));
|
|
}
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&dest), ["a", "a-2"], "{r}");
|
|
for (folder, fill) in [("a", "#ff0000"), ("a-2", "#00ff00")] {
|
|
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
|
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
|
|
assert!(svg.contains(fill), "{folder}: {svg}");
|
|
}
|
|
}
|
|
|
|
/// 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}");
|
|
}
|
|
}
|
|
|
|
/// 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}");
|
|
}
|
|
}
|
|
|
|
/// With the destination folder the same as the source folder, a file's output does not replace
|
|
/// another source file before Batch opens it: `a..svg` would be saved as `a.svg`, so it is saved
|
|
/// as `a-2.svg`, and `a.svg` is saved over itself.
|
|
#[test]
|
|
fn batch_does_not_save_over_another_source_file_in_the_destination_folder() {
|
|
let src = tmp("in-place");
|
|
write_svg(&src, "a..svg", "#f00");
|
|
write_svg(&src, "a.svg", "#0f0");
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"destination": "folder",
|
|
"destinationFolder": src.to_string_lossy(),
|
|
"commands": [{"command": "object.unlockAll", "params": {}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&src), ["a-2.svg", "a..svg", "a.svg"], "{r}");
|
|
for (file, fill) in [("a-2.svg", "#ff0000"), ("a.svg", "#00ff00")] {
|
|
let svg = std::fs::read_to_string(src.join(file)).unwrap();
|
|
assert!(svg.contains(fill), "{file}: {svg}");
|
|
}
|
|
}
|
|
|
|
/// With the destination folder the same as the source folder and `includeSubdirs`, an Export for
|
|
/// Screens folder does not take the name of a subfolder that holds source files: the folder of
|
|
/// `a.svg` is `a-2`, so `a/Artboard-1.svg` is opened as it was.
|
|
#[test]
|
|
fn batch_does_not_export_into_a_source_subfolder() {
|
|
let src = tmp("in-place-sub");
|
|
std::fs::create_dir_all(src.join("a")).unwrap();
|
|
write_svg(&src, "a.svg", "#f00");
|
|
write_svg(&src.join("a"), "Artboard-1.svg", "#0f0");
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"includeSubdirs": true,
|
|
"destinationFolder": src.to_string_lossy(),
|
|
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
let source = std::fs::read_to_string(src.join("a").join("Artboard-1.svg")).unwrap();
|
|
assert!(source.contains("fill=\"#0f0\""), "{r}: {source}");
|
|
let export = std::fs::read_to_string(src.join("a-2").join("Artboard-1.svg")).unwrap();
|
|
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]
|
|
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]
|
|
fn batch_numbers_saved_files_of_source_files_with_the_same_name() {
|
|
let src = tmp("save-same-src");
|
|
std::fs::create_dir_all(src.join("sub")).unwrap();
|
|
write_svg(&src, "a.svg", "#f00");
|
|
write_svg(&src.join("sub"), "a.svg", "#0f0");
|
|
let save = json!([{"command": "file.saveAs", "params": {"format": "svg"}}]);
|
|
let unlock = json!([{"command": "object.unlockAll", "params": {}}]);
|
|
for (tag, destination, commands) in [("save", "none", save), ("folder", "folder", unlock)] {
|
|
let dest = tmp(&format!("save-same-{tag}"));
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": src.to_string_lossy(),
|
|
"includeSubdirs": true,
|
|
"destination": destination,
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": commands
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{tag}: {r}");
|
|
assert_eq!(file_names(&dest), ["a-2.svg", "a.svg"], "{tag}: {r}");
|
|
for (file, fill) in [("a.svg", "#ff0000"), ("a-2.svg", "#00ff00")] {
|
|
let svg = std::fs::read_to_string(dest.join(file)).unwrap();
|
|
assert!(svg.contains(fill), "{tag}: {file}: {svg}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Override Export writes an Export Assets step's files into a folder for each data set inside
|
|
/// the destination folder, under the assets' names.
|
|
#[test]
|
|
fn batch_override_export_sends_asset_export_files_to_the_destination_folder() {
|
|
let own = tmp("assets-own");
|
|
let dest = tmp("assets-out");
|
|
let mut s = with_data_sets(&["a", "b"]);
|
|
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": {"folder": own.to_string_lossy(), "formats": [{"format": "svg"}]}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_b"], "{r}");
|
|
for stem in ["Untitled_a", "Untitled_b"] {
|
|
assert_eq!(file_names(&dest.join(stem)), ["Logo.svg"], "{r}");
|
|
}
|
|
assert!(file_names(&own).is_empty(), "{r}");
|
|
}
|
|
|
|
/// Override Save names each file with the extension of the format the step names, also over
|
|
/// data sets.
|
|
#[test]
|
|
fn batch_override_save_names_files_with_the_format_s_extension() {
|
|
for (i, format) in [".svg", "SVG", ".PDF"].into_iter().enumerate() {
|
|
let dest = tmp(&format!("ext-save-{i}"));
|
|
let mut s = with_data_sets(&["a"]);
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "file.saveAs", "params": {"format": format}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{format}: {r}");
|
|
let want = format!("Untitled_a.{}", format.trim_start_matches('.').to_ascii_lowercase());
|
|
assert_eq!(file_names(&dest), [want], "{format}: {r}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn batch_datasets_applies_each_row() {
|
|
let dest = tmp("sets");
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
|
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap();
|
|
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
|
s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap();
|
|
s.execute("dataset.capture", &json!({})).unwrap();
|
|
s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap();
|
|
s.execute("dataset.capture", &json!({})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets",
|
|
"destination": "folder",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "object.unlockAll", "params": {}}],
|
|
"label": "Per set"
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["count"], 2, "{r}");
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
let written: Vec<_> = std::fs::read_dir(&dest).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect();
|
|
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
|
|
}
|
|
|
|
fn named_batch(s: &mut Session, dest: &std::path::Path, naming: Option<&str>) -> Value {
|
|
let mut p = json!({
|
|
"source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "object.unlockAll", "params": {}}]
|
|
});
|
|
if let Some(naming) = naming {
|
|
p["datasetFileNaming"] = json!(naming);
|
|
}
|
|
s.execute("action.batch", &p).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn batch_data_set_naming_choices_and_omitted_default() {
|
|
for (i, (naming, want)) in [
|
|
(Some("fileNumber"), ["source001.vectorcraft", "source002.vectorcraft"]),
|
|
(Some("fileDataSetName"), ["source_Alpha.vectorcraft", "source_Beta.vectorcraft"]),
|
|
(Some("dataSetName"), ["Alpha.vectorcraft", "Beta.vectorcraft"]),
|
|
(None, ["source_Alpha.vectorcraft", "source_Beta.vectorcraft"]),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let dest = tmp(&format!("choice-{i}"));
|
|
let mut s = with_data_sets(&["Alpha", "Beta"]);
|
|
let saved = tmp(&format!("saved-{i}")).join("source.vectorcraft");
|
|
s.execute("document.save", &json!({"path": saved.to_string_lossy()})).unwrap();
|
|
let r = named_batch(&mut s, &dest, naming);
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&dest), want, "{naming:?}: {r}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn batch_file_number_uses_three_digits_through_500() {
|
|
let names: Vec<String> = (1..=500).map(|n| format!("Set {n}")).collect();
|
|
let run = Run {
|
|
label: "Test",
|
|
steps: &[],
|
|
stop_on_error: true,
|
|
override_save: true,
|
|
override_export: true,
|
|
destination: "none",
|
|
dest_folder: None,
|
|
dataset_file_naming: "fileDataSetName",
|
|
};
|
|
let stems = dataset_stems(&names, "source", "fileNumber", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap();
|
|
for n in [1, 2, 10, 100, 500] {
|
|
assert_eq!(stems[n - 1], format!("source{n:03}"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn batch_unsaved_data_set_names_and_empty_fallback() {
|
|
let dest = tmp("unsaved-number");
|
|
let mut s = with_data_sets(&["Alpha"]);
|
|
assert_eq!(named_batch(&mut s, &dest, Some("fileNumber"))["failed"], 0);
|
|
assert_eq!(file_names(&dest), ["Untitled001.vectorcraft"]);
|
|
let dest = tmp("unsaved-name");
|
|
assert_eq!(named_batch(&mut s, &dest, Some("fileDataSetName"))["failed"], 0);
|
|
assert_eq!(file_names(&dest), ["Untitled_Alpha.vectorcraft"]);
|
|
let run = Run {
|
|
label: "Test",
|
|
steps: &[],
|
|
stop_on_error: true,
|
|
override_save: true,
|
|
override_export: true,
|
|
destination: "none",
|
|
dest_folder: None,
|
|
dataset_file_naming: "fileDataSetName",
|
|
};
|
|
assert_eq!(dataset_stems(&[String::new()], "Untitled", "dataSetName", &run, &s.doc().unwrap().doc).unwrap(), ["Untitled001"]);
|
|
assert_eq!(dataset_stems(&[" ".to_string()], "Untitled", "dataSetName", &run, &s.doc().unwrap().doc).unwrap(), ["Untitled001"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_data_set_name_collisions_and_existing_files() {
|
|
let clear = tmp("choice-collision-clear");
|
|
let mut s = with_data_sets(&["a:b", "a-b"]);
|
|
let r = named_batch(&mut s, &clear, Some("dataSetName"));
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&clear), ["a-b-2.vectorcraft", "a-b.vectorcraft"]);
|
|
let dest = tmp("choice-collision");
|
|
std::fs::write(dest.join("a-b.vectorcraft"), b"keep").unwrap();
|
|
let mut s = with_data_sets(&["a:b", "a-b"]);
|
|
let r = named_batch(&mut s, &dest, Some("dataSetName"));
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(std::fs::read(dest.join("a-b.vectorcraft")).unwrap(), b"keep");
|
|
assert_eq!(file_names(&dest), ["a-b-2.vectorcraft", "a-b-3.vectorcraft", "a-b.vectorcraft"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_data_set_name_avoids_windows_device_files() {
|
|
let dest = tmp("choice-device");
|
|
let mut s = with_data_sets(&["CON"]);
|
|
let r = named_batch(&mut s, &dest, Some("dataSetName"));
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&dest), ["CON-.vectorcraft"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_override_export_keeps_existing_output() {
|
|
let dest = tmp("choice-existing-export");
|
|
std::fs::write(dest.join("Alpha.svg"), b"keep").unwrap();
|
|
let mut s = with_data_sets(&["Alpha"]);
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(std::fs::read(dest.join("Alpha.svg")).unwrap(), b"keep");
|
|
assert_eq!(file_names(&dest), ["Alpha-2.svg", "Alpha.svg"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_artboard_export_keeps_existing_output() {
|
|
let dest = tmp("choice-existing-artboard-export");
|
|
let existing = dest.join("Alpha-Artboard-1.png");
|
|
std::fs::write(&existing, b"keep").unwrap();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap();
|
|
s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.export", "params": {"format": "png", "useArtboards": true}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(std::fs::read(existing).unwrap(), b"keep");
|
|
assert_eq!(file_names(&dest), ["Alpha-2-Artboard-1.png", "Alpha-2-Artboard-2.png", "Alpha-Artboard-1.png"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_remembered_separate_artboards_keeps_existing_output() {
|
|
let source = tmp("choice-remembered-separate-source");
|
|
let dest = tmp("choice-remembered-separate-dest");
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap();
|
|
s.execute("document.save", &json!({"path": source.join("source.vectorcraft").to_string_lossy(), "separateArtboards": true})).unwrap();
|
|
assert!(source.join("source-Artboard-1.vectorcraft").exists());
|
|
let existing = dest.join("Alpha-Artboard-1.vectorcraft");
|
|
std::fs::write(&existing, b"keep").unwrap();
|
|
s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.save"}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(std::fs::read(existing).unwrap(), b"keep");
|
|
assert_eq!(
|
|
file_names(&dest),
|
|
["Alpha-2-Artboard-1.vectorcraft", "Alpha-2-Artboard-2.vectorcraft", "Alpha-2.vectorcraft", "Alpha-Artboard-1.vectorcraft",]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_single_artboard_save_keeps_existing_artboard_file() {
|
|
let source = tmp("choice-single-artboard-source");
|
|
let dest = tmp("choice-single-artboard-dest");
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 1})).unwrap();
|
|
s.execute(
|
|
"document.save",
|
|
&json!({
|
|
"path": source.join("source.vectorcraft").to_string_lossy(),
|
|
"separateArtboards": true
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert!(source.join("source-Artboard-1.vectorcraft").exists());
|
|
|
|
let existing = dest.join("Alpha-Artboard-1.vectorcraft");
|
|
std::fs::write(&existing, b"keep").unwrap();
|
|
s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.save", "params": {"separateArtboards": true}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(std::fs::read(existing).unwrap(), b"keep");
|
|
assert_eq!(file_names(&dest), ["Alpha-2-Artboard-1.vectorcraft", "Alpha-2.vectorcraft", "Alpha-Artboard-1.vectorcraft"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_pdf_export_ignores_artboard_named_file() {
|
|
let dest = tmp("choice-pdf-artboard-name");
|
|
let existing = dest.join("Alpha-Artboard-1.pdf");
|
|
std::fs::write(&existing, b"keep").unwrap();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap();
|
|
s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.export", "params": {"format": "pdf"}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(std::fs::read(existing).unwrap(), b"keep");
|
|
assert_eq!(file_names(&dest), ["Alpha-Artboard-1.pdf", "Alpha.pdf"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_separate_artboards_ignores_unrelated_extension() {
|
|
let dest = tmp("choice-separate-unrelated-extension");
|
|
let unrelated = dest.join("Alpha-photo.png");
|
|
std::fs::write(&unrelated, b"keep").unwrap();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap();
|
|
s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.save", "params": {"separateArtboards": true}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(std::fs::read(unrelated).unwrap(), b"keep");
|
|
assert_eq!(file_names(&dest), ["Alpha-Artboard-1.vectorcraft", "Alpha-Artboard-2.vectorcraft", "Alpha-photo.png", "Alpha.vectorcraft"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_separate_artboards_reserves_prior_output_names() {
|
|
let dest = tmp("choice-separate-reserved");
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 20, "height": 20, "artboards": 2})).unwrap();
|
|
s.execute("dataset.new", &json!({"name": "Alpha"})).unwrap();
|
|
s.execute("dataset.new", &json!({"name": "Alpha-Artboard-1"})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.save", "params": {"separateArtboards": true}}]
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert!(dest.join("Alpha-Artboard-1.vectorcraft").exists());
|
|
assert!(dest.join("Alpha-Artboard-1-2.vectorcraft").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn batch_does_not_probe_a_denied_destination() {
|
|
let allowed = tmp("probe-allowed");
|
|
let denied = tmp("probe-denied");
|
|
std::fs::write(denied.join("Alpha.svg"), b"private").unwrap();
|
|
let roots = crate::file_access::AutomationRoots::new(&[allowed.as_path()], &[allowed.as_path()]).unwrap().unwrap();
|
|
let steps = [json!({"command": "document.export", "params": {"format": "svg"}})];
|
|
let folder = denied.to_string_lossy().into_owned();
|
|
let run = Run {
|
|
label: "Test",
|
|
steps: &steps,
|
|
stop_on_error: true,
|
|
override_save: true,
|
|
override_export: true,
|
|
destination: "none",
|
|
dest_folder: Some(&folder),
|
|
dataset_file_naming: "fileDataSetName",
|
|
};
|
|
crate::file_access::confine(Some(&roots), || {
|
|
assert_eq!(
|
|
dataset_stems(&["Alpha".into()], "source", "dataSetName", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap(),
|
|
["Alpha"]
|
|
);
|
|
let mut s = with_data_sets(&["Alpha"]);
|
|
let result = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"source": "datasets", "datasetFileNaming": "dataSetName", "destination": "none",
|
|
"destinationFolder": folder, "overrideExport": true,
|
|
"commands": steps,
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert!(!result.to_string().contains("Alpha-2"), "{result}");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn batch_does_not_probe_unknown_format_paths() {
|
|
let root = tmp("probe-format");
|
|
let folder = root.join("out");
|
|
std::fs::create_dir_all(&folder).unwrap();
|
|
std::fs::write(root.join("outside"), b"private").unwrap();
|
|
for format in ["../outside", "x/../../outside", "..\\..\\outside"] {
|
|
let steps = [json!({"command": "document.export", "params": {"format": format}})];
|
|
let folder = folder.to_string_lossy().into_owned();
|
|
let run = Run {
|
|
label: "Test",
|
|
steps: &steps,
|
|
stop_on_error: true,
|
|
override_save: true,
|
|
override_export: true,
|
|
destination: "none",
|
|
dest_folder: Some(&folder),
|
|
dataset_file_naming: "fileDataSetName",
|
|
};
|
|
assert_eq!(
|
|
dataset_stems(&["Alpha".into()], "source", "dataSetName", &run, &vectorcraft_doc::Document::new(100.0, 80.0))
|
|
.unwrap_or_else(|e| panic!("{format}: {e}")),
|
|
["Alpha"],
|
|
"{format}"
|
|
);
|
|
assert_eq!(rewrite_paths(&steps, false, true, Some(&folder), "Alpha", "vectorcraft"), steps, "{format}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn batch_probe_and_dot_names_stay_in_destination() {
|
|
let root = tmp("probe-parent");
|
|
let folder = root.join("out");
|
|
std::fs::create_dir_all(&folder).unwrap();
|
|
std::fs::write(root.join("outside.svg"), b"private").unwrap();
|
|
let folder = folder.to_string_lossy().into_owned();
|
|
let run = Run {
|
|
label: "Test",
|
|
steps: &[],
|
|
stop_on_error: true,
|
|
override_save: true,
|
|
override_export: true,
|
|
destination: "none",
|
|
dest_folder: Some(&folder),
|
|
dataset_file_naming: "fileDataSetName",
|
|
};
|
|
assert_eq!(
|
|
dataset_stems(&[".".into(), "..".into()], ".", "dataSetName", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap(),
|
|
["Untitled001", "Untitled002"]
|
|
);
|
|
assert_eq!(
|
|
dataset_stems(&["Alpha".into()], "..", "fileNumber", &run, &vectorcraft_doc::Document::new(100.0, 80.0)).unwrap(),
|
|
["Untitled001"]
|
|
);
|
|
let output = root.join("named");
|
|
let mut s = with_data_sets(&[".", ".."]);
|
|
let r = named_batch(&mut s, &output, Some("dataSetName"));
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(file_names(&output), ["Untitled001.vectorcraft", "Untitled002.vectorcraft"]);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_probe_rejects_parent_escape() {
|
|
let root = tmp("probe-parent-only");
|
|
let folder = root.join("out");
|
|
std::fs::create_dir_all(&folder).unwrap();
|
|
std::fs::write(root.join("outside.svg"), b"private").unwrap();
|
|
let folder = folder.to_string_lossy().into_owned();
|
|
assert!(!dataset_output_exists(
|
|
Some(&folder),
|
|
"../outside",
|
|
&[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-", folder: false }],
|
|
&vectorcraft_doc::Document::new(100.0, 80.0),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn batch_rejects_unknown_data_set_naming_but_ignores_it_for_folders() {
|
|
let mut s = with_data_sets(&["Alpha"]);
|
|
let e = s
|
|
.execute("action.batch", &json!({"source": "datasets", "datasetFileNaming": "other", "commands": [{"command": "object.unlockAll"}]}))
|
|
.unwrap_err();
|
|
let msg = e.to_string();
|
|
for value in ["fileNumber", "fileDataSetName", "dataSetName"] {
|
|
assert!(msg.contains(value), "{msg}");
|
|
}
|
|
assert!(
|
|
s.execute("action.batch", &json!({"source": "datasets", "datasetFileNaming": 7, "commands": [{"command": "object.unlockAll"}]})).is_err()
|
|
);
|
|
let src = tmp("folder-ignore-src");
|
|
write_svg(&src, "one.svg", "#f00");
|
|
let r = s.execute("action.batch", &json!({"source": "folder", "folder": src.to_string_lossy(), "datasetFileNaming": "other", "commands": [{"command": "object.unlockAll"}]})).unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
|
|
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name:
|
|
/// Batch writes them as `-` in a data set's name, on every platform.
|
|
#[test]
|
|
fn batch_saves_data_sets_named_with_characters_windows_does_not_allow() {
|
|
let dest = tmp("set-names-win");
|
|
let mut s = with_data_sets(&["a?b", "c*d<e>", "f\"g|h", "i\u{7}j"]);
|
|
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-e-.vectorcraft", "Untitled_f-g-h.vectorcraft", "Untitled_i-j.vectorcraft"],
|
|
"{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}");
|
|
}
|
|
|
|
/// A saved document with a text object bound to `Name` and two data sets, `A` and `B`.
|
|
fn saved_with_two_data_sets(path: &std::path::Path) -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
|
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap();
|
|
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
|
s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap();
|
|
s.execute("dataset.capture", &json!({"name": "A"})).unwrap();
|
|
s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap();
|
|
s.execute("dataset.capture", &json!({"name": "B"})).unwrap();
|
|
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn save_and_close_over_data_sets(s: &mut Session) -> Result<Value> {
|
|
s.execute(
|
|
"action.batch",
|
|
&json!({"source": "datasets", "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn batch_save_and_close_over_data_sets_saves_each_over_the_template_and_warns() {
|
|
let path = tmp("save-close-sets").join("template.vectorcraft");
|
|
let mut s = saved_with_two_data_sets(&path);
|
|
std::fs::write(&path, b"stale").unwrap();
|
|
let r = save_and_close_over_data_sets(&mut s).unwrap();
|
|
assert_eq!((r["count"].as_u64(), r["failed"].as_u64()), (Some(2), Some(0)), "{r}");
|
|
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
|
|
assert_ne!(std::fs::read(&path).unwrap(), b"stale", "the template's file is saved over");
|
|
assert_eq!(file_names(path.parent().unwrap()), ["template.vectorcraft"], "{r}");
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.doc.variables.active_dataset.as_deref(), Some("B"), "the last data set is applied");
|
|
assert_eq!(st.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the template stays open");
|
|
assert!(!st.is_dirty(), "the last data set is saved");
|
|
}
|
|
|
|
#[test]
|
|
fn batch_warns_when_a_recorded_save_writes_one_file_for_every_data_set() {
|
|
let path = tmp("recorded-save-sets").join("template.vectorcraft");
|
|
let mut s = saved_with_two_data_sets(&path);
|
|
let save = json!([{"command": "document.save", "params": {}}]);
|
|
let r = s.execute("action.batch", &json!({"source": "datasets", "commands": save})).unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
|
|
let dest = tmp("recorded-save-dest");
|
|
let p = json!({"source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(), "commands": save});
|
|
let r = s.execute("action.batch", &p).unwrap();
|
|
assert!(r.get("warning").is_none(), "Override Save points each save into the folder: {r}");
|
|
let unlock = json!({"source": "datasets", "commands": [{"command": "object.unlockAll", "params": {}}]});
|
|
let r = s.execute("action.batch", &unlock).unwrap();
|
|
assert!(r.get("warning").is_none(), "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn batch_save_and_close_after_a_redirected_save_writes_each_data_set_to_its_own_file() {
|
|
let save_close = |dest: &std::path::Path| {
|
|
json!({
|
|
"source": "datasets",
|
|
"destination": "saveAndClose",
|
|
"destinationFolder": dest.to_string_lossy(),
|
|
"commands": [{"command": "document.save", "params": {}}]
|
|
})
|
|
};
|
|
let path = tmp("redirected-save-close").join("template.vectorcraft");
|
|
let mut s = saved_with_two_data_sets(&path);
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let dest = tmp("redirected-save-close-dest");
|
|
let p = save_close(&dest);
|
|
let mut kept = p.clone();
|
|
kept["overrideSave"] = json!(false);
|
|
assert!(batch_overwrites_one_file(&s, &kept), "without Override Save the save stays on the template");
|
|
let r = s.execute("action.batch", &p).unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert!(r.get("warning").is_none(), "{r}");
|
|
assert_eq!(file_names(&dest), ["template_A.vectorcraft", "template_B.vectorcraft"], "{r}");
|
|
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template's file stays as it was");
|
|
// A document never saved gets its files from the redirected save too.
|
|
let mut unsaved = with_data_sets(&["a", "b"]);
|
|
let dest = tmp("redirected-save-close-unsaved");
|
|
let r = unsaved.execute("action.batch", &save_close(&dest)).unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert!(r.get("warning").is_none(), "{r}");
|
|
assert_eq!(file_names(&dest), ["Untitled_a.vectorcraft", "Untitled_b.vectorcraft"], "{r}");
|
|
// A template save writes a copy: Save and Close still saves over the document's own file.
|
|
let mut template = save_close(&dest);
|
|
template["commands"] = json!([{"command": "document.save", "params": {"format": "template"}}]);
|
|
assert!(batch_overwrites_one_file(&s, &template), "{template}");
|
|
let mut never = with_data_sets(&["a", "b"]);
|
|
let msg = never.execute("action.batch", &template).unwrap_err().to_string();
|
|
assert!(msg.ends_with("the document has never been saved"), "{msg}");
|
|
// A recorded Save As with its own path gives a never-saved document its file, the same one
|
|
// for every data set.
|
|
let one = tmp("save-as-then-close").join("one.vectorcraft");
|
|
let p = json!({
|
|
"source": "datasets",
|
|
"destination": "saveAndClose",
|
|
"commands": [{"command": "file.saveAs", "params": {"path": one.to_string_lossy()}}]
|
|
});
|
|
let r = never.execute("action.batch", &p).unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
|
|
assert!(one.exists(), "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn one_file_rule_covers_save_and_close_and_steps_left_in_place() {
|
|
let saved = saved_with_two_data_sets(&tmp("one-file-rule").join("template.vectorcraft"));
|
|
let unsaved = with_data_sets(&["a"]);
|
|
let with = |extra: Value| {
|
|
let mut p = json!({"source": "datasets", "commands": [{"command": "document.export", "params": {"path": "/x/a.png"}}]});
|
|
for (k, v) in extra.as_object().unwrap() {
|
|
p[k] = v.clone();
|
|
}
|
|
batch_overwrites_one_file(&saved, &p)
|
|
};
|
|
assert!(with(json!({})), "an export with its own path");
|
|
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out"})));
|
|
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false})));
|
|
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "overrideSave": false})));
|
|
assert!(!with(json!({"source": "folder"})), "a folder source opens a file per pass");
|
|
assert!(with(json!({"destination": "saveAndClose", "commands": [{"command": "object.unlockAll"}]})));
|
|
let saved_as = json!({"commands": [{"command": "file.saveAs", "params": {"path": "/x/a.svg"}}]});
|
|
assert!(with(saved_as.clone()));
|
|
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": saved_as["commands"]})));
|
|
assert!(!with(json!({"commands": [{"command": "file.saveAs", "params": {}}]})), "no path: the data comes back");
|
|
assert!(!with(json!({"commands": [{"command": "document.export", "params": {"format": "png"}}]})), "no path: the data comes back");
|
|
let copy = json!([{"command": "file.saveCopy", "params": {"path": "/x/a.vectorcraft"}}]);
|
|
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": copy})), "Override Save leaves copies alone");
|
|
let save = |format: Option<&str>| {
|
|
let params = format.map_or(json!({}), |f| json!({"format": f}));
|
|
json!({"source": "datasets", "commands": [{"command": "document.save", "params": params}]})
|
|
};
|
|
assert!(batch_overwrites_one_file(&saved, &save(None)), "the document's own file");
|
|
assert!(batch_overwrites_one_file(&saved, &save(Some("vectorcraft"))), "the document's own format");
|
|
assert!(!batch_overwrites_one_file(&unsaved, &save(None)), "a document never saved has no file to write");
|
|
assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back");
|
|
assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back");
|
|
let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]);
|
|
assert!(
|
|
!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})),
|
|
"Override Export gives each data set its own folder"
|
|
);
|
|
assert!(
|
|
with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false, "commands": screens})),
|
|
"without Override Export the step keeps its folder"
|
|
);
|
|
assert!(with(json!({"commands": screens})), "without a destination folder the step keeps its folder");
|
|
assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]})));
|
|
let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]);
|
|
assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back");
|
|
for id in ["document.exportPdf", "document.exportEps", "document.exportDxf", "document.exportForWeb", "document.exportForOffice"] {
|
|
let step = json!([{"command": id, "params": {"path": "/x/a.out"}}]);
|
|
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": step})), "{id} keeps its path");
|
|
assert!(!with(json!({"commands": [{"command": id, "params": {}}]})), "{id} without a path: the data comes back");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn batch_refuses_save_and_close_over_data_sets_of_a_converted_or_recovered_document() {
|
|
for converted in [true, false] {
|
|
let path = tmp(if converted { "converted-sets" } else { "recovered-sets" }).join("template.vectorcraft");
|
|
let mut s = saved_with_two_data_sets(&path);
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let (active, revision) = {
|
|
let st = s.doc_mut().unwrap();
|
|
if converted {
|
|
st.converted = true;
|
|
} else {
|
|
st.recovered = true;
|
|
}
|
|
(st.doc.variables.active_dataset.clone(), st.revision)
|
|
};
|
|
let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string();
|
|
assert!(msg.ends_with("save the document with Save As before Save and Close"), "{msg}");
|
|
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the file is not written");
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected");
|
|
assert_eq!(st.revision, revision, "no step runs");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn batch_refuses_save_and_close_over_data_sets_of_an_unsaved_document() {
|
|
let mut s = with_data_sets(&["a", "b"]);
|
|
let (active, revision) = {
|
|
let st = s.doc().unwrap();
|
|
(st.doc.variables.active_dataset.clone(), st.revision)
|
|
};
|
|
let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string();
|
|
assert!(msg.ends_with("the document has never been saved"), "{msg}");
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected");
|
|
assert_eq!(st.revision, revision, "no step runs");
|
|
assert!(st.path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn batch_save_and_close_saves_and_closes_each_source_file() {
|
|
let src = tmp("save-close-src");
|
|
let a = write_svg(&src, "a.svg", "#f00");
|
|
let before = std::fs::read(&a).unwrap();
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute(
|
|
"action.batch",
|
|
&json!({"folder": src.to_string_lossy(), "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["failed"], 0, "{r}");
|
|
assert_ne!(std::fs::read(&a).unwrap(), before, "the source file is saved over");
|
|
assert_eq!(file_names(&src), ["a.svg"], "{r}");
|
|
assert!(s.doc().is_err(), "the source file is closed");
|
|
}
|
|
|
|
#[test]
|
|
fn batch_refuses_nesting_and_empty_commands() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
|
assert!(s.execute("action.batch", &json!({"commands": []})).is_err());
|
|
assert!(
|
|
s.execute(
|
|
"action.batch",
|
|
&json!({
|
|
"folder": ".",
|
|
"commands": [{"command": "command.batch", "params": {"commands": []}}]
|
|
})
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|