From 98731894ec79714a2654c97f71f4c5771828e010 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 06:09:47 -0800 Subject: [PATCH] Links: a nested link follows its moved file whatever else its link holds On open, a link found away from its path was passed to the same file's links in symbols, graph designs, patterns and opacity masks only when the nested link equaled the layers' link field for field. A nested link without a relative path, as documents saved before kept them, then kept the old path. The new path now goes to every nested object that shows the same file and content. --- crates/engine/src/cmd/links.rs | 43 +++++++++++++++++++------------- crates/engine/src/tests/links.rs | 42 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index f3cd7b6b6..4573ea2c2 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -858,8 +858,9 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // of other keys linking to the same file share them, rather than each reading, decoding and // keeping a copy of its own. let mut read_images: BTreeMap = BTreeMap::new(); - // (the link as it was, the link now) of each file found away from its path or read again. - let mut moved: Vec<(LinkInfo, LinkInfo)> = vec![]; + // (the path as it was, the content shown, a placed document?, the link now) of each file found + // away from its path or read again. + let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); @@ -892,7 +893,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p }; if link != g.link { set_links(d, &g.ids, &link); - moved.push((g.link.clone(), link)); + moved.push((g.link.path.clone(), g.key.clone(), true, link)); } } } @@ -904,7 +905,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p let link = f.link_hashed(hash, &g.link); if link != g.link { set_links(d, &g.ids, &link); - moved.push((g.link.clone(), link)); + moved.push((g.link.path.clone(), g.key.clone(), false, link)); } continue; } @@ -930,27 +931,33 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p }; if link != g.link { set_links(d, &g.ids, &link); - moved.push((g.link.clone(), link)); + moved.push((g.link.path.clone(), g.key.clone(), false, link)); } } (Status::Ok, None) => {} } } - // The same links in symbols, graph designs, patterns and opacity masks, which have no ids here. + // The objects in symbols, graph designs, patterns and opacity masks that show the same file and + // content, which have no ids here, whatever else their links hold. if !moved.is_empty() { - let mut by_path: BTreeMap> = BTreeMap::new(); - for (old, new) in moved { - by_path.entry(old.path.clone()).or_default().push((old, new)); + let mut by_path: BTreeMap> = BTreeMap::new(); + for (path, key, document, new) in moved { + by_path.entry(path).or_default().push((key, document, new)); } - let new = |l: &LinkInfo| by_path.get(&l.path).and_then(|m| m.iter().find(|(old, _)| old == l)).map(|(_, new)| new); - d.update_links_where( - |l| new(l).is_some(), - |l| { - if let Some(n) = new(l) { - *l = n.clone(); - } - }, - ); + let new = |n: &Node| { + let (l, key, document) = linked(n)?; + let m = by_path.get(&l.path)?; + m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new) + }; + let pick = |n: &Node| new(n).is_some(); + let mut make = |n: &Node| { + let mut out = n.clone(); + if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) { + *l = link.clone(); + } + (out, None) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); } out } diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index 07db9e009..fd1217c10 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -342,6 +342,48 @@ fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { assert_eq!(images, [new.clone(), new], "the layers' image, then the symbol's"); } +/// A link inside a symbol that the document records without a relative path, beside a link in the +/// layers to the same file that has one, takes the file's new path after the folder moves. +#[test] +fn a_symbols_link_without_a_relative_path_follows_the_moved_file() { + let a = Folder::new("nested-unrelative-a"); + let b = Folder::new("nested-unrelative-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(20, 10, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + place(&mut s, &pic); + let doc_path = a.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // Removes the relative path of every link under `v` → how many it removed. + fn unrelative(v: &mut Value) -> usize { + match v { + Value::Object(o) => { + let own = match o.get_mut("link") { + Some(Value::Object(l)) => usize::from(l.remove("relative").is_some()), + _ => 0, + }; + own + o.values_mut().map(unrelative).sum::() + } + Value::Array(a) => a.iter_mut().map(unrelative).sum(), + _ => 0, + } + } + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + assert_eq!(unrelative(&mut saved["document"]["symbols"]), 1, "the symbol's link"); + assert_eq!(unrelative(&mut saved["document"]["layers"].clone()), 1, "the layers' link keeps its relative path"); + std::fs::write(&doc_path, serde_json::to_vec(&saved).unwrap()).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + let mut paths = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| paths.push(PathBuf::from(&im.link.as_ref().unwrap().path))); + let moved = PathBuf::from(b.file("art/photo.png")); + assert_eq!(paths, [moved.clone(), moved], "the layers' link, then the symbol's"); +} + /// Update Links reads a changed file again for the images inside symbols, patterns and opacity /// masks too: those showing the same content as an image in the layers, and those no object in the /// layers shows. Each takes the file's place as its own Placement Options say, in one undo step.