diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index d6b3c17102..cad8d233fa 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -72,6 +72,22 @@ impl Headless { } } + /// Run `body` with document `index` active (the active one when `None`), then make the + /// previously active document active again, on success and on error alike: an `index` + /// targets one call and must not retarget the next automation command (#7025). + fn on_doc(&mut self, index: Option, body: impl FnOnce(&mut Self) -> Result) -> Result { + let i = self.doc_index(index)?; + let previously_active = self.session.active_index(); + if previously_active != Some(i) && !self.session.set_active(i) { + return Err(AutomationError::BadRequest(format!("no document at index {i}"))); + } + let result = body(self); + if let Some(active) = previously_active.filter(|&active| self.session.active_index() != Some(active)) { + self.session.set_active(active); + } + result + } + /// Open a file and make it the active document. pub fn open(&mut self, path: &Path) -> Result { let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?; @@ -113,27 +129,25 @@ impl Headless { /// automation (#2810). `path` resolves beneath the read root the way `open` resolves one, /// and `params` carries the command's own options (`scale`, `fit`, `center`). pub fn place(&mut self, index: Option, path: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("path".into(), json!(path)); - // Where ambient paths are allowed the command reads the file itself, as everywhere else. - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - return self.command_run("file.placeEmbedded", Value::Object(opts)); - } - let bytes = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace.read(path)?, - _ => { - return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())); + self.on_doc(index, |this| { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("path".into(), json!(path)); + // Where ambient paths are allowed the command reads the file itself, as everywhere else. + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + return this.command_run("file.placeEmbedded", Value::Object(opts)); } - }; - let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path); - self.sync_jobs(); - // The bytes are already in memory and the path resolved beneath the root capability, so - // placing them needs no filesystem check of its own (`place_bytes` is the command body). - Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?) + let bytes = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace.read(path)?, + _ => { + return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())); + } + }; + let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path); + this.sync_jobs(); + // The bytes are already in memory and the path resolved beneath the root capability, so + // placing them needs no filesystem check of its own (`place_bytes` is the command body). + Ok(file_cmds::place_bytes(&mut this.session, name, bytes, None, &Value::Object(opts))?) + }) } /// Export each exported layer of a document as its own file beneath the write root: the @@ -143,25 +157,23 @@ impl Headless { /// already exists, where the ambient command would, and it creates the directory before the /// export runs, so a refused export can leave an empty directory behind — never a file. pub fn export_layers(&mut self, index: Option, dir: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("dir".into(), json!(dir)); - return self.command_run("file.export.layersToFiles", Value::Object(opts)); - } - self.sync_jobs(); - let workspace = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace, - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), - }; - let mut outputs = workspace.export_sequence(dir)?; - let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| { - outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) - }); - exported(dir, written, &mut outputs) + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("dir".into(), json!(dir)); + return this.command_run("file.export.layersToFiles", Value::Object(opts)); + } + this.sync_jobs(); + let workspace = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace, + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), + }; + let mut outputs = workspace.export_sequence(dir)?; + let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| { + outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) + }); + exported(dir, written, &mut outputs) + }) } /// Export each data set of a document as its own flattened file beneath the write root: the @@ -169,25 +181,23 @@ impl Headless { /// batch is planned first and colliding names are refused before any file is written; the /// directory itself is created before that, so a refused batch can leave it empty behind. pub fn export_data_sets(&mut self, index: Option, dir: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("dir".into(), json!(dir)); - return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts)); - } - self.sync_jobs(); - let workspace = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace, - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), - }; - let mut outputs = workspace.export_sequence(dir)?; - let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| { - outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) - }); - exported(dir, written, &mut outputs) + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("dir".into(), json!(dir)); + return this.command_run("file.export.dataSetsAsFiles", Value::Object(opts)); + } + this.sync_jobs(); + let workspace = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace, + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), + }; + let mut outputs = workspace.export_sequence(dir)?; + let written = photocraft_engine::export_data_sets_with(&this.session, params, |name, bytes| { + outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) + }); + exported(dir, written, &mut outputs) + }) } /// Import a data set CSV from the read root: the capability-scoped @@ -195,24 +205,22 @@ impl Headless { /// in automation (#3216). The file is read beneath the root the way `open` reads one and /// handed to the command's own parser over the text it already has. pub fn import_data_sets(&mut self, index: Option, path: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("path".into(), json!(path)); - return self.command_run("file.import.variableDataSets", Value::Object(opts)); - } - let text = match &self.filesystem { - Filesystem::Workspace(workspace) => { - let bytes = workspace.read(path)?; - String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))? + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("path".into(), json!(path)); + return this.command_run("file.import.variableDataSets", Value::Object(opts)); } - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), - }; - self.sync_jobs(); - Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?) + let text = match &this.filesystem { + Filesystem::Workspace(workspace) => { + let bytes = workspace.read(path)?; + String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))? + } + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), + }; + this.sync_jobs(); + Ok(photocraft_engine::import_data_sets_text(&mut this.session, &text, params)?) + }) } /// Save (`.pcraft` or any export format by extension). With no path, @@ -266,14 +274,12 @@ impl Headless { format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase())); if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) { // Saving by index must not retarget the next automation command. - let previously_active = self.session.active_index(); - self.session.set_active(i); - if let Some(st) = self.session.active_mut() { - st.saved_to(target.to_string_lossy().into_owned()); - } - if let Some(active) = previously_active { - self.session.set_active(active); - } + self.on_doc(Some(i), |this| { + if let Some(st) = this.session.active_mut() { + st.saved_to(target.to_string_lossy().into_owned()); + } + Ok(()) + })?; } Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings })) } diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 9ece8bb4f4..83d91238f4 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -786,6 +786,43 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn indexed_place_and_data_set_methods_keep_the_active_document() { + let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let img = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![42; 4 * 4 * 3]).unwrap(); + std::fs::write(root.join("logo.png"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap(); + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "first"})).unwrap(); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "second"})).unwrap(); + let layer = h.handle("doc.inspect", json!({})).unwrap()["layers"][0]["id"].as_u64().expect("the layer id"); + h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}})) + .unwrap(); + h.handle("doc.select", json!({"index": 0})).unwrap(); + + let steps = [ + ("doc.place", json!({"path": "logo.png", "index": 1}), true), + ("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true), + ("doc.exportLayers", json!({"dir": "layers", "index": 1}), true), + ("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true), + // A failing call restores the active document too. + ("doc.importDataSets", json!({"path": "missing.csv", "index": 1}), false), + ]; + for (method, params, succeeds) in steps { + let result = h.handle(method, params.clone()); + assert_eq!(result.is_ok(), succeeds, "{method} {params}: {result:?}"); + assert_eq!(h.session.active_index(), Some(0), "{method} {params} must not select document 1"); + } + assert_eq!(h.session.documents()[0].doc.layer_count(), 1, "nothing was placed into the active document"); + assert_eq!(h.session.documents()[1].doc.layer_count(), 2, "the placed layer is in document 1"); + assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2, "document 1's data sets were exported"); + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn trusted_local_render_keeps_full_size_behavior() { let mut h = Headless::trusted_local(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 918a9f811b..ca635ab1a7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -385,6 +385,11 @@ no MCP framing, no app start-up per command. Configure its file access with the | `batch` | `{steps: [{command, params?, wait?} \| {method, params?}], stopOnError? (true)}` → `{completed, failed, results}` (a step with `wait: false` starts a long command as a background job, like `engine.execute`) | | `methods` | the list above | +An `index` targets that one call: `doc.place`, `doc.exportLayers`, `doc.exportDataSets`, +`doc.importDataSets`, `doc.save`, `doc.inspect` and `doc.render` act on the document at `index` +without making it the active one, so the next call without `index` still runs on the document +that was active before (`doc.select` is the method that changes it). + ```sh printf '%s\n' \ '{"id":1,"method":"doc.open","params":{"path":"in.jpg"}}' \