diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..ca28407356 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -653,6 +653,11 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { None => e, })?; remember(s, &p); + // A prepared export is written later, maybe on another thread: it names the folder as this + // request may see it. + if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) { + export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned())); + } if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); } @@ -663,10 +668,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { /// give: `folder` as the path the error is about names it by its own name /// ([`super::access::shown_path`]), and the rest of the message stays. fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { - let name = super::access::shown_path(folder); + leave_out_folder_named(e, folder, &super::access::shown_path(folder)) +} + +/// [`leave_out_folder_from_error`] with `folder` named `name`. +fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError { match e { - EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), - EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, + EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, name) }, e => e, } } @@ -699,9 +708,14 @@ fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), /// as the reply of an export to a ZIP names them. fn leave_out_unreadable_folder(r: &mut Value, folder: &str) { - if super::access::shown(folder) { - return; + if !super::access::shown(folder) { + leave_out_folder(r, folder); } +} + +/// The reply `r` of an export into `folder` with the ZIP's `path` left out and `files` named +/// relative to `folder` ([`leave_out_unreadable_folder`]). +fn leave_out_folder(r: &mut Value, folder: &str) { let Some(o) = r.as_object_mut() else { return }; o.remove("path"); let inside = format!("{folder}/"); @@ -1074,7 +1088,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { // Include Artboard Fill off: the encoders leave the background colors out. let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) }; let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect(); - let export = ScreenExport { cmd: C, o, rows, naming, items }; + let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None }; match &mut s.screens_hand_off { Some(slot) => { *slot = Some(export); @@ -1095,9 +1109,27 @@ pub struct ScreenExport { rows: Vec, naming: Naming, items: Vec, + /// The export folder and the name the request that prepared the export may see it by, when the + /// folder came from the `exportPreset` and is outside that request's read roots: the reply and + /// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does. + hidden: Option<(String, String)>, } impl Session { + /// The folder `document.exportForScreens` (`id`) with `params` writes into, the client's + /// `folder` or else the one its `exportPreset` keeps, with the name the request being served may + /// see it by when the reply leaves it out: a preset's folder outside the read roots, named by + /// its own name ([`super::access::shown_path`]). `assets.export` takes no preset, so for it the + /// folder is the client's. → `None` without a folder (the files come back in the reply) or for + /// an unknown preset, which the export itself refuses. + pub fn screen_export_folder(&self, id: &str, params: &Value) -> Option<(String, Option)> { + let given = params.get("folder").is_some(); + let p = if given || id == ASSETS { params.clone() } else { with_preset(self, params).ok()? }; + let folder = str_param(&p, "folder").filter(|f| !f.is_empty())?.to_string(); + let hidden = (!given && !super::access::shown(&folder)).then(|| super::access::shown_path(&folder).into_owned()); + Some((folder, hidden)) + } + /// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as /// a command (checked and journaled as any other; Export for Screens remembers its settings) /// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`] @@ -1123,10 +1155,19 @@ impl ScreenExport { /// return → the reply of `document.exportForScreens`. It reports one progress step per file /// ([`vectorcraft_doc::cancel::progress`]), and a cancel stops it before it encodes or writes /// another file. - pub fn write(self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + pub fn write(mut self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = self.cmd; + let hidden = self.hidden.take(); // A refusal names `assets.export` when that command prepared the export, as its reply does. - encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }) + let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }); + match hidden { + Some((folder, name)) => { + let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?; + leave_out_folder(&mut r, &folder); + Ok(r) + } + None => r, + } } } diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 0b529c87c6..1b3fe3cfec 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -735,6 +735,81 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ assert!(e.contains(&format!("root {root}: {name}")), "{e}"); } +/// A prepared export into the folder an export preset keeps, prepared for a client whose read roots +/// don't hold that folder and written later outside that client's scope (as on the background +/// thread): its reply leaves out the ZIP's path and names the files relative to the folder, and a +/// refused write names the folder by its name. A folder inside the read roots, and one the client +/// gives, stay whole. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("prepared-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap(); + let prepare = |s: &mut Session, roots: &Option>, p: Value| { + confine(roots.as_ref(), || s.prepare_screen_export("document.exportForScreens", &p)).unwrap() + }; + + let (r, files) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}"); + assert!(files.contains_key(&format!("{folder}/1x/Artboard-1.png")), "the files are written into the folder"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}))); + assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}"); + assert!(!r.to_string().contains(&folder), "{r}"); + // A refused write names the folder by its name. + let refused = prepare(&mut s, &write_only, json!({"exportPreset": "Kept"})); + let e = refused.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains("kept-out/1x/Artboard-1.png: access denied") && !e.contains(&folder), "{e}"); + // Inside the read roots, or given by the client: whole. + let (r, _) = write_prepared(prepare(&mut s, &both, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!([format!("{folder}/1x/Artboard-1.png")]), "{r}"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}))); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); + let given = prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder})); + let e = given.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains(&folder), "{e}"); +} + +/// [`Session::screen_export_folder`]: the client's folder, else the one the export preset keeps, +/// with its own name when the request's read roots don't hold it; none without a folder, for an +/// unknown preset or for `assets.export`, which takes no preset. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_export_folder_is_the_clients_else_the_presets() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("export-folder"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let ask = |id: &str, p: Value| confine(confined.as_ref(), || s.screen_export_folder(id, &p)); + + let screens = "document.exportForScreens"; + assert_eq!(ask(screens, json!({"exportPreset": "Kept"})), Some((folder.clone(), Some("kept-out".to_string())))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": "/x/y"})), Some(("/x/y".to_string(), None))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": ""})), None); + assert_eq!(ask(screens, json!({"exportPreset": "Nope"})), None); + assert_eq!(ask(screens, json!({})), None); + assert_eq!(ask("assets.export", json!({"exportPreset": "Kept"})), None); + assert_eq!(s.screen_export_folder(screens, &json!({"exportPreset": "Kept"})), Some((folder, None)), "unconfined: whole"); +} + /// The export settings the document keeps and the saved presets, read by a client whose read roots /// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and /// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 97e2b16ee7..7ae09dbdf6 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -341,6 +341,32 @@ fn edits_after_ok_do_not_reach_the_files() { let _ = std::fs::remove_dir_all(&dir); } +/// An export into the folder its export preset keeps, with no folder of its own, is written in the +/// background as one into a folder it names, and the status bar names that folder. +#[test] +fn an_export_into_a_presets_folder_writes_in_the_background() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + let dir = temp_folder("preset-folder"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + app.run("document.exportPresets.save", json!({"name": "Kept", "settings": settings})).unwrap(); + let p = json!({"exportPreset": "Kept"}); + background_export(&mut app, false); + let want = on_disk(&app.run("file.exportForScreens", p.clone()).unwrap()); + assert_eq!(want.len(), 1); + let status = app.ui.status.clone(); + assert_eq!(status, format!("Exported 1 file to {folder}")); + std::fs::remove_dir_all(&dir).unwrap(); + background_export(&mut app, true); + assert_eq!(app.run("file.exportForScreens", p).unwrap()["background"], true); + assert!(written(&files).is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!((files.lock().unwrap().clone(), app.ui.status.clone()), (want, status)); + let _ = std::fs::remove_dir_all(&dir); +} + /// Cancel stops the export before its next file; the file being written when it came stays. #[test] fn cancel_stops_the_export_before_its_next_file() { diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 03e0196267..85ef8ffa0c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -265,13 +265,19 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result Result { let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); - let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); + // The folder written into, and its name when the reply leaves it out. + let (folder, hidden) = if id == "assets.export3D" { + (params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(), None) + } else { + app.session.screen_export_folder(id, ¶ms).unwrap_or_default() + }; // Without a folder the files come back in the reply, which the caller waits for. let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty() && id != "assets.export3D"; @@ -279,7 +285,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> // Made at once, after the exports and saves started before it. background::wait_all(app); let r = app.run(id, params)?; - return report_exported(app, r, &folder, open_location); + return report_exported(app, r, &folder, hidden.as_deref(), open_location); } let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; let label = format!("Exporting {}", fileio::file_name(&folder)); @@ -299,14 +305,15 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> return Ok(json!({ "cancelled": true })); } let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; - report_exported(app, r, &folder, open_location) + report_exported(app, r, &folder, hidden.as_deref(), open_location) }) } /// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count -/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, -/// the file manager shows the first file written. -fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { +/// written into `folder` (named `hidden` when the reply leaves it out), else the ZIP or the files +/// downloaded (the web); with `open_location`, the file manager shows the first file written, or +/// the folder for a ZIP whose path the reply leaves out. +fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, hidden: Option<&str>, open_location: bool) -> Result { let count = r["files"].as_array().map_or(0, Vec::len); let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); // Files that came back as bytes: (name, base64). @@ -317,15 +324,22 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati } }; if returned.is_empty() { - let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); - let mut status = crate::i18n::msg_n(count as u64, "Exported {n} file to {folder}", "Exported {n} files to {folder}", &[("folder", folder)]); + let shown = hidden.unwrap_or(folder); + let first = match r["path"].as_str().or_else(|| r["files"][0].as_str()) { + // The reply names the files relative to a folder it leaves out, and a ZIP's files inside it. + Some(_) if hidden.is_some() && r.get("bytes").is_some() => Some(folder.to_string()), + Some(name) if hidden.is_some() => Some(format!("{folder}/{name}")), + first => first.map(str::to_string), + }; + let mut status = crate::i18n::msg_n(count as u64, "Exported {n} file to {folder}", "Exported {n} files to {folder}", &[("folder", shown)]); if warned { - status = saved_status(r["path"].as_str().unwrap_or(folder), &r); + status = saved_status(r["path"].as_str().unwrap_or(shown), &r); } if open_location && let Some(first) = first - && let Err(e) = reveal_path(app, first) + && let Err(e) = reveal_path(app, &first) { + let e = if hidden.is_some() { e.replace(folder, shown) } else { e }; status = format!("{status} ({e})"); } show_with_notes(app, status, notes_of(&r)); diff --git a/docs/mcp.md b/docs/mcp.md index b487fa0eff..36fb2c22ca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -131,7 +131,9 @@ the suite) keep the agent to the folders you give it: written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error - for that export names the folder by its name alone. + for that export names the folder by its name alone. With Background Export on, an export into the `folder` of + its `exportPreset` runs in the background as one into a `folder` the agent gives, and its replies, errors and + status bar text name that folder the same way. `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as a file) leave out a kept `folder` outside the read roots. `file.package` names the document's file and the linked files outside the read roots by their file names in `warnings`, in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b14c817d32..21fc933c8a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1569,6 +1569,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. + - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default). - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`.