mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
G6: action.batch for File › Automate › Batch
Play an action on every file in a folder or every data set, with destination and Override Open/Save/Export. Actions panel and File › Automate › Batch… open the dialog; new strings in pt-br. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
b3e484702f
commit
0fe70314b8
11 files changed
+651
-2
No files matched your search
@@ -0,0 +1,450 @@
|
||||
//! `action.batch`: play an action's commands on every file in a folder, or on every data set
|
||||
//! of the open document (File › Automate › Batch / the Actions panel).
|
||||
//!
|
||||
//! 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, json};
|
||||
|
||||
use super::super::*;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// 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, 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(),
|
||||
};
|
||||
match source {
|
||||
"folder" => batch_folder(s, C, p, override_open, &run),
|
||||
"datasets" => batch_datasets(s, C, &run),
|
||||
_ => Err(bad(C, "`source` must be `folder` or `datasets`")),
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
let steps = filter_steps(run.steps, override_open, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for path in &files {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = super::extension(path);
|
||||
match process_one_file(s, &run, path, stem, &ext) {
|
||||
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 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 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) {
|
||||
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}))
|
||||
}
|
||||
|
||||
/// 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, stem: &str, ext: &str) -> Result<Value> {
|
||||
s.execute("document.open", &json!({"path": path}))?;
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext);
|
||||
let 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, doc_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 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 = std::path::Path::new(folder).join(format!("{stem}.{out_ext}"));
|
||||
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(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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"));
|
||||
if !rewrite {
|
||||
return c;
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut params = params.as_object().cloned().unwrap_or_default();
|
||||
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
|
||||
let ext = if ext.is_empty() { default_ext } else { ext };
|
||||
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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, OPEN_EXTS, extension};
|
||||
let ext = extension(path);
|
||||
if ext.is_empty() {
|
||||
return false;
|
||||
}
|
||||
OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext))
|
||||
}
|
||||
|
||||
#[cfg(all(test, 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");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
|
||||
#[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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
|
||||
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
|
||||
//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and
|
||||
//! `action.batch` (File › Automate › Batch / the Actions panel).
|
||||
//!
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
|
||||
//! - `action_batch`: play an action on every file in a folder or every data set.
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
|
||||
@@ -19,6 +21,7 @@
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
|
||||
mod action_batch;
|
||||
mod affinity;
|
||||
mod batch;
|
||||
pub mod dxf;
|
||||
@@ -146,6 +149,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
batch::batch
|
||||
),
|
||||
cmd!(
|
||||
"action.batch",
|
||||
"Batch…",
|
||||
["File", "Automate"],
|
||||
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}",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
cmd!(
|
||||
query "document.formats",
|
||||
"File Formats",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Actions panel › Batch… / File › Automate › Batch: play the selected action on every file in a
|
||||
//! folder, or on every data set of the open document (`action.batch`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "actionBatch";
|
||||
const LABEL: f32 = 130.0;
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Batch").into(), body, confirm, ok: Some("OK"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
|
||||
|
||||
/// Open the Batch dialog for the selected action (`set`, `action` indexes into `action_sets`).
|
||||
pub fn open(app: &mut VectorcraftApp, set: usize, action: usize) -> Result<Value, String> {
|
||||
let a = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).ok_or("no such action")?;
|
||||
if a.steps.is_empty() {
|
||||
return Err("the action has no steps".into());
|
||||
}
|
||||
let name = a.name.clone();
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
KIND,
|
||||
json!({
|
||||
"set": set,
|
||||
"action": action,
|
||||
"actionName": name,
|
||||
"source": "folder",
|
||||
"folder": "",
|
||||
"includeSubdirs": false,
|
||||
"destination": "folder",
|
||||
"destinationFolder": "",
|
||||
"overrideOpen": true,
|
||||
"overrideSave": true,
|
||||
"overrideExport": true,
|
||||
"stopOnError": true,
|
||||
}),
|
||||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(d.str("actionName")).color(t.text_strong));
|
||||
ui.add_space(8.0);
|
||||
|
||||
form::choice(ui, d, "source", "Play:", (LABEL, 200.0), &[("folder", "Folder"), ("datasets", "Data Sets")]);
|
||||
let source = d.str("source");
|
||||
if source == "folder" {
|
||||
widgets::label_row(ui, tl!("Source Folder:"), LABEL, |ui| {
|
||||
form::text(ui, d, "folder", 200.0);
|
||||
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
|
||||
crate::picks::folder_field(app, d, "folder");
|
||||
}
|
||||
});
|
||||
form::check(ui, d, "includeSubdirs", "Include All Subdirectories");
|
||||
} else {
|
||||
ui.label(egui::RichText::new(tl!("Uses every data set of the open document.")).size(11.5).color(t.text_dim));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
|
||||
form::choice(ui, d, "destination", "Destination:", (LABEL, 200.0), &[("none", "None"), ("saveAndClose", "Save and Close"), ("folder", "Folder")]);
|
||||
if d.str("destination") == "folder" {
|
||||
widgets::label_row(ui, tl!("Destination Folder:"), LABEL, |ui| {
|
||||
form::text(ui, d, "destinationFolder", 200.0);
|
||||
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
|
||||
crate::picks::folder_field(app, d, "destinationFolder");
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
widgets::subheader(ui, tl!("Overrides"));
|
||||
form::check(ui, d, "overrideOpen", "Override Action \"Open\" Commands");
|
||||
form::check(ui, d, "overrideSave", "Override Action \"Save\" Commands");
|
||||
form::check(ui, d, "overrideExport", "Override Action \"Export\" Commands");
|
||||
form::check(ui, d, "stopOnError", "Stop on Errors");
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let a = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).ok_or("no such action")?.clone();
|
||||
let commands: Vec<Value> = a.steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
|
||||
let source = d.str("source");
|
||||
if source == "folder" && d.str("folder").trim().is_empty() {
|
||||
return Err("choose a source folder".into());
|
||||
}
|
||||
let destination = d.str("destination");
|
||||
if destination == "folder" && d.str("destinationFolder").trim().is_empty() {
|
||||
return Err("choose a destination folder".into());
|
||||
}
|
||||
if source == "datasets" && app.session.active().is_none() {
|
||||
return Err("open a document with data sets".into());
|
||||
}
|
||||
let mut params = json!({
|
||||
"commands": commands,
|
||||
"label": a.name,
|
||||
"source": source,
|
||||
"destination": destination,
|
||||
"overrideOpen": d.bool("overrideOpen"),
|
||||
"overrideSave": d.bool("overrideSave"),
|
||||
"overrideExport": d.bool("overrideExport"),
|
||||
"stopOnError": d.bool("stopOnError"),
|
||||
"includeSubdirs": d.bool("includeSubdirs"),
|
||||
});
|
||||
if source == "folder" {
|
||||
params["folder"] = json!(d.str("folder"));
|
||||
}
|
||||
if destination == "folder" {
|
||||
params["destinationFolder"] = json!(d.str("destinationFolder"));
|
||||
}
|
||||
let r = app.run("action.batch", params)?;
|
||||
app.ui.dialog = None;
|
||||
let count = r["count"].as_u64().unwrap_or(0);
|
||||
let failed = r["failed"].as_u64().unwrap_or(0);
|
||||
if failed > 0 {
|
||||
app.status(format!("Batch: {count} processed, {failed} failed"));
|
||||
} else {
|
||||
app.status(format!("Batch: {count} processed"));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! Shortcuts, Workspaces, Find Font) draw their own window.
|
||||
|
||||
mod about;
|
||||
pub mod action_batch;
|
||||
pub mod artboard_options;
|
||||
pub mod blend_options;
|
||||
mod brush_art_options;
|
||||
@@ -283,6 +284,7 @@ registry! {
|
||||
DxfOptions: [dxf_options::KIND] => dxf_options::SPEC,
|
||||
PlacementOptions: [placement_options::KIND] => placement_options::SPEC,
|
||||
Package: [package::KIND] => package::SPEC,
|
||||
ActionBatch: [action_batch::KIND] => action_batch::SPEC,
|
||||
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
|
||||
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
|
||||
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
|
||||
|
||||
@@ -3802,6 +3802,19 @@ axis Both Ambos
|
||||
Softness Suavidade
|
||||
Ambient Light Luz ambiente
|
||||
Light behind the object Luz atrás do objeto
|
||||
Automate Automatizar
|
||||
Batch… Lote…
|
||||
Source Folder: Pasta de origem:
|
||||
Destination Folder: Pasta de destino:
|
||||
Include All Subdirectories Incluir todos os subdiretórios
|
||||
Uses every data set of the open document. Usa todos os conjuntos de dados do documento aberto.
|
||||
Overrides Substituições
|
||||
Override Action "Open" Commands Substituir comandos "Abrir" da ação
|
||||
Override Action "Save" Commands Substituir comandos "Salvar" da ação
|
||||
Override Action "Export" Commands Substituir comandos "Exportar" da ação
|
||||
Stop on Errors Parar em erros
|
||||
Save and Close Salvar e fechar
|
||||
Play: Reproduzir:
|
||||
Make Graph Dynamic Tornar gráfico dinâmico
|
||||
Registration: Registro:
|
||||
# Dimension tool (linked dimension annotations)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -737,6 +737,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"Cmd+Alt+Shift+P",
|
||||
"{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage",
|
||||
),
|
||||
(
|
||||
"ui.batchDialog",
|
||||
"Batch…",
|
||||
"",
|
||||
"{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides",
|
||||
),
|
||||
("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"),
|
||||
(
|
||||
"docInfo.save",
|
||||
@@ -1495,6 +1501,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned())
|
||||
}
|
||||
"ui.packageDialog" => crate::dialogs::package::open(app),
|
||||
"ui.batchDialog" => {
|
||||
let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
crate::dialogs::action_batch::open(app, set, action)
|
||||
}
|
||||
"ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p),
|
||||
"file.showPackage" => crate::dialogs::package::show_package(app, p),
|
||||
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
|
||||
@@ -2107,6 +2118,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
"ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app),
|
||||
"ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
|
||||
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
|
||||
"ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()),
|
||||
"ui.epsOptionsDialog" => app.session.active().is_some(),
|
||||
"file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(),
|
||||
"file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
@@ -2195,6 +2207,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Export Selection…", "file.exportSelection"),
|
||||
Sep,
|
||||
c("Package…", "ui.packageDialog"),
|
||||
sub("Automate", vec![c("Batch…", "ui.batchDialog"), todos("Scripts…", "")]),
|
||||
sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]),
|
||||
Sep,
|
||||
c("Document Setup…", "file.documentSetup"),
|
||||
|
||||
@@ -51,6 +51,24 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result<Value, S
|
||||
app.run("command.batch", json!({"label": a.name, "commands": commands}))
|
||||
}
|
||||
|
||||
/// The panel's (≡) menu.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
let selected: Option<(usize, usize)> = pstate(ui.ctx(), "actions-selected");
|
||||
let has = selected.and_then(|(s, a)| app.ui.action_sets.get(s).and_then(|set| set.actions.get(a))).is_some();
|
||||
if widgets::menu_item(ui, tl!("Batch…"), has, false)
|
||||
&& let Some((s, a)) = selected
|
||||
&& let Err(e) = crate::dialogs::action_batch::open(app, s, a)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Play Current Selection"), has, false)
|
||||
&& let Some((s, a)) = selected
|
||||
&& let Err(e) = play(app, s, a)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The selected action as (set, action). Read as the type it is stored as: egui keeps a
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
|
||||
asset_export::ID => asset_export::menu(app, ui),
|
||||
css_properties::ID => css_properties::menu(app, ui),
|
||||
variables::ID => variables::menu(app, ui),
|
||||
"actions" => actions::menu(app, ui),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
|
||||
@@ -265,6 +265,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
## G6 Automation and scripting
|
||||
|
||||
- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), image and graph
|
||||
variable kinds (dataset CSV import/export and Batch are open as PRs). Actions, Variables (text and
|
||||
visibility) and full agent control over MCP are done.
|
||||
- **Estimate:** 6–12 h.
|
||||
- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image variable kind and dataset import/export (graph variables land separately). Actions, Variables (text,
|
||||
visibility, graph) and full agent control over MCP are done.
|
||||
- **Estimate:** 8–14 h.
|
||||
|
||||
+10
-1
@@ -986,6 +986,12 @@ already walks Image Trace. Short agent entry points:
|
||||
`symbol.replace`, `symbol.edit`, `symbol.duplicate`, `symbol.selectInstances {name}`.
|
||||
- Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`,
|
||||
`brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.freehand`, `object.expandBrush {ids?}`.
|
||||
- Variables: `variable.list`, `variable.define {name, kind: text|visibility}`, `variable.bind {variable, ids?}`,
|
||||
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.unbind`, `dataset.new` / `dataset.set` /
|
||||
`dataset.select`.
|
||||
- Batch: `action.batch {commands, source?: folder|datasets, folder?, destination?: none|saveAndClose|folder,
|
||||
destinationFolder?, overrideOpen?, overrideSave?, overrideExport?, includeSubdirs?, stopOnError?}` plays an
|
||||
action on every file in a folder or every data set (File › Automate › Batch…).
|
||||
- Variables: `variable.list`, `variable.define {name, kind: text|visibility|graph}`, `variable.bind {variable, ids?}`,
|
||||
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeGraphDynamic`, `variable.unbind`,
|
||||
`dataset.new` / `dataset.set` / `dataset.select` (graph values are CSV as `graph.setData`).
|
||||
@@ -1795,7 +1801,10 @@ for `edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack`, and to each
|
||||
none, when the step runs. When the document a step runs in has no artboard at that index (for example, after an
|
||||
earlier step deleted the active artboard when it was the last one), the step gets that document's last artboard. In
|
||||
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`. An action recorded in the Actions panel leaves the artboard out,
|
||||
Actions panel plays an action as a `command.batch`. File › Automate › Batch… and the Actions panel's Batch… run
|
||||
`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
|
||||
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
|
||||
tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. Choosing the Artboard
|
||||
|
||||
@@ -89,6 +89,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated |
|
||||
| Automation (Actions, Variables, scripting, batch) | 3 | 62% | a scripting surface (Illustrator: JavaScript/ExtendScript), image and graph variables (Batch and dataset CSV open as PRs) | 6–12 | estimated |
|
||||
| Automation (Actions, Variables, scripting, batch) | 3 | 68% | a scripting surface (Illustrator: JavaScript/ExtendScript); Batch, image variables and dataset CSV open as PRs; graph variables done | 5–10 | estimated |
|
||||
| Automation (Actions, Variables, scripting, batch) | 3 | 65% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables | 4–8 | estimated |
|
||||
| Automation (Actions, Variables, scripting, batch) | 3 | 62% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables, dataset import/export | 6–12 | estimated |
|
||||
@@ -338,6 +339,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 68% | scripting surface; image/graph variable kinds (Variables: text/visibility done; Batch `action.batch` and dataset CSV open as PRs) | 5–9 |
|
||||
| Automation | 3 | 70% | scripting surface, batch, image variables and dataset CSV (open as PRs); Variables: text, visibility and graph (CSV) bound to objects, dataset rows applied in one undo step | 4–8 |
|
||||
| Automation | 3 | 72% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, CSV import/export, panel and Window › Variables with dialogs; graph kinds open) | 4–7 |
|
||||
| Automation | 3 | 68% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; graph kinds and dataset import/export open) | 5–9 |
|
||||
|
||||
Reference in new issue
Block a user