Merge PR #7026: Automation: place and layer/data-set export by index keep the active document (#7025)

This commit is contained in:
aiongg
2026-10-21 05:04:01 +00:00
3 changed files with 131 additions and 83 deletions
+89 -83
View File
@@ -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<T>(&mut self, index: Option<usize>, body: impl FnOnce(&mut Self) -> Result<T, AutomationError>) -> Result<T, AutomationError> {
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<Value, AutomationError> {
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<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
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<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
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<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
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<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
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 }))
}
+37
View File
@@ -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();
+5
View File
@@ -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"}}' \