mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Merge PR #7026: Automation: place and layer/data-set export by index keep the active document (#7025)
This commit is contained in:
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"}}' \
|
||||
|
||||
Reference in New Issue
Block a user