diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 020b483f3..a134eb1c5 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}", + "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec { "Link Info", [], None, - "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size", + "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size; under automation read roots, a link whose file is outside the roots has no link, path or location, and fileSize and modified come only from the file", has_doc, info ), @@ -882,17 +882,35 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio } /// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in -/// the layers), with `refused`: why the path isn't followed, when it isn't. +/// the layers), with `refused`: why the path isn't followed, when it isn't. `path` is left out +/// where [`path_shown`] says so. fn rows<'a>(gs: impl Iterator, doc_path: Option<&str>) -> Vec { gs.filter(|g| !g.ids.is_empty()) .map(|g| { - let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) }); + let mut row = json!({ "name": g.link.name(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); note_refused(&mut row, &g.link, doc_path); row }) .collect() } +/// May a reply to the caller asking now carry the link path `path` (of the document saved at +/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a +/// relative path taken from the document's folder) lies inside them. +fn path_shown(path: &str, doc_path: Option<&str>) -> bool { + crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) +} + +/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. +fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { + if path_shown(path, doc_path) { + row[key] = json!(path); + } else if let Some(o) = row.as_object_mut() { + o.remove(key); + } +} + /// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]). fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) { if let Some(r) = refused(link, doc_path) { @@ -1188,10 +1206,12 @@ fn check(s: &mut Session, p: &Value) -> Result { let (status, found) = probe(g, LinkBase::of(st)); missing += usize::from(status == Status::Missing); modified += usize::from(status == Status::Modified); - let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) }); - note_refused(&mut row, &g.link, LinkBase::of(st).doc); + let doc_path = LinkBase::of(st).doc; + let mut row = json!({ "name": g.link.name(), "status": status.as_str(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); + note_refused(&mut row, &g.link, doc_path); if let Some(f) = found.filter(|f| f.path != g.link.path) { - row["found"] = json!(f.path); + note_path(&mut row, "found", &f.path, doc_path); } if stale(&st.doc, g) { row["preview"] = json!(true); @@ -1663,14 +1683,15 @@ fn list(s: &mut Session, p: &Value) -> Result { o.insert("linked".into(), json!(link.is_some())); o.insert("status".into(), json!(status)); } + let doc_path = LinkBase::of(st).doc; if let Some(l) = link { - row["path"] = json!(l.path); + note_path(&mut row, "path", &l.path, doc_path); if let Some(page) = l.page { row["page"] = json!(page); } } if let Some(f) = found { - row["found"] = json!(f); + note_path(&mut row, "found", f, doc_path); } if preview { row["preview"] = json!(true); @@ -1811,13 +1832,17 @@ fn placement_json(o: PlacementOptions) -> Value { /// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { + // Whether the path is shown is judged for the caller asking, before the opener's roots apply. + let shown = path_shown(&l.path, LinkBase::of(st).doc); // A confined caller reads within its own roots; anyone else within what the caller that // opened the document may read (never wider than the caller asking now). let roots = crate::file_access::current().or_else(|| st.opened_by.clone()); - crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out)); + crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, shown, out)); } -fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { +/// [`file_fields`]; without `shown`, the link's path (`path`, `link`) and folder (`location`) +/// and the size and date the document keeps of its file are left out. +fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, shown: bool, out: &mut Value) { let base = LinkBase::of(st); let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0); let doc_path = base.doc; @@ -1825,13 +1850,20 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va let stamp = path.as_deref().and_then(file_stamp); out["status"] = json!(status.as_str()); note_refused(out, l, doc_path); - out["path"] = json!(l.path); + if shown { + out["path"] = json!(l.path); + out["location"] = json!(l.folder()); + } else if let Some(o) = out.as_object_mut() { + for k in ["path", "link", "location"] { + o.remove(k); + } + } out["fileName"] = json!(l.name()); - out["location"] = json!(l.folder()); + let kept = |v: Option| v.filter(|_| shown); for (k, v) in [ ("page", l.page.map(u64::from)), - ("fileSize", stamp.map(|s| s.0).or(l.size)), - ("modified", stamp.and_then(|s| s.1).or(l.modified)), + ("fileSize", stamp.map(|s| s.0).or(kept(l.size))), + ("modified", stamp.and_then(|s| s.1).or(kept(l.modified))), ("created", path.as_deref().and_then(file_created)), ] { if let Some(v) = v { diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index 56784b69d..f029eccd3 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -296,6 +296,59 @@ fn link_info_stays_within_the_asking_callers_roots() { assert_ne!(i["status"], "ok", "{i}"); } +/// A confined caller's `links.list`, `links.check` and `links.info` replies, and its +/// `document.open` reply, leave out the path and folder of a linked file outside its read roots +/// and keep the row with the file's name. A file found by name inside the roots shows its path. +#[test] +fn link_paths_outside_the_read_roots_are_left_out() { + let away = Folder::new("lrr-away"); + let home = Folder::new("lrr-home"); + let pic = away.file("photo.png"); + let bytes = png(60, 30, RED); + write(&pic, &bytes); + let mut s = session(); + let id = place(&mut s, &pic); + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str, p: Value| crate::file_access::confine(Some(&roots), || run(s, cmd, p)); + let hidden = |r: &Value| assert!(!r.to_string().contains("lrr-away"), "{r}"); + let list = ask(&mut s, "links.list", json!({})); + hidden(&list); + let row = &list["links"][0]; + assert_eq!((row["id"].as_u64(), row["name"].as_str(), row["status"].as_str()), (Some(id.0), Some("photo.png"), Some("missing")), "{list}"); + let check = ask(&mut s, "links.check", json!({})); + hidden(&check); + assert_eq!((check["links"][0]["name"].as_str(), check["missing"].as_u64()), (Some("photo.png"), Some(1)), "{check}"); + let info = ask(&mut s, "links.info", json!({"id": id.0})); + hidden(&info); + assert_eq!( + (info["fileName"].as_str(), info["status"].as_str(), info["linked"].as_bool()), + (Some("photo.png"), Some("missing"), Some(true)), + "{info}" + ); + for k in ["path", "link", "location", "fileSize", "modified", "created"] { + assert!(info.get(k).is_none(), "{k}: {info}"); + } + // Unconfined, the same replies carry the paths. + assert_eq!(run(&mut s, "links.list", json!({}))["links"][0]["path"], json!(pic)); + let i = run(&mut s, "links.info", json!({"id": id.0})); + assert_eq!((&i["path"], &i["link"], i["fileSize"].as_u64()), (&json!(pic), &json!(pic), Some(bytes.len() as u64)), "{i}"); + // Saved inside the roots next to a copy of the file: the copy is found by name and shown. + save(&mut s, &home.file("poster.vectorcraft")); + let copy = home.file("photo.png"); + write(©, &bytes); + let check = ask(&mut s, "links.check", json!({})); + hidden(&check); + assert_eq!((check["links"][0]["status"].as_str(), &check["links"][0]["found"]), (Some("ok"), &json!(copy)), "{check}"); + let list = ask(&mut s, "links.list", json!({})); + hidden(&list); + assert_eq!(list["links"][0]["found"], json!(copy), "{list}"); + // Opened again without the copy: the missing link's row has no path. + std::fs::remove_file(©).unwrap(); + let opened = ask(&mut Session::new(), "document.open", json!({"path": home.file("poster.vectorcraft")})); + hidden(&opened); + assert_eq!(opened["missingLinks"][0]["name"], "photo.png", "{opened}"); +} + /// A 600×300 pt image at the centre of the artboard, its placement options set to `options`, /// relinked to a 300×100 px file → the image's id and session. fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 5871b85a3..e7d3c7cd7 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1060,6 +1060,30 @@ fn link_info_gives_the_artboards_size_not_pixels() { ); } +/// A confined caller's Link Info of a placed document whose file is outside its read roots has no +/// `link`, `path` or `location`, and keeps the file's name. +#[test] +fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { + let away = Folder::new("placed-lrr-away"); + let home = Folder::new("placed-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let i = crate::file_access::confine(Some(&roots), || s.execute("links.info", &json!({"id": id.0}))).unwrap(); + assert!(!i.to_string().contains("placed-lrr-away"), "{i}"); + assert_eq!( + (i["fileName"].as_str(), i["document"].as_bool(), i["status"].as_str()), + (Some("logo.vectorcraft"), Some(true), Some("missing")), + "{i}" + ); + let l = crate::file_access::confine(Some(&roots), || s.execute("links.list", &json!({}))).unwrap(); + assert!(!l.to_string().contains("placed-lrr-away") && l["links"][0]["name"] == "logo.vectorcraft", "{l}"); + let i = s.execute("links.info", &json!({"id": id.0})).unwrap(); + assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index f6b482f19..05f0e85b6 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -146,10 +146,12 @@ fn start_search(app: &mut VectorcraftApp, d: &mut Dialog, folder: &str) -> Resul Ok(()) } -/// The files a search found for `row` (`{path, ids}`), best first: the answer's `links` row with -/// the same path and ids. +/// The files a search found for `row` (`{path?, ids}`), best first: the answer's `links` row with +/// the same ids and, when both have one, the same path. A row has no `path` when its file is outside +/// an automation client's read roots. fn files_for<'a>(found: &'a [Value], row: &Value) -> &'a [Value] { - found.iter().find(|r| r["path"] == row["path"] && r["ids"] == row["ids"]).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice) + let same_path = |r: &Value| r["path"].is_null() || row["path"].is_null() || r["path"] == row["path"]; + found.iter().find(|r| r["ids"] == row["ids"] && same_path(r)).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice) } /// The file chosen at the start among `files`: the only one that is the same file, else the only diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 36659c53b..4722b8b32 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -339,3 +339,12 @@ fn find_in_folder_asks_for_the_folder_off_the_ui_thread() { searched(&mut app); assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); } + +/// A row whose path `links.check` left out (its file is outside the read roots) still shows the +/// files the search found for its objects. +#[test] +fn a_row_without_its_path_shows_the_files_found_for_its_objects() { + let found = [json!({"name": "a.png", "path": "/elsewhere/a.png", "ids": [3], "files": [{"path": "/root/a.png"}]})]; + assert_eq!(files_for(&found, &json!({"name": "a.png", "ids": [3]})), [json!({"path": "/root/a.png"})]); + assert!(files_for(&found, &json!({"name": "a.png", "ids": [4]})).is_empty()); +} diff --git a/docs/mcp.md b/docs/mcp.md index f418354ec..b1ec0b9fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -147,6 +147,12 @@ the suite) keep the agent to the folders you give it: others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file is outside the read roots without naming the file, and it and the library saves with `user: true` leave out `path` when the user library folder is outside the read roots. +- `links.list`, `links.check`, `links.info` and the `missingLinks`, `modifiedLinks` and `updatedLinks` rows of + `document.open` leave out a linked file's path when the file is outside the read roots (a relative path is taken + from the document's folder): `path`, `found`, and in `links.info` also `link` and `location`, with the `fileSize` + and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other + fields. This applies to every document, also one the person or a client with wider roots opened. The paths a + client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice @@ -5912,10 +5918,12 @@ reduced size"; the document keeps the whole file) or, when it can't be read that allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus `status`, `format`, the file's `ppi` and the `effectivePpi`, `scale` (% of 100%), `rotation` (degrees, counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`. +With automation read roots, a file outside them has no `path` or `found` in `links.list` and `links.check`, and no +`link`, `path`, `location`, `fileSize` or `modified` in `links.info`; its row keeps the file's name. `links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. -`links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, +`links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and `links.reveal` open the linked file in its app or show it in its folder (desktop). Edit Original runs only from the app's window, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ea6750282..929dede1f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -750,6 +750,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). + - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`.