diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 718ceb710..783587878 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -246,7 +246,8 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result Ok(()) } -/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on. +/// 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`. #[cfg(not(target_arch = "wasm32"))] fn rewrite_paths( steps: &[Value], @@ -271,8 +272,10 @@ fn rewrite_paths( } 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 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(), + }; 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() { @@ -283,6 +286,21 @@ fn rewrite_paths( .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> { @@ -410,6 +428,70 @@ mod tests { 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 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"); diff --git a/docs/mcp.md b/docs/mcp.md index b89c100af..272e175e7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1141,7 +1141,11 @@ already walks Image Trace. Short agent entry points: line the row starts on. - 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 (the Actions panel menu's Batch…). + action on every file in a folder or every data set (the Actions panel menu's Batch…). Override Save and Override + Export end the file name of a step with `format` in that `format`, in lower case and without a leading dot, when it + is one of the format's extensions (`.svg` and `SVG` give `.svg`, `.JPEG` gives `.jpeg`), and otherwise in the + format's first extension (`png8` gives `.png`, `template` gives `.vctemplate`). A step without `format` keeps the + source file's extension (`.vectorcraft` over data sets). - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index acf95a0f2..38bc1db9a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -427,6 +427,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. + - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\` and `:` in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.