diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 1f1cff613..89492ad01 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", has_doc, relink ), @@ -1125,6 +1125,62 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - Ok(json!({ "updated": updated, "missing": missing })) } +/// Which links `links.relink` points at the new file (the Replace choice of Relink). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Scope { + /// Each link its own file (`paths`). + Each, + /// The objects given or selected. + Selected, + /// Every object in the layers linked to the same file as one given or selected. + All, + /// The objects given or selected, then every other missing link whose file name is in the new + /// file's folder. + MissingInFolder, +} + +impl Scope { + fn from_id(id: &str) -> Option { + match id { + "each" => Some(Scope::Each), + "selected" => Some(Scope::Selected), + "all" => Some(Scope::All), + "missingInFolder" => Some(Scope::MissingInFolder), + _ => None, + } + } +} + +/// The error for `scope` and `paths` given one without the other, or `paths` beside ids, path or +/// folder. +fn each_mismatch(cmd: &str) -> EngineError { + bad(cmd, "scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder") +} + +/// `links.relink {scope: "each", paths}`: each entry's ids with its absolute file, entries naming +/// one file merged. A malformed entry, an id in two entries or an empty list is an error. +fn each_paths(p: &Value, cmd: &str) -> Result, String)>> { + let entries = p.get("paths").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| each_mismatch(cmd))?; + let mut seen = BTreeSet::new(); + let mut out: Vec<(Vec, String)> = vec![]; + for (i, e) in entries.iter().enumerate() { + let malformed = || bad(cmd, format!("paths entry {i} needs path and ids (object ids no other entry has)")); + let path = e.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(malformed)?; + let ids = e.get("ids").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(malformed)?; + let mut these = vec![]; + for v in ids { + let id = v.as_u64().map(NodeId).filter(|id| seen.insert(*id)).ok_or_else(malformed)?; + these.push(id); + } + let path = absolute_path(path); + match out.iter_mut().find(|(_, p)| *p == path) { + Some((ids, _)) => ids.extend(these), + None => out.push((these, path)), + } + } + Ok(out) +} + fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "links.relink"; // How the new file takes the image's place this time (default: its Placement Options). @@ -1132,13 +1188,26 @@ fn relink(s: &mut Session, p: &Value) -> Result { None => None, Some(v) => Some(Preserve::from_id(v).ok_or_else(|| bad(C, format!("preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill")))?), }; + let scope = match p.get("scope").filter(|v| !v.is_null()) { + None => Scope::Selected, + Some(v) => v.as_str().and_then(Scope::from_id).ok_or_else(|| bad(C, format!("scope {v} is not each|selected|all|missingInFolder")))?, + }; let st = s.doc()?; let folder_param = str_param(p, "folder"); + match scope { + Scope::Each if p.get("path").is_some() || folder_param.is_some() || p.get("ids").is_some() => return Err(each_mismatch(C)), + Scope::Selected | Scope::All | Scope::MissingInFolder if p.get("paths").is_some() => return Err(each_mismatch(C)), + Scope::MissingInFolder if folder_param.is_some() => { + return Err(bad(C, "scope missingInFolder takes path: the folder of that file is searched for the other missing links")); + } + _ => {} + } + let each = if scope == Scope::Each { Some(each_paths(p, C)?) } else { None }; // With copy: the saved document's folder, which the file is copied into. - let copy_to = match (bool_or(p, "copy", false), str_param(p, "path")) { + let copy_to = match (bool_or(p, "copy", false), str_param(p, "path").is_some() || each.is_some()) { (false, _) => None, - (true, None) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), - (true, Some(_)) => { + (true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), + (true, true) => { let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?; Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?) } @@ -1151,9 +1220,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { _ => None, }) }; - let ids: Vec = match ids_param(p, "ids") { - Some(ids) => ids, - None => { + let mut ids: Vec = match (&each, ids_param(p, "ids")) { + (Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(), + (None, Some(ids)) => ids, + (None, None) => { let selected: Vec = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect(); match folder_param { Some(_) if selected.is_empty() => { @@ -1169,15 +1239,37 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } + // All Instances: the other objects in the layers linked to the same files. + if scope == Scope::All { + let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); + let given: BTreeSet = ids.iter().copied().collect(); + let mut more = vec![]; + st.doc.walk(|n| { + if let Some((l, _, _)) = linked(n) + && paths.contains(l.path.as_str()) + && !given.contains(&n.id) + { + more.push(n.id); + } + }); + ids.extend(more); + } if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. let mut files: Vec<(Vec, String)> = vec![]; let mut not_found = vec![]; - match (str_param(p, "path"), folder_param) { - (Some(path), _) => files.push((ids, absolute_path(path))), - (None, Some(dir)) => { + match (each, str_param(p, "path"), folder_param) { + (Some(each), _, _) => files = each, + (None, Some(path), _) if scope == Scope::MissingInFolder => { + let path = absolute_path(path); + let given: BTreeSet = ids.iter().copied().collect(); + files.push((ids, path)); + missing_in_folder(st, &given, copy_to.is_some(), &mut files, &mut not_found); + } + (None, Some(path), _) => files.push((ids, absolute_path(path))), + (None, None, Some(dir)) => { for id in ids { let Some(link) = image(&id).flatten() else { continue }; let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy()); @@ -1188,10 +1280,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } } - (None, None) => return Err(bad(C, "give path (a file) or folder")), + (None, None, None) => return Err(bad(C, "give path (a file) or folder")), } - let mut reads = vec![]; - let (mut copied, mut kept) = (vec![], vec![]); + // Every file is read and checked before any is copied. + let mut checked = vec![]; for (ids, path) in files { let bytes = read_file(&path)?; // Images show the file; placed documents each show their artboard or page of it. @@ -1215,20 +1307,28 @@ fn relink(s: &mut Session, p: &Value) -> Result { for ((page, bounding, page_box), ids) in documents { sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?)); } - let to = match ©_to { - Some(dir) => { - let (to, new) = copy_into_folder(&path, &bytes, dir)?; + // The bytes are kept only to be copied, the file as it was checked. + let link = link_info(&path, &bytes); + let bytes = copy_to.is_some().then_some(bytes); + checked.push((path, link, bytes, images, img, sources)); + } + let mut reads = vec![]; + let (mut copied, mut kept): (Vec, Vec) = (vec![], vec![]); + for (path, link, bytes, images, img, sources) in checked { + let link = match (©_to, bytes) { + (Some(dir), Some(bytes)) => { + let (to, new) = copy_or_remove(&path, &bytes, dir, &copied)?; + let link = link_info(&to, &bytes); let pair = json!({ "from": path, "to": to }); if new { copied.push(pair) } else { kept.push(pair) } - to + link } - None => path, + _ => link, }; - let link = link_info(&to, &bytes); if let Some(img) = img { reads.push((images, Read::Image((link.clone(), img)))); } @@ -1250,6 +1350,41 @@ fn relink(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// `links.relink {scope: "missingInFolder"}`: the other missing links of `st` (not `given`; only +/// images when `images_only`, as copy takes), each added to `files` with the file of its name in +/// the folder of the first file there; names not there go to `not_found`. +fn missing_in_folder( + st: &crate::DocState, + given: &BTreeSet, + images_only: bool, + files: &mut Vec<(Vec, String)>, + not_found: &mut Vec, +) { + let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return }; + for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) { + let rest: Vec = g.ids.iter().copied().filter(|id| !given.contains(id)).collect(); + if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing { + continue; + } + let found = absolute_path(&dir.join(g.link.name()).to_string_lossy()); + match files.iter_mut().find(|(_, p)| *p == found) { + Some((ids, _)) => ids.extend(rest), + None if file_stamp(&found).is_some() => files.push((rest, found)), + None => not_found.push(g.link.name().to_string()), + } + } +} + +/// [`copy_into_folder`]; when it fails, the copies `copied` (`{from, to}`) the same call made +/// before are removed, since nothing is relinked. +fn copy_or_remove(from: &str, bytes: &[u8], dir: &Path, copied: &[Value]) -> Result<(String, bool)> { + copy_into_folder(from, bytes, dir).inspect_err(|_| { + for to in copied.iter().filter_map(|pair| pair.get("to").and_then(Value::as_str)) { + let _ = std::fs::remove_file(to); + } + }) +} + /// `links.relink {copy}`: put the file at `from` (`bytes`, as just read) into the folder `dir`, /// never replacing a file there ([`fileio::copy_new`]) → where it is now, and whether it was /// copied (`false`: a file there with the same contents, of its name or a numbered one, is used). diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index fd1217c10..cfe43ee8e 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -480,6 +480,151 @@ fn a_missing_link_renders_its_preview_and_relinks() { assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder"); } +/// The path of the file image `id` links to. +fn link_path(s: &Session, id: NodeId) -> String { + image(s, id).link.map(|l| l.path).unwrap_or_default() +} + +/// Replace: All Instances relinks every image linked to the same file as the one given, in one +/// undo step; Selected Instances (the default) only the one given. +#[test] +fn relink_all_instances_takes_every_image_of_the_same_file() { + let dir = Folder::new("relink-all"); + let (pic, other) = (dir.file("photo.png"), dir.file("other.png")); + write(&pic, &png(60, 30, RED)); + write(&other, &png(60, 30, RED)); + let mut s = session(); + let (a, b, c) = (place(&mut s, &pic), place(&mut s, &pic), place(&mut s, &other)); + let blue = dir.file("new/blue.png"); + write(&blue, &png(60, 30, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "selected"})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0], "notFound": []})); + assert_eq!(link_path(&s, b), pic, "selected instances: the other copy keeps its file"); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "all"})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0, b.0], "notFound": []})); + assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (blue.clone(), blue.clone(), other.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((link_path(&s, a), link_path(&s, b)), (pic.clone(), pic.clone()), "one undo step"); + // The selection stands in for ids. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let r = s.execute("links.relink", &json!({"path": blue, "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([b.0, a.0])); +} + +/// Replace: Missing Links from This Folder relinks the given links to the chosen file, then every +/// other missing link to the file of its name in that file's folder, in one undo step; names not +/// there are listed in notFound and stay missing. +#[test] +fn relink_missing_links_from_this_folder_finds_the_others_by_name() { + let dir = Folder::new("relink-missing-folder"); + let names = ["a.png", "b.png", "c.png", "d.png"]; + let mut s = session(); + let ids: Vec = names + .iter() + .map(|n| { + write(&dir.file(n), &png(60, 30, RED)); + place(&mut s, &dir.file(n)) + }) + .collect(); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // d.png stays where it was: it isn't missing. + for n in &names[..3] { + std::fs::remove_file(dir.file(n)).unwrap(); + } + open(&mut s, &doc_path); + let found = dir.file("found/a new name.png"); + write(&found, &png(60, 30, BLUE)); + write(&dir.file("found/b.png"), &png(60, 30, BLUE)); + write(&dir.file("found/d.png"), &png(60, 30, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [ids[0].0], "path": found, "scope": "missingInFolder"})).unwrap(); + assert_eq!(r, json!({"relinked": [ids[0].0, ids[1].0], "notFound": ["c.png"]})); + let b_found = dir.0.join("found").join("b.png").to_string_lossy().into_owned(); + assert_eq!((link_path(&s, ids[0]), link_path(&s, ids[1])), (found.clone(), b_found)); + assert_eq!((link_path(&s, ids[2]), link_path(&s, ids[3])), (dir.file("c.png"), dir.file("d.png"))); + let c = s.execute("links.check", &json!({})).unwrap(); + assert_eq!(c["missing"], 1, "{c}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 3, "one undo step"); +} + +/// Replace: Each Link Separately takes one file for each link, in one undo step. +#[test] +fn relink_each_link_separately_takes_a_file_for_each() { + let dir = Folder::new("relink-each"); + let (one, two) = (dir.file("one.png"), dir.file("two.png")); + write(&one, &png(60, 30, RED)); + write(&two, &png(60, 30, RED)); + let mut s = session(); + let (a, b, c) = (place(&mut s, &one), place(&mut s, &one), place(&mut s, &two)); + let (x, y) = (dir.file("new/x.png"), dir.file("new/y.png")); + write(&x, &png(60, 30, BLUE)); + write(&y, &png(30, 30, BLUE)); + let paths = json!([{"ids": [a.0, b.0], "path": x}, {"ids": [c.0], "path": y}]); + let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0, b.0, c.0], "notFound": []})); + assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (x.clone(), x.clone(), y.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((link_path(&s, a), link_path(&s, c)), (one.clone(), two.clone()), "one undo step"); + // Two entries naming the same file read it once. + let paths = json!([{"ids": [a.0], "path": y}, {"ids": [c.0], "path": y}]); + let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap(); + assert_eq!(r["relinked"], json!([a.0, c.0])); +} + +/// With copy, a file that can't be shown stops the call before any file is copied into the +/// document's folder. +#[test] +fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() { + let dir = Folder::new("relink-each-copy"); + let (one, two) = (dir.file("doc/one.png"), dir.file("doc/two.png")); + write(&one, &png(60, 30, RED)); + write(&two, &png(60, 30, RED)); + let mut s = session(); + let (a, c) = (place(&mut s, &one), place(&mut s, &two)); + save(&mut s, &dir.file("doc/poster.vectorcraft")); + let (x, bad) = (dir.file("new/x.png"), dir.file("new/broken.png")); + write(&x, &png(60, 30, BLUE)); + write(&bad, b"not an image"); + let paths = json!([{"ids": [a.0], "path": x}, {"ids": [c.0], "path": bad}]); + assert!(s.execute("links.relink", &json!({"scope": "each", "paths": paths, "copy": true})).is_err()); + assert!(!Path::new(&dir.file("doc/x.png")).exists(), "no copy is left behind"); + assert_eq!(link_path(&s, a), one); +} + +/// A scope or paths that doesn't fit is refused, and nothing is relinked. +#[test] +fn relink_refuses_scopes_and_paths_that_dont_fit() { + let dir = Folder::new("relink-bad-scope"); + let pic = dir.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + let a = place(&mut s, &pic); + let blue = dir.file("blue.png"); + write(&blue, &png(60, 30, BLUE)); + let bad = [ + json!({"ids": [a.0], "path": blue, "scope": "everything"}), + json!({"ids": [a.0], "path": blue, "scope": 3}), + json!({"scope": "each"}), + json!({"scope": "each", "paths": []}), + json!({"scope": "each", "paths": [{"ids": [a.0]}]}), + json!({"scope": "each", "paths": [{"path": blue}]}), + json!({"scope": "each", "paths": [{"ids": ["one"], "path": blue}]}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}, {"ids": [a.0], "path": pic}]}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "path": blue}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "ids": [a.0]}), + json!({"scope": "all", "paths": [{"ids": [a.0], "path": blue}]}), + json!({"ids": [a.0], "paths": [{"ids": [a.0], "path": blue}]}), + json!({"ids": [a.0], "folder": dir.0.to_string_lossy(), "scope": "missingInFolder"}), + json!({"ids": [a.0], "scope": "missingInFolder"}), + ]; + for p in bad { + assert!(s.execute("links.relink", &p).is_err(), "{p}"); + assert_eq!(link_path(&s, a), pic, "{p} relinks nothing"); + } +} + /// A link stored as a classic Mac OS path (`Disk:Folder:file`, as older `.ai` files keep them) is /// named by its last component: the Links panel shows that name and its folder, Relink to Folder /// looks for that name, and the document's own folder is searched for it. The stored path is kept. diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e3e85d229..7616a4529 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -488,6 +488,35 @@ fn relink_points_a_placed_document_at_another_file() { assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); } +/// Missing Links from This Folder finds a missing placed document by name, except with copy, which +/// takes images only: the placed document then stays as it is. +#[test] +fn relink_missing_in_folder_finds_placed_documents_except_with_copy() { + let dir = Folder::new("placed-missing-folder"); + let (doc_file, pic) = (dir.file("art/a.vectorcraft"), dir.file("art/photo.png")); + write(&pic, &crate::tests_links::png(60, 30, RED)); + source(&doc_file, 100.0, 50.0, RED, 0); + let mut s = session(); + let (placed_id, image_id) = (place(&mut s, &doc_file), place(&mut s, &pic)); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + std::fs::remove_file(&doc_file).unwrap(); + std::fs::remove_file(&pic).unwrap(); + open(&mut s, &doc_path); + let found = dir.0.join("found"); + let chosen = found.join("renamed.png").to_string_lossy().into_owned(); + write(&chosen, &crate::tests_links::png(60, 30, BLUE)); + source(&found.join("a.vectorcraft").to_string_lossy(), 100.0, 50.0, BLUE, 0); + let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder", "copy": true}); + let r = s.execute("links.relink", &p).unwrap(); + assert_eq!(r["relinked"], json!([image_id.0]), "{r}"); + assert_eq!(placed(&s, placed_id).link.path, doc_file, "copy leaves the placed document"); + s.execute("edit.undo", &json!({})).unwrap(); + let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder"}); + let r = s.execute("links.relink", &p).unwrap(); + assert_eq!(r["relinked"], json!([image_id.0, placed_id.0]), "{r}"); +} + /// A file made from scratch at `path` in `format` (`pdf`, or `ai`: a PDF-compatible .ai): one /// 100×50 page per color of `pages`, filled with it (`extra` more rectangles on each change the /// file's size). diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 6a92ec0d9..5f16715da 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -243,6 +243,10 @@ fn structured_junk() { ("addon.select.byLinkStatus", json!({"status": "embedded", "ids": {"a": 1}, "add": [1], "zoom": "yes"})), ("addon.select.byLinkStatus", json!({"status": ["modified", "embedded"], "ids": [], "add": true, "zoom": true})), ("addon.select.byLinkStatus", json!({"status": [[["linked"]]], "ids": null})), + ("links.relink", json!({"scope": "each", "paths": [{"ids": [2, u64::MAX, -1, "x"], "path": ""}, null, 7, {"path": [1]}]})), + ("links.relink", json!({"scope": ["all"], "path": "/no/such/file.png", "ids": {"a": 1}})), + ("links.relink", json!({"scope": "missingInFolder", "path": "", "folder": [], "paths": "x"})), + ("links.relink", json!({"scope": "all", "path": "/no/such/file.png", "ids": [2, u64::MAX, -1, 1.5]})), ("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})), ("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})), ("object.transform", json!({"matrix": [1, 2, 3]})), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4193f8d6a..a7766dd8d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -92,6 +92,7 @@ pub mod raster_effects; pub mod rearrange_artboards; pub mod recolor; mod recovery; +pub mod relink; pub mod rename_artboards; pub(crate) mod revolve; pub(crate) mod revolve_gizmo; @@ -368,6 +369,7 @@ registry! { OfficeExport: [office_export::KIND] => office_export::SPEC, DxfOptions: [dxf_options::KIND] => dxf_options::SPEC, PlacementOptions: [placement_options::KIND] => placement_options::SPEC, + Relink: [relink::KIND] => relink::SPEC, Package: [package::KIND] => package::SPEC, ActionBatch: [action_batch::KIND] => action_batch::SPEC, ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/relink.rs b/crates/ui-egui/src/dialogs/relink.rs new file mode 100644 index 000000000..ceca12c04 --- /dev/null +++ b/crates/ui-egui/src/dialogs/relink.rs @@ -0,0 +1,316 @@ +//! Links panel → Relink…: the selected links, and the Replace choice of which links the new file +//! takes. Selected Instances relinks the selected images and placed documents, All Instances every +//! object linked to the same files, and Missing Links from This Folder the selected ones and then +//! every other missing link whose file name is in the new file's folder. Each Link Separately asks +//! for a file for each link in turn, one OK each, and relinks them all at the last one. Every +//! choice is one `links.relink` call: one undo step. +//! +//! Fields: `ids` (the images and placed documents), `links` (`[{name, ids}]`, one row per linked +//! file, as `links.check` lists them, then one per embedded image), `scope` (each|selected|all|missingInFolder; default +//! selected), `path` (the file; picked when empty) and `paths` (with each: the files chosen so +//! far, one per row of `links` in order; the next OK takes `path` or picks one for the next row). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use super::form; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "relink"; +const CMD: &str = "links.relink"; +/// (value, label) of each Replace choice. +const SCOPES: [(&str, &str); 4] = [ + ("each", "Each Link Separately"), + ("selected", "Selected Instances"), + ("all", "All Instances"), + ("missingInFolder", "Missing Links from This Folder"), +]; +const LABEL: f32 = 70.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Relink").into(), + body, + confirm, + ok: Some("Relink…"), + min_width: 400.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// Open the dialog on the images and placed documents `ids` (default: the selected ones). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let ids = match p.get("ids").filter(|v| !v.is_null()) { + Some(v) => v.as_array().cloned().ok_or_else(|| format!("`ids` must be an array of object ids, not {v}"))?, + None => crate::panels::links::selected_ids(app).into_iter().map(Value::from).collect(), + }; + if ids.is_empty() { + return Err("select the images to relink, or pass ids".into()); + } + let check = app.session.execute("links.check", &json!({ "ids": ids })).map_err(|e| e.to_string())?; + // One row per file: objects showing different content of one file (pages of a PDF) share it. + let mut by_path: Vec<(Value, Value, Vec)> = vec![]; + for l in check["links"].as_array().into_iter().flatten() { + let ids = l["ids"].as_array().cloned().unwrap_or_default(); + match by_path.iter_mut().find(|(path, _, _)| *path == l["path"]) { + Some((_, _, all)) => all.extend(ids), + None => by_path.push((l["path"].clone(), l["name"].clone(), ids)), + } + } + let mut links: Vec = by_path.into_iter().map(|(_, name, ids)| json!({ "name": name, "ids": ids })).collect(); + // Embedded images have no link: each gets a row of its own, named as the Links panel lists it. + let linked: Vec<&Value> = links.iter().flat_map(|l| l["ids"].as_array().into_iter().flatten()).collect(); + let mut embedded: Vec = vec![]; + for id in ids.iter().filter(|id| !linked.contains(id)) { + if !embedded.contains(id) { + embedded.push(id.clone()); + } + } + if !embedded.is_empty() { + let list = app.session.execute("links.list", &json!({})).map_err(|e| e.to_string())?; + let rows = list["links"].as_array().cloned().unwrap_or_default(); + for id in embedded { + let name = rows.iter().find(|r| r["id"] == id).map_or(Value::Null, |r| r["name"].clone()); + links.push(json!({ "name": name, "ids": [id] })); + } + } + app.ui.dialog = Some(Dialog::new(KIND, json!({ "ids": ids, "links": links, "scope": "selected", "path": "", "paths": [] }))); + Ok(Value::Null) +} + +fn rows(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() +} + +/// The file the next OK uses: `path`, else one picked (none: cancelled). +fn file(app: &mut VectorcraftApp, d: &Dialog) -> Result { + match d.str("path") { + p if !p.is_empty() => Ok(p), + _ => { + let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; + crate::picks::open(app, &pick).ok_or_else(|| "cancelled".to_string()) + } + } +} + +/// OK: relink with the chosen scope. With each, a file for the next link: the dialog stays open +/// until every link has one, then relinks them all. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let scope = d.str("scope"); + let r = if scope == "each" { + let links = rows(d, "links"); + let mut paths = rows(d, "paths"); + if paths.len() < links.len() { + paths.push(json!(file(app, d)?)); + } + if paths.len() < links.len() { + let mut next = d.clone(); + next.fields.insert("paths".into(), json!(paths)); + next.fields.insert("path".into(), json!("")); + app.ui.dialog = Some(next); + return Ok(json!({ "next": links.get(paths.len()).map_or(Value::Null, |l| l["name"].clone()) })); + } + let entries: Vec = links.iter().zip(&paths).map(|(l, p)| json!({ "ids": l["ids"], "path": p })).collect(); + app.run(CMD, json!({ "scope": "each", "paths": entries }))? + } else { + let path = file(app, d)?; + app.run(CMD, json!({ "ids": d.fields.get("ids").cloned().unwrap_or(Value::Null), "path": path, "scope": scope }))? + }; + app.ui.dialog = None; + let missed: Vec<&str> = r["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + app.status(crate::panels::links::relinked_status_missed(r["relinked"].as_array().map_or(0, Vec::len), &missed)); + Ok(r) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let links = rows(d, "links"); + let paths = rows(d, "paths"); + let each = d.str("scope") == "each"; + widgets::list_box(ui, |ui| { + ui.set_width(ui.available_width()); + for (i, l) in links.iter().enumerate() { + ui.horizontal(|ui| { + ui.add_space(6.0); + // With each: the link the next OK picks a file for is in bold. + let font = if each && i == paths.len() { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) }; + ui.label(egui::RichText::new(l["name"].as_str().unwrap_or_default()).font(font).color(t.text_strong)); + if let Some(p) = paths.get(i).and_then(Value::as_str).filter(|_| each) { + let name = std::path::Path::new(p).file_name().map_or(p.into(), |n| n.to_string_lossy()); + ui.add(egui::Label::new(egui::RichText::new(format!("→ {name}")).size(11.5).color(t.text_dim)).truncate()).on_hover_text(p); + } + }); + } + }); + ui.add_space(8.0); + form::choice(ui, d, "scope", tl!("Replace:"), (LABEL, 260.0), &SCOPES); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + fn png(rgb: [u8; 3]) -> Vec { + let mut out = vec![]; + let [r, g, b] = rgb; + image::RgbaImage::from_pixel(40, 30, image::Rgba([r, g, b, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// A fresh folder with the PNG files `names` → (app with a document, folder). + fn setup(name: &str, names: &[&str]) -> (VectorcraftApp, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("vectorcraft-relink-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("new")).unwrap(); + for n in names { + std::fs::write(dir.join(n), png([200, 0, 0])).unwrap(); + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + (app, dir) + } + + fn place(app: &mut VectorcraftApp, path: &std::path::Path) -> u64 { + app.run("file.place", json!({"path": path.to_string_lossy()})).unwrap()["ids"][0].as_u64().unwrap() + } + + fn link_path(app: &VectorcraftApp, id: u64) -> String { + let st = app.session.active().unwrap(); + match st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) { + Some(vectorcraft_doc::NodeKind::Image(im)) => im.link.as_ref().map(|l| l.path.clone()).unwrap_or_default(), + _ => String::new(), + } + } + + fn new_file(dir: &std::path::Path, name: &str) -> String { + let p = dir.join("new").join(name); + std::fs::write(&p, png([0, 0, 200])).unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn relink_opens_with_the_replace_choice_and_all_instances_relinks_every_copy() { + let (mut app, dir) = setup("all", &["photo.png", "other.png"]); + let (a, b, c) = (place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("other.png"))); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.services.pick_open = None; + assert!(!crate::menus::enabled(&app, "ui.relinkDialog"), "no file picker (the web)"); + let picked = new_file(&dir, "blue.png"); + let answer = picked.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(answer.clone()))); + assert!(crate::menus::enabled(&app, "ui.relinkDialog")); + app.run("ui.relinkDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + assert_eq!((d.kind.as_str(), d.str("scope"), d.fields["links"].as_array().unwrap().len()), (KIND, "selected".to_string(), 1)); + let text = frame(&mut app); + for label in ["Relink", "photo.png", "Replace:", "Selected Instances", "Relink…", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("all")); + // OK picks the file. + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["relinked"], json!([a, b])); + assert!(app.ui.dialog.is_none()); + assert_eq!((link_path(&app, a), link_path(&app, b)), (picked.clone(), picked.clone())); + assert_eq!(link_path(&app, c), dir.join("other.png").to_string_lossy()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(link_path(&app, b), dir.join("photo.png").to_string_lossy(), "one undo step"); + // Nothing selected and no ids: nothing to relink. + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.relinkDialog")); + assert!(app.run("ui.relinkDialog", json!({})).is_err()); + assert!(app.run("ui.relinkDialog", json!({"ids": 3})).is_err(), "ids is a list"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn each_link_separately_asks_for_a_file_for_each_link_then_relinks_them_all() { + let (mut app, dir) = setup("each", &["one.png", "two.png"]); + let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("two.png"))); + let (x, y) = (new_file(&dir, "x.png"), new_file(&dir, "y.png")); + let mut answers = vec![y.clone(), x.clone()]; + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| answers.pop())); + app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("each")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r, json!({"next": "two.png"})); + let d = app.ui.dialog.as_ref().expect("asks for the next link's file"); + assert_eq!(d.fields["paths"], json!([x])); + assert_eq!(link_path(&app, a), dir.join("one.png").to_string_lossy(), "nothing relinked yet"); + assert!(frame(&mut app).contains("→ x.png")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["relinked"], json!([a, b])); + assert!(app.ui.dialog.is_none()); + assert_eq!((link_path(&app, a), link_path(&app, b)), (x.clone(), y.clone())); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!( + (link_path(&app, a), link_path(&app, b)), + (dir.join("one.png").to_string_lossy().into_owned(), dir.join("two.png").to_string_lossy().into_owned()), + "one undo step" + ); + let _ = std::fs::remove_dir_all(dir); + } + + /// An embedded image in the selection gets a row of its own, so Each Link Separately asks for + /// its file too. + #[test] + fn embedded_images_get_their_own_rows() { + let (mut app, dir) = setup("embedded", &["one.png"]); + let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("one.png"))); + app.run("links.embed", json!({"ids": [b]})).unwrap(); + app.run("ui.relinkDialog", json!({"ids": [a, b, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + let rows = d.fields["links"].as_array().unwrap(); + assert_eq!((rows.len(), &rows[0]["ids"], &rows[1]["ids"]), (2, &json!([a]), &json!([b]))); + let _ = std::fs::remove_dir_all(dir); + } + + /// Objects showing different content of one file share its row, so Each Link Separately asks + /// for that file once. + #[test] + fn one_file_with_two_contents_is_one_row() { + let (mut app, dir) = setup("one-file", &["photo.png"]); + let a = place(&mut app, &dir.join("photo.png")); + std::fs::write(dir.join("photo.png"), png([0, 200, 0])).unwrap(); + let b = place(&mut app, &dir.join("photo.png")); + app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + assert_eq!(d.fields["links"], json!([{"name": "photo.png", "ids": [a, b]}])); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_links_from_this_folder_relinks_the_others_found_there() { + let (mut app, dir) = setup("missing", &["a.png", "b.png"]); + let (a, b) = (place(&mut app, &dir.join("a.png")), place(&mut app, &dir.join("b.png"))); + let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + app.run("document.save", json!({"path": doc})).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::remove_file(dir.join(n)).unwrap(); + } + app.run("file.close", json!({})).unwrap(); + crate::io::open_path(&mut app, &doc).unwrap(); + // The Linked File Not Found question is left for the Links panel. + app.ui.dialog = None; + let chosen = new_file(&dir, "renamed.png"); + let found = new_file(&dir, "b.png"); + app.run("ui.relinkDialog", json!({"ids": [a]})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("scope".into(), json!("missingInFolder")); + d.fields.insert("path".into(), json!(chosen)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r, json!({"relinked": [a, b], "notFound": []})); + assert_eq!((link_path(&app, a), link_path(&app, b)), (chosen, found)); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2dbd637b5..5aae47f65 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2638,6 +2638,14 @@ digits Malayalam Малаялам Relink Свързване наново Relink Linked Files to the Document Свързване наново на свързаните файлове към документа Relink to Folder… Свързване наново към папка… + Each Link Separately Всяка връзка поотделно + Selected Instances Избраните екземпляри + All Instances Всички екземпляри + Missing Links from This Folder Липсващите връзки от тази папка +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не е each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each и paths вървят заедно: paths изброява записи с ids и path вместо ids, path и folder +@msg paths entry {i} needs path and ids (object ids no other entry has) записът {i} в paths изисква path и ids (идентификатори на обекти, които няма друг запис) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder приема path: в папката на този файл се търсят другите липсващи връзки Relink… Свързване наново… Find Linked Files Търсене на свързани файлове Find Linked Files in Folder… Търсене на свързани файлове в папка… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 683233454..68e3c872d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2067,6 +2067,14 @@ digits Malayalam Malajalam Location Lokacija No images Nema slika Relink to Folder… Ponovo poveži s folderom… + Each Link Separately Svaku vezu zasebno + Selected Instances Odabrane instance + All Instances Sve instance + Missing Links from This Folder Nedostajuće veze iz ovog foldera +@msg scope {v} is not each|selected|all|missingInFolder scope {v} nije each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths idu zajedno: paths navodi unose s ids i path, umjesto ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) unos {i} u paths treba path i ids (ID-ove objekata koje nema nijedan drugi unos) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder uzima path: u folderu te datoteke traže se ostale nedostajuće veze Show Missing Prikaži nedostajuće Sort by Status Sortiraj po statusu Clear Overrides Očisti zamjene diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 928c02443..6f0db87c9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3325,6 +3325,14 @@ digits Malayalam Malaiàlam Relink Torna a enllaçar Relink Linked Files to the Document Torna a enllaçar els fitxers enllaçats al document Relink to Folder… Torna a enllaçar a una carpeta… + Each Link Separately Cada enllaç per separat + Selected Instances Instàncies seleccionades + All Instances Totes les instàncies + Missing Links from This Folder Enllaços que falten d’aquesta carpeta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} no és each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths van junts: paths llista entrades amb ids i path, en lloc d’ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrada {i} de paths necessita path i ids (identificadors d’objecte que cap altra entrada no tingui) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder pren path: a la carpeta d’aquest fitxer es cerquen els altres enllaços que falten Relink… Torna a enllaçar… Reload Plug-ins Recarrega els complements Remember Import Matches Recorda les coincidències d’importació diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 503740c64..7ecb23245 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2093,6 +2093,14 @@ digits Malayalam Malajálamské Location Umístění No images Žádné obrázky Relink to Folder… Znovu propojit ke složce… + Each Link Separately Každé propojení zvlášť + Selected Instances Vybrané instance + All Instances Všechny instance + Missing Links from This Folder Chybějící propojení z této složky +@msg scope {v} is not each|selected|all|missingInFolder scope {v} není each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each a paths patří k sobě: paths uvádí položky s ids a path místo ids, path a folder +@msg paths entry {i} needs path and ids (object ids no other entry has) položka {i} v paths potřebuje path a ids (ID objektů, které nemá žádná jiná položka) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder bere path: ve složce tohoto souboru se hledají ostatní chybějící propojení Show Missing Zobrazit chybějící Sort by Status Seřadit podle stavu Clear Overrides Vymazat přepsání diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 153d684b7..0c671f131 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1899,6 +1899,14 @@ digits Malayalam Malayalam Location Placering No images Ingen billeder Relink to Folder… Genopret link til mappe… + Each Link Separately Hvert link for sig + Selected Instances Valgte forekomster + All Instances Alle forekomster + Missing Links from This Folder Manglende links fra denne mappe +@msg scope {v} is not each|selected|all|missingInFolder scope {v} er ikke each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each og paths hører sammen: paths angiver poster med ids og path i stedet for ids, path og folder +@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths skal have path og ids (objekt-id'er, som ingen anden post har) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tager path: der søges efter de andre manglende links i den fils mappe Show Missing Vis manglende Sort by Status Sortér efter status Clear Overrides Ryd tilsidesættelser diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a40c17be3..562e393a4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2665,6 +2665,14 @@ digits Malayalam Malayalam Relink Neu verknüpfen Relink Linked Files to the Document Verknüpfte Dateien neu mit dem Dokument verknüpfen Relink to Folder… Mit Ordner neu verknüpfen… + Each Link Separately Jede Verknüpfung einzeln + Selected Instances Ausgewählte Instanzen + All Instances Alle Instanzen + Missing Links from This Folder Fehlende Verknüpfungen aus diesem Ordner +@msg scope {v} is not each|selected|all|missingInFolder scope {v} ist nicht each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each und paths gehören zusammen: paths listet Einträge mit ids und path anstelle von ids, path und folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-Eintrag {i} braucht path und ids (Objekt-IDs, die kein anderer Eintrag hat) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nimmt path: Im Ordner dieser Datei wird nach den anderen fehlenden Verknüpfungen gesucht Relink… Neu verknüpfen… Find Linked Files Verknüpfte Dateien suchen Find Linked Files in Folder… Verknüpfte Dateien in Ordner suchen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index eb4692e55..9db983115 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3275,6 +3275,14 @@ digits Malayalam Μαλαγιάλαμ Relink Επανασύνδεση Relink Linked Files to the Document Επανασύνδεση συνδεδεμένων αρχείων με το έγγραφο Relink to Folder… Επανασύνδεση σε φάκελο… + Each Link Separately Κάθε σύνδεση χωριστά + Selected Instances Επιλεγμένες παρουσίες + All Instances Όλες οι παρουσίες + Missing Links from This Folder Συνδέσεις που λείπουν από αυτόν τον φάκελο +@msg scope {v} is not each|selected|all|missingInFolder το scope {v} δεν είναι each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder τα scope each και paths πάνε μαζί: το paths απαριθμεί καταχωρίσεις με ids και path, αντί για ids, path και folder +@msg paths entry {i} needs path and ids (object ids no other entry has) η καταχώριση {i} του paths χρειάζεται path και ids (αναγνωριστικά αντικειμένων που δεν έχει άλλη καταχώριση) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links το scope missingInFolder δέχεται path: στον φάκελο αυτού του αρχείου αναζητούνται οι άλλες συνδέσεις που λείπουν Relink… Επανασύνδεση… Find Linked Files Εύρεση συνδεδεμένων αρχείων Find Linked Files in Folder… Εύρεση συνδεδεμένων αρχείων σε φάκελο… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8123438fb..45dda5936 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2708,6 +2708,14 @@ digits Malayalam Malayálam Relink Volver a vincular Relink Linked Files to the Document Volver a vincular los archivos vinculados al documento Relink to Folder… Volver a vincular a carpeta… + Each Link Separately Cada vínculo por separado + Selected Instances Instancias seleccionadas + All Instances Todas las instancias + Missing Links from This Folder Vínculos que faltan de esta carpeta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} no es each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each y paths van juntos: paths enumera entradas con ids y path, en lugar de ids, path y folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la entrada {i} de paths necesita path e ids (id. de objeto que ninguna otra entrada tenga) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder toma path: en la carpeta de ese archivo se buscan los demás vínculos que faltan Relink… Volver a vincular… Find Linked Files Buscar archivos vinculados Find Linked Files in Folder… Buscar archivos vinculados en una carpeta… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3d553fb9d..160cea391 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2054,6 +2054,14 @@ digits Malayalam Malajalam Location Sijainti No images Ei kuvia Relink to Folder… Linkitä uudelleen kansioon… + Each Link Separately Jokainen linkki erikseen + Selected Instances Valitut esiintymät + All Instances Kaikki esiintymät + Missing Links from This Folder Puuttuvat linkit tästä kansiosta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} ei ole each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each ja paths kuuluvat yhteen: paths luettelee merkinnät, joissa on ids ja path, ids:n, path:n ja folder:n sijaan +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-merkintä {i} tarvitsee path- ja ids-arvot (objektitunnukset, joita millään muulla merkinnällä ei ole) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder ottaa path-arvon: muita puuttuvia linkkejä haetaan tämän tiedoston kansiosta Show Missing Näytä puuttuvat Sort by Status Lajittele tilan mukaan Clear Overrides Tyhjennä ohitukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b83d0aaa4..4c8ac1d24 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2700,6 +2700,14 @@ digits Malayalam Malayalam Relink Lier à nouveau Relink Linked Files to the Document Lier à nouveau les fichiers liés au document Relink to Folder… Lier à nouveau à un dossier… + Each Link Separately Chaque lien séparément + Selected Instances Occurrences sélectionnées + All Instances Toutes les occurrences + Missing Links from This Folder Liens manquants de ce dossier +@msg scope {v} is not each|selected|all|missingInFolder scope {v} n’est pas each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each et paths vont ensemble : paths liste des entrées avec ids et path, à la place de ids, path et folder +@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrée {i} de paths nécessite path et ids (des identifiants d’objet qu’aucune autre entrée n’a) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder prend path : les autres liens manquants sont cherchés dans le dossier de ce fichier Relink… Lier à nouveau… Find Linked Files Rechercher des fichiers liés Find Linked Files in Folder… Rechercher des fichiers liés dans un dossier… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0dedd4a4f..81c647bc3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3624,6 +3624,14 @@ Relink दोबारा लिंक करें Relink Linked Files to the Document लिंक्ड फ़ाइलें डॉक्यूमेंट से दोबारा लिंक करें Relink to Folder… फ़ोल्डर से दोबारा लिंक करें… + Each Link Separately हर लिंक अलग से + Selected Instances चुने गए इंस्टेंस + All Instances सभी इंस्टेंस + Missing Links from This Folder इस फ़ोल्डर से मिसिंग लिंक +@msg scope {v} is not each|selected|all|missingInFolder scope {v}, each|selected|all|missingInFolder में से नहीं है +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each और paths साथ में दिए जाते हैं: paths में ids और path वाली एंट्री होती हैं, ids, path और folder की जगह +@msg paths entry {i} needs path and ids (object ids no other entry has) paths की एंट्री {i} को path और ids चाहिए (ऐसे ऑब्जेक्ट ids जो किसी दूसरी एंट्री में न हों) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder, path लेता है: उस फ़ाइल के फ़ोल्डर में बाकी मिसिंग लिंक खोजे जाते हैं Relink… दोबारा लिंक करें… Reload Plug-ins प्लग-इन फिर से लोड करें Remember Import Matches याद रखें आयात मैच diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b884e382e..34d3c84c2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3231,6 +3231,14 @@ mark-style Japanese Japán Relink Újracsatolás Relink Linked Files to the Document Csatolt fájlok újracsatolása a dokumentumhoz Relink to Folder… Újracsatolás mappából… + Each Link Separately Minden csatolás külön + Selected Instances Kijelölt példányok + All Instances Összes példány + Missing Links from This Folder Hiányzó csatolások ebből a mappából +@msg scope {v} is not each|selected|all|missingInFolder a scope {v} nem each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder a scope each és a paths együtt jár: a paths ids és path elemű bejegyzéseket sorol fel az ids, path és folder helyett +@msg paths entry {i} needs path and ids (object ids no other entry has) a paths {i} bejegyzéséhez path és ids kell (olyan objektumazonosítók, amelyek más bejegyzésben nincsenek) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links a scope missingInFolder path értéket vár: a többi hiányzó csatolást a fájl mappájában keresi Relink… Újracsatolás… Find Linked Files Csatolt fájlok keresése Find Linked Files in Folder… Csatolt fájlok keresése mappában… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5be2d068b..dd65683d0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1899,6 +1899,14 @@ digits Malayalam Malayalam Location Lokasi No images Tidak ada gambar Relink to Folder… Tautkan ulang ke folder… + Each Link Separately Setiap tautan terpisah + Selected Instances Instans yang dipilih + All Instances Semua instans + Missing Links from This Folder Tautan hilang dari folder ini +@msg scope {v} is not each|selected|all|missingInFolder scope {v} bukan each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each dan paths digunakan bersama: paths mencantumkan entri berisi ids dan path, sebagai ganti ids, path, dan folder +@msg paths entry {i} needs path and ids (object ids no other entry has) entri paths {i} memerlukan path dan ids (id objek yang tidak dimiliki entri lain) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder menerima path: tautan hilang lainnya dicari di folder file tersebut Show Missing Tampilkan yang hilang Sort by Status Urutkan berdasarkan status Clear Overrides Hapus penimpaan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 33c38b1d6..cec8c7636 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2741,6 +2741,14 @@ digits Malayalam Malayalam Relink Ricollega Relink Linked Files to the Document Ricollega i file collegati al documento Relink to Folder… Ricollega a cartella… + Each Link Separately Ogni collegamento separatamente + Selected Instances Istanze selezionate + All Instances Tutte le istanze + Missing Links from This Folder Collegamenti mancanti da questa cartella +@msg scope {v} is not each|selected|all|missingInFolder scope {v} non è each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths vanno insieme: paths elenca voci con ids e path, al posto di ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la voce {i} di paths richiede path e ids (ID di oggetti che nessun'altra voce ha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder accetta path: nella cartella di quel file si cercano gli altri collegamenti mancanti Relink… Ricollega… Find Linked Files Trova file collegati Find Linked Files in Folder… Trova file collegati in una cartella… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2e164997c..90c0b75aa 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2730,6 +2730,14 @@ mark-style Japanese 日本式 Release to Layers (Sequence) レイヤーに分配 (シーケンス) Relink 再リンク Relink to Folder… フォルダーに再リンク… + Each Link Separately リンクごとに個別 + Selected Instances 選択したインスタンス + All Instances すべてのインスタンス + Missing Links from This Folder このフォルダーから見つからないリンク +@msg scope {v} is not each|selected|all|missingInFolder scope {v} は each|selected|all|missingInFolder のいずれでもありません +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each と paths は一緒に指定します: paths には ids、path、folder の代わりに、ids と path を持つエントリを並べます +@msg paths entry {i} needs path and ids (object ids no other entry has) paths のエントリ {i} には path と ids (ほかのエントリにないオブジェクト ID) が必要です +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder は path を取ります: そのファイルのフォルダーで、ほかの見つからないリンクを探します Relink… 再リンク… Find Linked Files リンクファイルを検索 Find Linked Files in Folder… フォルダー内のリンクファイルを検索… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3004f8077..3597e3e5e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1806,6 +1806,14 @@ digits Malayalam მალაიალამური Location მდებარეობა No images გამოსახულებები არ არის Relink to Folder… საქაღალდეში ხელახლა დაკავშირება… + Each Link Separately თითოეული ბმული ცალ-ცალკე + Selected Instances არჩეული ეგზემპლარები + All Instances ყველა ეგზემპლარი + Missing Links from This Folder დაკარგული ბმულები ამ საქაღალდიდან +@msg scope {v} is not each|selected|all|missingInFolder scope {v} არ არის each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each და paths ერთად გამოიყენება: paths ჩამოთვლის ჩანაწერებს ids-ით და path-ით, ids-ის, path-ისა და folder-ის ნაცვლად +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-ის ჩანაწერს {i} სჭირდება path და ids (ობიექტის ID-ები, რომლებიც სხვა ჩანაწერს არ აქვს) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder იღებს path-ს: სხვა დაკარგული ბმულები ამ ფაილის საქაღალდეში იძებნება Show Missing დაკარგულების ჩვენება Sort by Status სტატუსის მიხედვით სორტირება Clear Overrides ცვლილებების გასუფთავება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7041af27f..937e208f9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2719,6 +2719,14 @@ mark-style Japanese 일본식 Release to Layers (Sequence) 레이어로 풀기(순차) Relink 다시 연결 Relink to Folder… 폴더로 다시 연결… + Each Link Separately 각 연결 따로 + Selected Instances 선택한 인스턴스 + All Instances 모든 인스턴스 + Missing Links from This Folder 이 폴더에서 누락된 연결 +@msg scope {v} is not each|selected|all|missingInFolder scope {v}은(는) each|selected|all|missingInFolder 중 하나가 아닙니다 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each와 paths는 함께 사용합니다: paths에는 ids, path, folder 대신 ids와 path가 있는 항목을 나열합니다 +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 항목 {i}에는 path와 ids(다른 항목에 없는 개체 ID)가 필요합니다 +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder는 path를 받습니다: 그 파일의 폴더에서 다른 누락된 연결을 찾습니다 Relink… 다시 연결… Find Linked Files 연결된 파일 찾기 Find Linked Files in Folder… 폴더에서 연결된 파일 찾기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0dbf7e505..956002bca 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1913,6 +1913,14 @@ digits Malayalam Malayalam Location Locatie No images Geen afbeeldingen Relink to Folder… Opnieuw koppelen aan map… + Each Link Separately Elke koppeling afzonderlijk + Selected Instances Geselecteerde instanties + All Instances Alle instanties + Missing Links from This Folder Ontbrekende koppelingen uit deze map +@msg scope {v} is not each|selected|all|missingInFolder scope {v} is niet each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each en paths horen bij elkaar: paths bevat items met ids en path, in plaats van ids, path en folder +@msg paths entry {i} needs path and ids (object ids no other entry has) item {i} van paths heeft path en ids nodig (object-id's die geen ander item heeft) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder neemt path: in de map van dat bestand wordt naar de andere ontbrekende koppelingen gezocht Show Missing Ontbrekende weergeven Sort by Status Sorteren op status Clear Overrides Onderdrukkingen wissen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b2d27d979..549fefb70 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2013,6 +2013,14 @@ digits Malayalam Malajalam Location Położenie No images Brak obrazów Relink to Folder… Połącz ponownie z folderem… + Each Link Separately Każde łącze osobno + Selected Instances Zaznaczone wystąpienia + All Instances Wszystkie wystąpienia + Missing Links from This Folder Brakujące łącza z tego folderu +@msg scope {v} is not each|selected|all|missingInFolder scope {v} nie jest jednym z each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths występują razem: paths wymienia wpisy z ids i path zamiast ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) wpis {i} w paths wymaga path i ids (identyfikatorów obiektów, których nie ma żaden inny wpis) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder przyjmuje path: w folderze tego pliku szukane są pozostałe brakujące łącza Show Missing Pokaż brakujące Sort by Status Sortuj według stanu Clear Overrides Wyczyść zastąpienia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 947939852..46d7b8fad 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2259,6 +2259,14 @@ digits Malayalam Malaiala Release with Perspective Liberar com Perspectiva Relink Revinculcar Relink to Folder… Revinculcar Pasta… + Each Link Separately Cada Vínculo Separadamente + Selected Instances Instâncias Selecionadas + All Instances Todas as Instâncias + Missing Links from This Folder Vínculos Ausentes Desta Pasta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} não é each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths andam juntos: paths lista entradas com ids e path, no lugar de ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) a entrada {i} de paths precisa de path e ids (ids de objeto que nenhuma outra entrada tenha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder recebe path: os outros vínculos ausentes são procurados na pasta desse arquivo Relink… Revinculcar… Reload Plug-ins Recarregar Plug-ins Remove Remover diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c8064121e..fe42ab1c9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2736,6 +2736,14 @@ digits Malayalam Малаялам Relink Восстановить связь Relink Linked Files to the Document Восстановить связи связанных файлов с документом Relink to Folder… Восстановить связь с папкой… + Each Link Separately Каждую связь отдельно + Selected Instances Выбранные экземпляры + All Instances Все экземпляры + Missing Links from This Folder Отсутствующие связи из этой папки +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не является одним из each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each и paths задаются вместе: paths перечисляет записи с ids и path вместо ids, path и folder +@msg paths entry {i} needs path and ids (object ids no other entry has) записи {i} в paths нужны path и ids (идентификаторы объектов, которых нет в других записях) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder принимает path: в папке этого файла ищутся остальные отсутствующие связи Relink… Восстановить связь… Find Linked Files Найти связанные файлы Find Linked Files in Folder… Найти связанные файлы в папке… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 86652ddc8..9835e4d87 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2644,6 +2644,14 @@ digits Malayalam Malayalam Relink Länka om Relink Linked Files to the Document Länka om länkade filer till dokumentet Relink to Folder… Länka om till mapp… + Each Link Separately Varje länk separat + Selected Instances Markerade instanser + All Instances Alla instanser + Missing Links from This Folder Saknade länkar från den här mappen +@msg scope {v} is not each|selected|all|missingInFolder scope {v} är inte each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each och paths hör ihop: paths listar poster med ids och path i stället för ids, path och folder +@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths behöver path och ids (objekt-id:n som ingen annan post har) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tar path: de andra saknade länkarna söks i den filens mapp Relink… Länka om… Find Linked Files Sök länkade filer Find Linked Files in Folder… Sök länkade filer i mapp… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8aa1f47dc..d8c35fe55 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2514,6 +2514,14 @@ digits Malayalam Malayalam Relink Yeniden bağla Relink Linked Files to the Document Bağlantılı Dosyaları Belgeye Yeniden Bağlama Relink to Folder… Klasöre Yeniden Bağlan… + Each Link Separately Her Bağlantıyı Ayrı Ayrı + Selected Instances Seçili Örnekler + All Instances Tüm Örnekler + Missing Links from This Folder Bu Klasördeki Eksik Bağlantılar +@msg scope {v} is not each|selected|all|missingInFolder scope {v}, each|selected|all|missingInFolder değerlerinden biri değil +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each ve paths birlikte kullanılır: paths, ids, path ve folder yerine ids ve path içeren girdileri listeler +@msg paths entry {i} needs path and ids (object ids no other entry has) paths girdisi {i} için path ve ids gerekir (başka hiçbir girdide olmayan nesne kimlikleri) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder path alır: diğer eksik bağlantılar o dosyanın klasöründe aranır Relink… Yeniden bağla… Find Linked Files Bağlantılı Dosyaları Bul Find Linked Files in Folder… Klasördeki Bağlantılı Dosyaları Bul… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 02501af2e..8958f5838 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2733,6 +2733,14 @@ digits Malayalam Малаялам Relink Відновити зв’язок Relink Linked Files to the Document Відновити зв’язані файли з документом Relink to Folder… Відновити зв’язок із папкою… + Each Link Separately Кожен зв’язок окремо + Selected Instances Вибрані екземпляри + All Instances Усі екземпляри + Missing Links from This Folder Відсутні зв’язки з цієї папки +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не є одним із each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each і paths задаються разом: paths перелічує записи з ids і path замість ids, path і folder +@msg paths entry {i} needs path and ids (object ids no other entry has) запису {i} у paths потрібні path і ids (ідентифікатори об’єктів, яких немає в інших записах) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder приймає path: у папці цього файлу шукаються інші відсутні зв’язки Relink… Відновити зв’язок… Find Linked Files Знайти пов’язані файли Find Linked Files in Folder… Знайти пов’язані файли в папці… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b503825e9..9301bc167 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3007,6 +3007,14 @@ mark-style Japanese Kiểu Nhật Relink Liên kết lại Relink Linked Files to the Document Liên kết lại các tệp liên kết với tài liệu Relink to Folder… Liên kết lại vào thư mục… + Each Link Separately Từng liên kết riêng + Selected Instances Các phiên bản đã chọn + All Instances Tất cả phiên bản + Missing Links from This Folder Liên kết thiếu từ thư mục này +@msg scope {v} is not each|selected|all|missingInFolder scope {v} không phải each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each và paths đi cùng nhau: paths liệt kê các mục có ids và path, thay cho ids, path và folder +@msg paths entry {i} needs path and ids (object ids no other entry has) mục {i} của paths cần path và ids (ID đối tượng mà không mục nào khác có) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nhận path: các liên kết thiếu khác được tìm trong thư mục của tệp đó Relink… Liên kết lại… Reload Plug-ins Tải lại plug-in Remove Gỡ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 75ca8195c..8152ee008 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2612,6 +2612,14 @@ digits Malayalam 马拉雅拉姆数字 Relink 重新链接 Relink Linked Files to the Document 将链接文件重新链接到文档 Relink to Folder… 重新链接到文件夹… + Each Link Separately 逐个链接 + Selected Instances 所选实例 + All Instances 所有实例 + Missing Links from This Folder 此文件夹中缺失的链接 +@msg scope {v} is not each|selected|all|missingInFolder scope {v} 不是 each|selected|all|missingInFolder 之一 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each 与 paths 需一起使用:paths 列出包含 ids 和 path 的条目,用来代替 ids、path 和 folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 条目 {i} 需要 path 和 ids(其他条目没有的对象 ID) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder 需要 path:将在该文件所在的文件夹中查找其他缺失的链接 Relink… 重新链接… Find Linked Files 查找链接文件 Find Linked Files in Folder… 在文件夹中查找链接文件… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be4d191df..6048c9655 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3606,6 +3606,14 @@ digits Malayalam 馬來亞拉姆數字 Relink 重新連結 Relink Linked Files to the Document 將連結檔案重新連結至文件 Relink to Folder… 重新連結至資料夾… + Each Link Separately 逐一連結 + Selected Instances 所選實例 + All Instances 所有實例 + Missing Links from This Folder 此資料夾中遺失的連結 +@msg scope {v} is not each|selected|all|missingInFolder scope {v} 不是 each|selected|all|missingInFolder 其中之一 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each 與 paths 須一起使用:paths 列出含 ids 與 path 的項目,用來取代 ids、path 與 folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 項目 {i} 需要 path 與 ids(其他項目沒有的物件 ID) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder 需要 path:會在該檔案所在的資料夾中尋找其他遺失的連結 Relink… 重新連結… Find Linked Files 尋找連結檔案 Find Linked Files in Folder… 在資料夾中尋找連結檔案… diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 104b20459..f44359973 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -851,6 +851,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::links::open_file(app, p, false), "links.reveal" => crate::panels::links::open_file(app, p, true), "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), + "ui.relinkDialog" => crate::dialogs::relink::open(app, p), "ui.findLinksInFolder" => { crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) } diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index a1c15df20..ecaca0c6e 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -281,6 +281,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.dxfOptionsDialog" => app.session.active().is_some(), "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), + "ui.relinkDialog" => app.services.pick_open.is_some() && (selected_image(app, |_| true) || selected_placed(app)), "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 0256c0f1f..208abcfeb 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1259,6 +1259,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", ), + ( + "ui.relinkDialog", + "Relink…", + "", + "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected", + ), ( "ui.findLinksInFolder", "Find Linked Files in Folder…", diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index a7fe30185..38f984876 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -2,8 +2,8 @@ //! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects //! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows //! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink, -//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the -//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: +//! Go To, Update and Edit Original act on the selected rows (Relink through its dialog, with the +//! Replace choice: `ui.relinkDialog`); Link Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: //! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Select Linked / Embedded / Missing / //! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size //! and Show in Folder. @@ -128,16 +128,19 @@ pub(crate) fn updated_status(n: usize) -> String { crate::i18n::msg_n(n as u64, "Updated {n} image", "Updated {n} images", &[]) } -/// Relink images `ids` to a picked file. -pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { - crate::picks::button(app, move |app| relink_picked(app, ids.clone())); +/// The status bar's line for `n` relinked images, naming the files `missed` that weren't found. +pub(crate) fn relinked_status_missed(n: usize, missed: &[&str]) -> String { + if missed.is_empty() { + relinked_status(n) + } else { + let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); + crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) + } } -fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { - let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; - let Some(path) = crate::picks::open(app, &pick) else { return }; - let r = app.run("links.relink", json!({ "ids": ids, "path": path })); - report(app, r, |v| relinked_status(v["relinked"].as_array().map_or(0, Vec::len))); +/// The ids of the selected image objects and placed documents (Relink's dialog). +pub(crate) fn selected_ids(app: &VectorcraftApp) -> Vec { + selected_images(app) } /// Replace images `ids` with a picked file (Control bar, Properties): it keeps its own aspect @@ -163,13 +166,7 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { let r = app.run("links.relink", json!({ "ids": ids, "folder": folder })); report(app, r, |v| { let missed: Vec<&str> = v["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - let n = v["relinked"].as_array().map_or(0, Vec::len); - if missed.is_empty() { - relinked_status(n) - } else { - let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); - crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) - } + relinked_status_missed(v["relinked"].as_array().map_or(0, Vec::len), &missed) }); } @@ -426,7 +423,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { go_to(app, id); } if widgets::icon_button_enabled(ui, "link", tl!("Relink…"), false, !sel.is_empty() && can_pick(app), 24.0).clicked() { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if widgets::icon_button_enabled(ui, "link-2-off", tl!("Break Link"), false, !linked.is_empty(), 24.0).clicked() { break_link(app, linked.clone()); @@ -548,7 +545,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let any = !sel.is_empty(); let missing_any = list["missing"].as_u64().unwrap_or(0) > 0; if menu_item(ui, tl!("Relink…"), any && can_pick(app), false) { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Relink to Folder…"), (any || missing_any) && app.services.pick_folder.is_some(), false) { relink_to_folder(app, ids(&sel, |_| true)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f0..4e8408cd5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -685,7 +685,16 @@ art too large at that scale) keeps the dialog open. Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the `placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/ `fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as -one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's +one undo step. + +Relink: the Links panel's Relink… button and flyout item (or `ui.relinkDialog {ids?}` for agents) open the `relink` +dialog for the selected images and placed documents (fields `ids`, `links`: `[{name, ids}]`, one row per linked file, +`scope`: `each`/`selected`/`all`/`missingInFolder`, default `selected`, `path` and `paths`). With nothing selected and +no `ids`, it fails with "select the images to relink, or pass ids". `ui.dialog.set {field: "path", value}` then `ui.dialog.confirm` runs +`links.relink {ids, path, scope}` as one undo step; without `path` a file is picked. With `scope: "each"`, each confirm +takes `path` (or picks a file) for the next row of `links`, adds it to `paths` and keeps the dialog open, returning +`{next}` (the next row's name); the last row's confirm runs `links.relink {scope: "each", paths}` for every row as one +undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's `links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open` reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). diff --git a/docs/mcp.md b/docs/mcp.md index 8e0e0a128..646204cc7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4380,6 +4380,14 @@ path isn't followed) when it isn't. The desktop app also checks the active docum comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as +one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, +`path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, +`path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are +errors, and nothing is relinked. Each call is one undo step. The Links panel's Relink… opens the `relink` dialog +(`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, +another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 016b14fd6..1cee8cd2f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1348,6 +1348,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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. - 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`. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed.