diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 0adebfa9..43159b0f 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -72,10 +72,31 @@ pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result u64 { + bytes.iter().fold(h, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3)) +} + /// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits. pub fn hash_bytes(bytes: &[u8]) -> String { - let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3)); - format!("img{h:016x}") + format!("img{:016x}", fnv1a(FNV_BASIS, bytes)) +} + +/// [`hash_bytes`] of everything `r` reads, read 64 KB at a time. +pub fn hash_reader(mut r: impl std::io::Read) -> std::io::Result { + let mut buf = vec![0; 1 << 16]; + let mut h = FNV_BASIS; + loop { + match r.read(&mut buf) { + Ok(0) => return Ok(format!("img{h:016x}")), + Ok(n) => h = fnv1a(h, buf.get(..n).unwrap_or_default()), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } } impl ImageBlob { @@ -370,6 +391,14 @@ mod tests { assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2})); } + #[test] + fn a_reader_hashes_as_its_bytes_do() { + // Several 64 KB reads, and none. + let bytes: Vec = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect(); + assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes)); + assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string())); + } + #[test] fn large_images_get_a_small_png_preview() { let big = ImageBlob::new("image/png", png(600, 300)).with_proxy(); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 98b059d4..bfa892dd 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -792,6 +792,51 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> { }) } +/// The highest number [`copy_new`] gives a copy's name (`Name 99.png`). +#[cfg(not(target_arch = "wasm32"))] +const MAX_COPY_NUMBER: u32 = 99; + +/// Where [`copy_new`] put a file. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) enum Placed { + /// Copied to this new file. + Copied(std::path::PathBuf), + /// Kept as this file, which holds the same contents. + Kept(std::path::PathBuf), +} + +/// Put a copy of the file `from` into the folder `dir`, never replacing a file there. It tries +/// `Name.ext`, then `Name 2.ext` up to `Name 99.ext`, and stops at the first name that is free, +/// where `create` makes the copy, or that is a file holding the same contents (`same`, given that +/// file and its metadata), which is kept. When `create` fails with `AlreadyExists` (the name was +/// taken meanwhile), it tries the next name. `None` when no name is free or holds the same +/// contents; a name whose metadata can't be read counts as taken. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn copy_new( + from: &std::path::Path, + dir: &std::path::Path, + create: impl Fn(&std::path::Path) -> std::io::Result<()>, + same: impl Fn(&std::path::Path, &std::fs::Metadata) -> bool, +) -> std::io::Result> { + use std::io::ErrorKind; + let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(ErrorKind::InvalidInput.into()) }; + let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); + for n in 1..=MAX_COPY_NUMBER { + let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) }; + match std::fs::symlink_metadata(&to) { + Err(e) if e.kind() == ErrorKind::NotFound => match create(&to) { + Ok(()) => return Ok(Some(Placed::Copied(to))), + // Taken meanwhile: the next name. + Err(e) if e.kind() == ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + }, + Ok(m) if m.is_file() && same(&to, &m) => return Ok(Some(Placed::Kept(to))), + _ => {} + } + } + Ok(None) +} + /// The most entries `file.list` returns. const LIST_MAX: usize = 5000; diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 222ee2b9..c981c24b 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -45,9 +45,6 @@ const MAX_ADD_FILES: usize = 1_000; pub(crate) const MAX_FONT_FILE: u64 = 256 << 20; #[cfg(not(target_arch = "wasm32"))] const MAX_ADD_BYTES: u64 = 1 << 30; -/// The highest number a copy's name gets (`Name 99.otf`). -#[cfg(not(target_arch = "wasm32"))] -const MAX_NUMBER: u32 = 99; /// The extensions of the font files `text.addFontFiles` copies (in any case), besides suitcase /// fonts. #[cfg(not(target_arch = "wasm32"))] @@ -88,7 +85,7 @@ pub fn specs() -> Vec { "Add Font Files", [], None, - "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: a file with the same contents is kept, and when another file has that name, the copy gets a number (Name 2.otf); then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", + "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", always, add ), @@ -367,18 +364,13 @@ fn add(s: &mut Session, p: &Value) -> Result { Ok(out) } -/// Where [`copy_one`] put a file: copied to, or kept as, the file in the folder. -#[cfg(not(target_arch = "wasm32"))] -enum Placed { - Copied(PathBuf), - Kept(PathBuf), -} - -/// Copy `files` into `dir`, never replacing a file there: a file with the same contents is kept, -/// and when another file has the name, the copy gets the first free name from `Name 2.otf` to -/// `Name 99.otf`. At most [`MAX_FONT_FILE`] bytes a file and 1 GB in all. +/// Copy `files` into `dir`, never replacing a file there ([`super::fileio::copy_new`]): the names +/// `Name.otf`, `Name 2.otf` up to `Name 99.otf` are tried in turn until one is free, which gets +/// the copy, or is a file with the same contents, which is kept. At most [`MAX_FONT_FILE`] bytes a +/// file and 1 GB in all. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn copy_into(files: &[PathBuf], dir: &Path) -> CopyReport { + use super::fileio::Placed; let mut report = CopyReport::default(); let mut total = 0; for from in files { @@ -398,10 +390,10 @@ pub(crate) fn copy_into(files: &[PathBuf], _: &Path) -> CopyReport { } #[cfg(not(target_arch = "wasm32"))] -fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result { - use std::io::ErrorKind; +fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result { + use super::fileio::Placed; let meta = std::fs::symlink_metadata(from).map_err(|e| e.to_string())?; - if !meta.is_file() { + if !meta.is_file() || from.file_name().is_none() { return Err(NOT_A_FILE.into()); } let font = from.extension().and_then(|e| e.to_str()).is_some_and(|e| FONT_EXTENSIONS.iter().any(|x| e.eq_ignore_ascii_case(x))); @@ -418,25 +410,16 @@ fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result MAX_ADD_BYTES { return Err(TOO_MUCH.into()); } - let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(NOT_A_FILE.into()) }; - let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); - for n in 1..=MAX_NUMBER { - let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) }; - match std::fs::symlink_metadata(&to) { - Err(e) if e.kind() == ErrorKind::NotFound => match create_copy(from, &to, fork.as_deref()) { - Ok(()) => { - *total += len; - return Ok(Placed::Copied(to)); - } - // Taken meanwhile: the next name. - Err(e) if e.kind() == ErrorKind::AlreadyExists => {} - Err(e) => return Err(e.to_string()), - }, - Ok(m) if m.is_file() && same_file(from, &to, m.len(), len, fork.as_deref()) => return Ok(Placed::Kept(to)), - _ => {} + let create = |to: &Path| create_copy(from, to, fork.as_deref()); + match super::fileio::copy_new(from, dir, create, |to, m| same_file(from, to, m.len(), len, fork.as_deref())) { + Ok(Some(Placed::Copied(to))) => { + *total += len; + Ok(Placed::Copied(to)) } + Ok(Some(kept)) => Ok(kept), + Ok(None) => Err(NO_NAME.into()), + Err(e) => Err(e.to_string()), } - Err(NO_NAME.into()) } /// Create `to` as a copy of the font file `from`, or for a suitcase font, as an empty file whose diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs new file mode 100644 index 00000000..c70d423d --- /dev/null +++ b/crates/engine/src/cmd/linkfiles.rs @@ -0,0 +1,267 @@ +//! Looking for the files of linked images and placed documents in a folder the user picks, and its +//! subfolders (`links.findFiles`, searched on separate threads: [`super::findfiles`]). A file with +//! a linked file's name, in any letter case, is ranked by what the link saved +//! ([`vectorcraft_doc::LinkInfo`]): the same file (its size and hash), the same size (when the link +//! saved no hash, or the file is larger than 1 GB and isn't hashed), or the name only. + +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; + +use super::findfiles::{self, Limits, Visitor}; +use super::*; + +/// The most linked files one search looks for. +const MAX_LINKS: usize = 1_000; +/// The longest folder path given (bytes). +const MAX_PATH: usize = 4096; +/// The largest file a search hashes to compare with a link's saved hash. A larger file of the +/// saved size counts as the same size only. +const MAX_HASHED: u64 = 1 << 30; + +pub fn specs() -> Vec { + vec![cmd!( + query "links.findFiles", + "Find Linked Files", + [], + None, + "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + always, + find + )] +} + +/// A linked file a search looks for: a file and content the objects `ids` show. +#[derive(Clone, Debug)] +pub(crate) struct Sought { + pub(crate) name: String, + pub(crate) path: String, + pub(crate) ids: Vec, + /// The file's size and hash when it was last read. + pub(crate) size: Option, + pub(crate) hash: Option, +} + +/// The session's last search for linked files (`links.findFiles`). +pub(crate) struct LinkSearch { + /// The search's number in the session; [`stop`] takes it. + pub(crate) id: u64, + /// The folder as given. + pub(crate) folder: String, + pub(crate) links: Vec, + /// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]). + pub(crate) sizes: Arc>>, + pub(crate) search: findfiles::Search, +} + +/// The files a search for linked files reads: those with a linked file's name. Linked file `i` is +/// item `i` (a file of its name) and item `n + i` (the same file), of `n` linked files. +struct LinkFiles { + /// The linked files' names in lowercase, each with the linked files of that name. + names: HashMap>, + /// Each linked file's saved size and hash. + saved: Vec<(Option, Option)>, + /// The size of each file read, by the path the search reports ([`findfiles::plain`]). + sizes: Arc>>, +} + +impl LinkFiles { + fn new(links: &[Sought], sizes: Arc>>) -> Self { + let mut names: HashMap> = HashMap::new(); + for (i, l) in links.iter().enumerate() { + names.entry(l.name.to_lowercase()).or_default().push(i); + } + Self { names, saved: links.iter().map(|l| (l.size, l.hash.clone())).collect(), sizes } + } +} + +impl Visitor for LinkFiles { + fn wants(&self, name: &str) -> bool { + self.names.contains_key(&name.to_lowercase()) + } + fn items(&self, path: &Path) -> Vec { + let Some(of_name) = path.file_name().and_then(|n| n.to_str()).and_then(|n| self.names.get(&n.to_lowercase())) else { return vec![] }; + let Ok(meta) = std::fs::symlink_metadata(path) else { return vec![] }; + let size = meta.len(); + let n = self.saved.len(); + // Read once, for the first linked file of its name saved with this size and a hash. + let mut hash: Option> = None; + let mut items = vec![]; + for &i in of_name { + items.push(i); + if let Some((Some(saved_size), Some(saved_hash))) = self.saved.get(i) + && *saved_size == size + && size <= MAX_HASHED + && hash.get_or_insert_with(|| std::fs::File::open(path).and_then(vectorcraft_doc::links::hash_reader).ok()).as_ref() + == Some(saved_hash) + { + items.push(n + i); + } + } + lock(&self.sizes).insert(findfiles::plain(path.to_path_buf()), size); + items + } +} + +/// The sizes map, also when a thread that held it panicked (entries are only inserted). +fn lock(sizes: &Mutex>) -> MutexGuard<'_, HashMap> { + sizes.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn find(s: &mut Session, p: &Value) -> Result { + const C: &str = "links.findFiles"; + static NEXT: AtomicU64 = AtomicU64::new(1); + if bool_or(p, "stop", false) { + if let Some(l) = &s.link_search { + l.search.stop(); + } + return Ok(state(s)); + } + let Some(folder) = p.get("folder").filter(|f| !f.is_null()) else { return Ok(state(s)) }; + let seconds = p.get("maxSeconds").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(60.0).clamp(1.0, 600.0); + // Only checked here: the folder is read on a walker thread. + let folder = folder + .as_str() + .filter(|f| !f.is_empty() && f.len() <= MAX_PATH && !f.contains(['\0', '\u{FFFD}']) && Path::new(f).is_absolute()) + .ok_or_else(|| bad(C, "`folder` must be an absolute folder path"))? + .to_string(); + let st = s.active().ok_or_else(|| bad(C, "open a document to look for its linked files"))?; + let ids = match p.get("ids") { + None | Some(Value::Null) => None, + Some(_) => { + let ids = checked_ids_param(s, p, "ids", C)?; + let linkable = |id: &NodeId| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); + if let Some(id) = ids.iter().find(|id| !linkable(id)) { + return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); + } + Some(ids) + } + }; + let links: Vec = super::links::files_to_find(st, ids.as_deref()) + .into_iter() + .take(MAX_LINKS) + .map(|(link, ids)| Sought { + name: link.name().to_string(), + ids: ids.iter().map(|id| id.0).collect(), + size: link.size, + hash: link.hash, + path: link.path, + }) + .collect(); + if links.is_empty() { + return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" })); + } + // A new search stops the last one. + s.link_search = None; + let threads = s.search_threads.unwrap_or_else(findfiles::threads); + let limits = Limits { seconds, ..Limits::default() }; + let sizes = Arc::new(Mutex::new(HashMap::new())); + let visitor = Arc::new(LinkFiles::new(&links, sizes.clone())); + let search = + findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?; + s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search }); + Ok(state(s)) +} + +/// How many folder names the file at `found` has in common with the saved path `saved` (whichever +/// system's separators it uses), counted from the innermost out up to the first that differs, +/// compared in lowercase. +fn shared_folders(saved: &str, found: &Path) -> usize { + let saved = saved.rsplit(['/', '\\']).skip(1).filter(|c| !c.is_empty()); + let found = found.parent().into_iter().flat_map(|p| p.components().rev()).map_while(|c| match c { + Component::Normal(n) => n.to_str(), + _ => None, + }); + saved.zip(found).take_while(|(a, b)| a.to_lowercase() == b.to_lowercase()).count() +} + +/// Where the session's last search stands (`links.findFiles` without `folder`). +fn state(s: &Session) -> Value { + let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) }; + let p = l.search.progress(); + let n = l.links.len(); + let sizes = lock(&l.sizes).clone(); + // Per linked file: the files found, each with whether it is the same file. + let mut found: Vec> = vec![vec![]; n]; + for (path, items) in &p.hits { + let mut of: Vec<(usize, bool)> = vec![]; + for i in items { + let (k, same) = if *i < n { (*i, false) } else { (i - n, true) }; + match of.iter_mut().find(|(j, _)| *j == k) { + Some((_, s)) => *s |= same, + None => of.push((k, same)), + } + } + for (k, same) in of { + if let Some(v) = found.get_mut(k) { + v.push((path, same)); + } + } + } + let links: Vec = l + .links + .iter() + .zip(found) + .map(|(link, files)| { + let mut files: Vec<(u8, bool, usize, &Path, Option)> = files + .into_iter() + .map(|(path, same)| { + let size = sizes.get(path).copied(); + // Compared by hash where the link saved one and the file isn't too large to hash. + let hashed = link.hash.is_some() && size.is_some_and(|z| z <= MAX_HASHED); + let rank = if same { + 0 + } else if size.is_some() && size == link.size && !hashed { + 1 + } else { + 2 + }; + let case_differs = path.file_name().and_then(|f| f.to_str()) != Some(link.name.as_str()); + (rank, case_differs, shared_folders(&link.path, path), path, size) + }) + .collect(); + files.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(b.2.cmp(&a.2)).then(a.3.cmp(b.3))); + let files: Vec = files + .into_iter() + .map(|(rank, case_differs, _, path, size)| { + let kind = match rank { + 0 => "same", + 1 => "size", + _ => "name", + }; + let mut f = json!({ "path": path.to_string_lossy(), "size": size, "match": kind }); + if case_differs { + f["caseDiffers"] = json!(true); + } + f + }) + .collect(); + json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files }) + }) + .collect(); + let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; + let mut out = json!({ + "id": l.id, "state": p.state(), "folder": folder, "links": links, + "searched": { "folders": p.folders, "files": p.files, "read": p.read }, + "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, + }); + if let Some(why) = p.stopped() { + out["stopped"] = json!(why); + } + if let Some(e) = p.error() { + out["error"] = json!(e); + } + out +} + +/// Stop the session's search when it is the one numbered `id` (`links.findFiles`'s `id`): a newer +/// search, started by someone else, goes on. +pub fn stop(s: &mut Session, id: u64) { + if let Some(l) = s.link_search.as_ref().filter(|l| l.id == id) { + l.search.stop(); + } +} diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index cd527216..a5d396fa 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -66,7 +66,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} link images (or placed documents, each showing the same artboard 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]}", + "{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)} link images (or placed documents, each showing the same artboard 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 ), @@ -322,6 +322,17 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec) -> Vec<(LinkInfo, Vec)> { + groups(&st.doc, ids) + .into_iter() + .filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, folder(st.path.as_deref())).0 == Status::Missing)) + .map(|g| (g.link, g.ids)) + .collect() +} + // ---------- reading files into the document ---------- /// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a @@ -731,6 +742,15 @@ fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "links.relink"; let st = s.doc()?; let folder_param = str_param(p, "folder"); + // 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")) { + (false, _) => None, + (true, None) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), + (true, Some(_)) => { + 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")))?) + } + }; // An image or a placed document, with its link. let image = |id: &NodeId| { st.doc.node(*id).and_then(|n| match &n.kind { @@ -759,6 +779,9 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } + 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![]; @@ -778,9 +801,9 @@ fn relink(s: &mut Session, p: &Value) -> Result { (None, None) => return Err(bad(C, "give path (a file) or folder")), } let mut reads = vec![]; + let (mut copied, mut kept) = (vec![], vec![]); for (ids, path) in files { let bytes = read_file(&path)?; - let link = link_info(&path, &bytes); // Images show the file; placed documents each show their artboard of it. let mut images = vec![]; let mut documents: Vec<((u32, bool), Vec)> = vec![]; @@ -796,12 +819,30 @@ fn relink(s: &mut Session, p: &Value) -> Result { _ => images.push(id), } } - if !images.is_empty() { - let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?; + // Read before it is copied: a file that can't be shown isn't copied. + let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) }; + let mut sources = vec![]; + for ((page, bounding), ids) in documents { + sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?)); + } + let to = match ©_to { + Some(dir) => { + let (to, new) = copy_into_folder(&path, &bytes, dir)?; + let pair = json!({ "from": path, "to": to }); + if new { + copied.push(pair) + } else { + kept.push(pair) + } + to + } + None => path, + }; + let link = link_info(&to, &bytes); + if let Some(img) = img { reads.push((images, Read::Image((link.clone(), img)))); } - for ((page, bounding), ids) in documents { - let src = super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?; + for (page, ids, src) in sources { reads.push((ids, Read::Document(LinkInfo { page: Some(page), ..link.clone() }, src))); } } @@ -811,7 +852,43 @@ fn relink(s: &mut Session, p: &Value) -> Result { } not_found.sort(); not_found.dedup(); - Ok(json!({ "relinked": relinked, "notFound": not_found })) + let mut out = json!({ "relinked": relinked, "notFound": not_found }); + if copy_to.is_some() { + out["copied"] = json!(copied); + out["kept"] = json!(kept); + } + Ok(out) +} + +/// `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). +#[cfg(not(target_arch = "wasm32"))] +fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, bool)> { + use std::io::Write as _; + let source = Path::new(from); + let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + // Each name it takes is checked against the automation roots in force. + let create = |to: &Path| { + crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + vectorcraft_format::write_new_with(to, |f| f.write_all(bytes)) + }; + // A file there that automation may not read isn't taken for the same file. + let same = |to: &Path, m: &std::fs::Metadata| { + m.len() == bytes.len() as u64 && crate::file_access::check_read(&to.to_string_lossy()).is_ok() && std::fs::read(to).is_ok_and(|b| b == bytes) + }; + match fileio::copy_new(source, dir, create, same) { + Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)), + Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)), + Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))), + Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } +} + +/// The web has no file system: nothing is copied. +#[cfg(target_arch = "wasm32")] +fn copy_into_folder(_: &str, _: &[u8], _: &Path) -> Result<(String, bool)> { + Err(bad("links.relink", "copying a file isn't available on the web")) } // ---------- the Links panel ---------- diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 81c878c9..3545b4f1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -35,6 +35,7 @@ pub(crate) mod inline; mod layer; mod layerpanel; pub mod library; +pub mod linkfiles; pub mod links; mod live; pub(crate) mod maskedit; @@ -261,6 +262,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(newdoc::specs()); v.extend(place::specs()); v.extend(links::specs()); + v.extend(linkfiles::specs()); v.extend(pdfcmds::specs()); v.extend(fileio::dxf::specs()); v.extend(fileio::eps::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6370d8d1..307c4084 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -918,6 +918,9 @@ pub struct Session { /// The last search for the files of missing fonts (`text.findFontFiles`), until another /// starts; dropping it stops it. pub(crate) font_search: Option, + /// The last search for the files of linked images and placed documents (`links.findFiles`), + /// until another starts; dropping it stops it. + pub(crate) link_search: Option, /// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]). /// Tests set it. pub search_rules: Option, @@ -982,6 +985,7 @@ impl Session { plane_widget_press: false, ruler_guide: None, font_search: None, + link_search: None, search_rules: None, search_threads: None, link_stamps: Default::default(), @@ -1592,6 +1596,8 @@ mod tests_library; #[cfg(test)] mod tests_linked_stops; #[cfg(test)] +mod tests_linkfiles; +#[cfg(test)] mod tests_links; #[cfg(test)] mod tests_linkspanel; diff --git a/crates/engine/src/tests_linkfiles.rs b/crates/engine/src/tests_linkfiles.rs new file mode 100644 index 00000000..da0c1352 --- /dev/null +++ b/crates/engine/src/tests_linkfiles.rs @@ -0,0 +1,362 @@ +//! Missing linked files looked for in a folder on threads of their own (`links.findFiles`), the +//! files found ranked by the size and hash saved with each link, and `links.relink {copy}`, which +//! puts the file in the document's folder without replacing a file there. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::cmd::findfiles::{self, Limits, Rules, Visitor}; +use crate::cmd::linkfiles::{self, LinkSearch, Sought}; +use crate::tests_links::{BLUE, RED, place, png}; +use crate::{EngineError, Session}; + +/// A fresh temporary folder (canonical, as a search reports it). +fn folder(tag: &str) -> PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("linkfiles-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + findfiles::plain(std::fs::canonicalize(&dir).unwrap()) +} + +/// Write `bytes` to the file at `path`, making its folders. +fn write(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// A session with a new document, whose folder searches may go anywhere in `root`, on two threads. +fn session_for(root: &Path) -> Session { + let mut s = Session::new(); + s.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); + s.search_threads = Some(2); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +/// Place the file at `path`, linked → its id. +fn placed(s: &mut Session, path: &Path) -> u64 { + place(s, &path.to_string_lossy()).0 +} + +/// `links.findFiles {}` once the search ended (10 seconds at most). +fn finished(s: &mut Session) -> Value { + let until = Instant::now() + Duration::from_secs(10); + loop { + let r = s.execute("links.findFiles", &json!({})).unwrap(); + if r["state"] != "searching" || Instant::now() > until { + return r; + } + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// The files a search found for the linked file named `name`, best first: (path below `root`, +/// match, whether the name's letter case differs). +fn found(r: &Value, name: &str, root: &Path) -> Vec<(String, String, bool)> { + let link = r["links"].as_array().unwrap().iter().find(|l| l["name"] == name).unwrap_or_else(|| panic!("{name} in {r}")); + link["files"] + .as_array() + .unwrap() + .iter() + .map(|f| { + let path = Path::new(f["path"].as_str().unwrap()).strip_prefix(root).unwrap(); + let path = path.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::>().join("/"); + (path, f["match"].as_str().unwrap().to_string(), f["caseDiffers"] == true) + }) + .collect() +} + +/// The same file comes first, then the same size, then the name only; within each, the name in its +/// own letter case, then the folder names the file shares with the saved path, counted from the +/// innermost out up to the first that differs. +#[test] +fn files_found_are_ranked_by_what_the_link_saved() { + let root = folder("rank"); + let mut s = session_for(&root); + let (logo, photo) = (png(60, 40, RED), png(50, 30, BLUE)); + write(&root.join("orig/logo.png"), &logo); + write(&root.join("orig/photo.png"), &photo); + placed(&mut s, &root.join("orig/logo.png")); + let photo_id = placed(&mut s, &root.join("orig/photo.png")); + // A link saved without a hash (as older documents saved them) can't be the same file. + s.edit("old link", |d, _| { + if let NodeKind::Image(im) = &mut d.node_mut(NodeId(photo_id)).unwrap().kind { + im.link.as_mut().unwrap().hash = None; + } + Ok(()) + }) + .unwrap(); + std::fs::remove_dir_all(root.join("orig")).unwrap(); + let search = root.join("search"); + let mut changed = logo.clone(); + *changed.last_mut().unwrap() ^= 1; + let mut longer = logo.clone(); + longer.push(0); + write(&search.join("a-other/logo.png"), &longer); + write(&search.join("z/orig/logo.png"), &changed); + write(&search.join("0/LOGO.PNG"), &logo); + write(&search.join("a/b/logo.png"), &logo); + write(&search.join("photo.png"), &photo); + write(&search.join("q/photo.png"), &[photo.as_slice(), &[0]].concat()); + write(&search.join("readme.txt"), b"logo.png"); + write(&search.join("logo.png.txt"), &logo); + let r = s.execute("links.findFiles", &json!({"folder": search})).unwrap(); + assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); + let r = finished(&mut s); + assert_eq!((r["state"].as_str(), r["searched"]["read"].as_u64()), (Some("done"), Some(6)), "{r}"); + let row = |(path, kind, case): (&str, &str, bool)| (format!("search/{path}"), kind.to_string(), case); + let logo_files = + [("a/b/logo.png", "same", false), ("0/LOGO.PNG", "same", true), ("z/orig/logo.png", "name", false), ("a-other/logo.png", "name", false)]; + assert_eq!(found(&r, "logo.png", &root), logo_files.map(row)); + assert_eq!(found(&r, "photo.png", &root), [("photo.png", "size", false), ("q/photo.png", "name", false)].map(row)); + let photo_row = r["links"].as_array().unwrap().iter().find(|l| l["name"] == "photo.png").unwrap(); + assert_eq!((&photo_row["ids"], &photo_row["files"][0]["size"]), (&json!([photo_id]), &json!(photo.len()))); + assert_eq!(photo_row["path"], json!(root.join("orig/photo.png"))); +} + +/// Without `ids`, a search looks for the files that can't be found; with them, for those objects' +/// files, whatever their status. +#[test] +fn a_search_looks_for_the_missing_files_or_the_objects_given() { + let root = folder("which"); + let mut s = session_for(&root); + write(&root.join("orig/gone.png"), &png(40, 30, RED)); + write(&root.join("orig/here.png"), &png(40, 30, BLUE)); + let gone = placed(&mut s, &root.join("orig/gone.png")); + let here = placed(&mut s, &root.join("orig/here.png")); + std::fs::remove_file(root.join("orig/gone.png")).unwrap(); + let search = root.join("search"); + std::fs::create_dir_all(&search).unwrap(); + let names = |r: &Value| r["links"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap().to_string()).collect::>(); + let r = s.execute("links.findFiles", &json!({"folder": search})).unwrap(); + assert_eq!((names(&r), &r["links"][0]["ids"]), (vec!["gone.png".to_string()], &json!([gone]))); + let r = s.execute("links.findFiles", &json!({"folder": search, "ids": [here]})).unwrap(); + assert_eq!(names(&r), ["here.png"]); + assert_eq!(finished(&mut s)["links"][0]["files"], json!([])); + // What isn't an image, an embedded image, and a document with nothing missing. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let embedded = s.execute("file.place", &json!({"path": root.join("orig/here.png"), "link": false})).unwrap()["ids"][0].as_u64().unwrap(); + for (ids, why) in [(json!([rect]), "is not an image"), (json!([embedded]), "show no linked files"), (json!([u64::MAX]), "no such object")] { + let r = s.execute("links.findFiles", &json!({"folder": search, "ids": ids})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains(why)), "{ids}: {r:?}"); + } + write(&root.join("orig/gone.png"), &png(40, 30, RED)); + let r = s.execute("links.findFiles", &json!({"folder": search})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("no linked files are missing")), "{r:?}"); + let r = s.execute("links.findFiles", &json!({"folder": "relative/folder", "ids": [here]})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("absolute")), "{r:?}"); + // Without a document: the last search's state, and no new search. + let mut none = Session::new(); + assert_eq!(none.execute("links.findFiles", &json!({})).unwrap(), json!({"state": "idle", "links": []})); + assert!(matches!(none.execute("links.findFiles", &json!({"folder": search})), Err(EngineError::BadParams { .. }))); +} + +/// A search ends once every linked file has its same file, and only then: a file of the name with +/// other contents leaves it to search every folder. +#[test] +fn a_search_ends_once_every_linked_file_has_its_same_file() { + let root = folder("early"); + let mut s = session_for(&root); + s.search_threads = Some(1); + let bytes = png(40, 30, RED); + write(&root.join("orig/logo.png"), &bytes); + placed(&mut s, &root.join("orig/logo.png")); + std::fs::remove_file(root.join("orig/logo.png")).unwrap(); + let search = root.join("search"); + for a in 0..40 { + for b in 0..40 { + std::fs::create_dir_all(search.join(format!("f{a}/g{b}"))).unwrap(); + } + } + write(&search.join("logo.png"), &png(40, 30, BLUE)); + s.execute("links.findFiles", &json!({"folder": search})).unwrap(); + let r = finished(&mut s); + assert_eq!((r["state"].as_str(), r["searched"]["folders"].as_u64()), (Some("done"), Some(1641)), "{r}"); + assert_eq!(r["links"][0]["files"][0]["match"], "name"); + write(&search.join("logo.png"), &bytes); + s.execute("links.findFiles", &json!({"folder": search})).unwrap(); + let r = finished(&mut s); + assert_eq!(r["links"][0]["files"][0]["match"], "same"); + assert!(r["state"] == "done" && r["searched"]["folders"].as_u64().unwrap() < 100, "{r}"); +} + +/// Reads every file, and waits on reading one until its channel sends or closes. +struct Blocked(Mutex>); + +impl Visitor for Blocked { + fn wants(&self, _: &str) -> bool { + true + } + fn items(&self, _: &Path) -> Vec { + let _ = self.0.lock().unwrap().recv(); + vec![] + } +} + +#[test] +fn stop_ends_only_the_search_it_names() { + let root = folder("stop"); + std::fs::write(root.join("a.png"), b"png").unwrap(); + let mut s = session_for(&root); + let (tx, rx) = mpsc::channel(); + let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap(); + let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }]; + s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search }); + let until = Instant::now() + Duration::from_secs(10); + while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until { + std::thread::sleep(Duration::from_millis(2)); + } + linkfiles::stop(&mut s, 8); + assert_eq!(s.execute("links.findFiles", &json!({})).unwrap()["state"], "searching"); + linkfiles::stop(&mut s, 7); + let r = s.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!((r["state"].as_str(), r["stopped"].as_str(), r["id"].as_u64()), (Some("stopped"), Some("stop"), Some(7))); + drop(tx); +} + +#[test] +fn relinking_with_copy_puts_the_file_in_the_documents_folder() { + let root = folder("copy"); + let mut s = session_for(&root); + write(&root.join("orig/photo.png"), &png(40, 30, RED)); + let id = placed(&mut s, &root.join("orig/photo.png")); + std::fs::create_dir_all(root.join("doc")).unwrap(); + s.execute("document.save", &json!({"path": root.join("doc/poster.vectorcraft")})).unwrap(); + std::fs::remove_dir_all(root.join("orig")).unwrap(); + let (found, blue) = (root.join("found/photo.png"), png(40, 30, BLUE)); + write(&found, &blue); + let copy = root.join("doc/photo.png"); + let relink = |s: &mut Session, path: &Path| s.execute("links.relink", &json!({"ids": [id], "path": path, "copy": true})); + let r = relink(&mut s, &found).unwrap(); + assert_eq!(r, json!({"relinked": [id], "notFound": [], "copied": [{"from": found, "to": copy}], "kept": []})); + assert_eq!(std::fs::read(©).unwrap(), blue); + let link = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Image(im) => im.link.clone().unwrap(), + k => panic!("not an image: {k:?}"), + }; + assert_eq!((link(&s).path, link(&s).hash), (copy.to_string_lossy().into_owned(), Some(vectorcraft_doc::links::hash_bytes(&blue)))); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Relink"); + // The same file again: the copy is used. + let r = relink(&mut s, &found).unwrap(); + assert_eq!((&r["copied"], &r["kept"]), (&json!([]), &json!([{"from": found, "to": copy}]))); + assert!(!root.join("doc/photo 2.png").exists()); + // Another file of the name: the copy gets a number, and the file there stays as it was. + let (other, red) = (root.join("other/photo.png"), png(50, 30, RED)); + write(&other, &red); + let numbered = root.join("doc/photo 2.png"); + assert_eq!(relink(&mut s, &other).unwrap()["copied"], json!([{"from": other, "to": numbered}])); + assert_eq!((std::fs::read(©).unwrap(), std::fs::read(&numbered).unwrap()), (blue, red)); + // That file again: the numbered copy holds the same contents and is used. + assert_eq!(relink(&mut s, &other).unwrap()["kept"], json!([{"from": other, "to": numbered}])); + assert!(!root.join("doc/photo 3.png").exists()); + // A file in the document's folder is linked where it is. + assert_eq!(relink(&mut s, &numbered).unwrap()["kept"], json!([{"from": numbered, "to": numbered}])); + assert_eq!(link(&s).path, numbered.to_string_lossy()); + // A file that can't be shown isn't copied. + write(&root.join("found/junk.png"), b"not an image"); + assert!(relink(&mut s, &root.join("found/junk.png")).is_err()); + assert!(!root.join("doc/junk.png").exists()); + // Copy takes a file, not a folder, and a saved document. + let r = s.execute("links.relink", &json!({"ids": [id], "folder": root.join("found"), "copy": true})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("needs path")), "{r:?}"); + let mut unsaved = session_for(&root); + let image = placed(&mut unsaved, &found); + let r = unsaved.execute("links.relink", &json!({"ids": [image], "path": found, "copy": true})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("save the document first")), "{r:?}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// Copy takes images only: with copy, a call that includes a placed document fails and copies +/// nothing; without it, the document is relinked where its file is. +#[test] +fn copy_refuses_placed_documents() { + let root = folder("copy-placed"); + let mut s = session_for(&root); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let badge = root.join("badge.vectorcraft"); + s.execute("document.save", &json!({"path": badge})).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = s.execute("file.place", &json!({"path": badge, "link": true})).unwrap()["ids"][0].clone(); + std::fs::create_dir_all(root.join("doc")).unwrap(); + s.execute("document.save", &json!({"path": root.join("doc/poster.vectorcraft")})).unwrap(); + let r = s.execute("links.relink", &json!({"ids": [id], "path": badge, "copy": true})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("images only")), "{r:?}"); + assert!(!root.join("doc/badge.vectorcraft").exists()); + assert_eq!(s.execute("links.relink", &json!({"ids": [id], "path": badge})).unwrap()["relinked"], json!([id])); +} + +/// With automation roots in force, a copy is written only where the write root allows, whichever +/// name it takes: a link of the file's name into the write root doesn't let a numbered copy land +/// beside it. +#[cfg(unix)] +#[test] +fn a_copy_stays_inside_the_write_root() { + let root = folder("roots"); + let mut s = session_for(&root); + write(&root.join("orig/photo.png"), &png(40, 30, RED)); + let id = placed(&mut s, &root.join("orig/photo.png")); + std::fs::create_dir_all(root.join("doc")).unwrap(); + s.execute("document.save", &json!({"path": root.join("doc/poster.vectorcraft")})).unwrap(); + let found = root.join("found/photo.png"); + write(&found, &png(40, 30, BLUE)); + let allowed = root.join("allowed"); + write(&allowed.join("photo.png"), b"kept as it is"); + std::os::unix::fs::symlink(allowed.join("photo.png"), root.join("doc/photo.png")).unwrap(); + let roots = crate::file_access::AutomationRoots::new(Some(&root), Some(&allowed)).unwrap(); + let r = crate::file_access::confine(roots.as_ref(), || s.execute("links.relink", &json!({"ids": [id], "path": found, "copy": true}))); + assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("outside the write root")), "{r:?}"); + assert!(!root.join("doc/photo 2.png").exists()); + assert_eq!(std::fs::read(allowed.join("photo.png")).unwrap(), b"kept as it is"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn junk_search_params_never_fail_internally() { + let root = folder("junk"); + let mut s = session_for(&root); + write(&root.join("orig/a.png"), &png(4, 4, RED)); + let id = placed(&mut s, &root.join("orig/a.png")); + std::fs::remove_file(root.join("orig/a.png")).unwrap(); + let internal = |r: &crate::Result| matches!(r, Err(EngineError::Internal { .. })); + let mut cases = vec![]; + for v in vectorcraft_testkit::strategies::junk_values() { + cases.extend([ + json!({"folder": root, "maxSeconds": v}), + json!({"folder": root, "ids": v}), + json!({"folder": root, "ids": [v]}), + json!({"folder": root, "stop": v}), + json!({"folder": v}), + ]); + for p in [ + json!({"ids": [id], "path": root.join("b.png"), "copy": v}), + json!({"ids": [id], "folder": root, "copy": v}), + json!({"ids": v, "path": root.join("b.png"), "copy": true}), + ] { + let r = s.execute("links.relink", &p); + assert!(!internal(&r), "{p}: {r:?}"); + } + } + cases.extend([ + json!({"folder": "/a\u{0}b"}), + json!({"folder": "relative/folder"}), + json!({"folder": "/\u{FFFD}"}), + json!({"folder": format!("/{}", "x".repeat(4096))}), + json!({"folder": root.join("No Such Folder")}), + json!({"folder": root, "ids": [-1]}), + json!({"folder": root, "ids": [id, id]}), + ]); + let mut started = 0; + for p in &cases { + let r = s.execute("links.findFiles", p); + assert!(!internal(&r), "{p}: {r:?}"); + if r.is_ok() && p.get("folder").is_some_and(Value::is_string) { + started += 1; + assert_ne!(finished(&mut s)["state"], "searching", "{p}"); + } + } + assert!(started > 30, "only {started} searches started"); +} diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs new file mode 100644 index 00000000..e8c4c56f --- /dev/null +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -0,0 +1,715 @@ +//! Find Linked Files: the files of a document's missing linked images and placed documents (from +//! the Links panel, of the selected ones whatever their status, when any are selected), looked for +//! in a folder the user picks and its subfolders (`links.findFiles`, on separate threads). Each +//! file it looks for lists the files found with its name, best first, with Don't Relink: a file is +//! chosen at the start only when it is the only file found, or the only one that is the same file +//! (the size and hash saved with the link). Relink relinks each choice (`links.relink`, one undo +//! step each); with Copy into the document's folder (a saved document), an image's file is copied +//! there first, never replacing a file, and a placed document is relinked where its file is. Opened +//! from Find in Folder… in Linked File Not Found (dialog `missingLinks`), the files left without a +//! choice and those that fail to relink go back to that dialog; opened from the Links panel's Find +//! in Folder… (`ui.findLinksInFolder`), they stay as they are. +//! +//! Fields: `document` (the document's uid), `missing` (`[{name, path, ids}]`), `modified` (the ids +//! of the images whose file was modified, offered for update afterwards), `back` (the files left +//! without a choice and those that fail to relink go back to dialog `missingLinks`), `state` +//! (empty, `searching`, `done`, `stopped` or `failed`), `search` (the search's `id`), `folder`, +//! `found` (`links.findFiles`'s `links`, with the files found), `searched`, `skipped`, +//! `unreadable`, `stopped`, `error`, `picks` (the file chosen for each row of `missing`, or null), +//! `touched` (the rows whose file was chosen in the dialog: new search results leave their pick as +//! it is), `copy` and `searchFolder` (set it and confirm to search that folder). + +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::picks::PickRequest; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Find Linked Files. +pub const KIND: &str = "findLinks"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Find Linked Files").into(), + body, + confirm, + ok: Some("Relink"), + min_width: 440.0, + max_width: Some(560.0), + ..DialogSpec::FORM +}; + +/// The rows of field `key` of `d` (`missing`, `modified`, `picks`…). +fn rows(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() +} + +/// The ids in `rows` (`[{ids}]`). +fn ids(rows: &[Value]) -> Vec { + rows.iter().flat_map(|r| r["ids"].as_array().cloned().unwrap_or_default()).collect() +} + +/// A Find Linked Files dialog for the `missing` files of the active document. +fn dialog(app: &VectorcraftApp, missing: Vec, modified: Vec, back: bool) -> Result { + let uid = app.session.active().map(|d| d.uid).ok_or("no document open")?; + let picks = vec![Value::Null; missing.len()]; + let fields = + json!({ "document": uid, "missing": missing, "modified": modified, "back": back, "state": "", "picks": picks, "touched": [], "copy": false }); + Ok(Dialog::new(KIND, fields)) +} + +/// Linked File Not Found's Find in Folder… (`d`, the `missingLinks` dialog): `d` becomes Find +/// Linked Files, searching `folder` for every file `d` still asks about. +pub(super) fn from_missing_links(app: &mut VectorcraftApp, d: &mut Dialog, folder: &str) -> Result<(), String> { + let mut find = dialog(app, rows(d, "missing"), rows(d, "modified"), true)?; + start_search(app, &mut find, folder)?; + *d = find; + Ok(()) +} + +/// The selected linked images and placed documents of the active document. +fn selected_linked(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let linked = |id: &vectorcraft_doc::NodeId| { + st.doc.node(*id).is_some_and(|n| match &n.kind { + vectorcraft_doc::NodeKind::Image(im) => im.link.is_some(), + vectorcraft_doc::NodeKind::PlacedDocument(_) => true, + _ => false, + }) + }; + st.selection.objects.iter().filter(|id| linked(id)).map(|id| json!(id.0)).collect() +} + +/// `ui.findLinksInFolder` (the Links panel's Find in Folder…): ask for a folder (or take `folder`), +/// then open Find Linked Files searching it for the files of linked objects `ids` (default: the +/// selected linked images and placed documents, else the files that are missing). +pub(crate) fn find_in_folder_command(app: &mut VectorcraftApp, folder: Option, ids: Option>) -> Result { + let ids = ids.filter(|ids| !ids.is_empty()).or_else(|| Some(selected_linked(app)).filter(|ids| !ids.is_empty())); + let all = ids.is_some(); + let r = app.session.execute("links.check", &json!({ "ids": ids })).map_err(|e| e.to_string())?; + let missing: Vec = r["links"] + .as_array() + .into_iter() + .flatten() + .filter(|l| all || l["status"] == "missing") + .map(|l| json!({ "name": l["name"], "path": l["path"], "ids": l["ids"] })) + .collect(); + // Nothing to look for: no folder is asked for. + if missing.is_empty() { + return Err("No linked files to look for".into()); + } + let folder = match folder { + Some(f) => f, + None => crate::picks::folder(app).ok_or("cancelled")?, + }; + let mut d = dialog(app, missing, vec![], false)?; + start_search(app, &mut d, &folder)?; + app.ui.dialog = Some(d); + Ok(Value::Null) +} + +/// Whether object `id` of the active document is an image (and not a placed document). +fn is_image(app: &VectorcraftApp, id: &Value) -> bool { + let Some(id) = id.as_u64() else { return false }; + let st = app.session.active(); + st.and_then(|st| st.doc.node(vectorcraft_doc::NodeId(id))).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) +} + +/// Make the dialog's document the active one: it may be another tab's. +fn activate(app: &mut VectorcraftApp, d: &Dialog) -> Result<(), String> { + let uid = d.fields.get("document").and_then(Value::as_u64); + if app.session.active().map(|st| st.uid) == uid { + return Ok(()); + } + let index = app.session.documents().iter().position(|doc| Some(doc.uid) == uid).ok_or("that document is no longer open")?; + app.run("document.activate", json!({ "index": index })).map(|_| ()) +} + +/// Start looking in `folder` for the files of the dialog's rows; what an earlier search found, and +/// the files chosen from it, are dropped. +fn start_search(app: &mut VectorcraftApp, d: &mut Dialog, folder: &str) -> Result<(), String> { + activate(app, d)?; + let r = app.run("links.findFiles", json!({ "folder": folder, "ids": ids(&rows(d, "missing")) }))?; + for k in ["found", "searched", "skipped", "unreadable", "stopped", "error"] { + d.fields.remove(k); + } + d.fields.insert("picks".into(), json!(vec![Value::Null; rows(d, "missing").len()])); + d.fields.insert("touched".into(), json!([])); + d.fields.insert("search".into(), r["id"].clone()); + d.fields.insert("folder".into(), json!(folder)); + update(d, &r); + app.ui.links_search = r["id"].as_u64(); + Ok(()) +} + +/// The files a search found for `row` (`{path, ids}`), best first: the answer's `links` row with +/// the same path and ids. +fn files_for<'a>(found: &'a [Value], row: &Value) -> &'a [Value] { + found.iter().find(|r| r["path"] == row["path"] && r["ids"] == row["ids"]).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice) +} + +/// The file chosen at the start among `files`: the only one that is the same file, else the only +/// file found. +fn first_pick(files: &[Value]) -> Option<&Value> { + let mut same = files.iter().filter(|f| f["match"] == "same"); + match (same.next(), same.next(), files) { + (Some(f), None, _) | (None, _, [f]) => Some(&f["path"]), + _ => None, + } +} + +/// A search's answer `r` into the dialog. A row whose file wasn't chosen in the dialog takes the +/// file chosen at the start ([`first_pick`]). +fn update(d: &mut Dialog, r: &Value) { + let missing = rows(d, "missing"); + let touched = rows(d, "touched"); + let mut picks = rows(d, "picks"); + picks.resize(missing.len(), Value::Null); + let found = r["links"].as_array().map_or(&[][..], Vec::as_slice); + for (i, (row, pick)) in missing.iter().zip(picks.iter_mut()).enumerate() { + if !touched.contains(&json!(i)) { + *pick = first_pick(files_for(found, row)).cloned().unwrap_or(Value::Null); + } + } + d.fields.insert("picks".into(), json!(picks)); + d.fields.insert("found".into(), r["links"].clone()); + for k in ["state", "searched", "skipped", "unreadable", "stopped", "error"] { + match r.get(k) { + Some(v) => d.fields.insert(k.into(), v.clone()), + None => d.fields.remove(k), + }; + } +} + +/// While searching: the search's progress into the dialog. A search started elsewhere since +/// stopped this one. +fn follow(app: &mut VectorcraftApp, d: &mut Dialog) { + // Polled through `session.execute`. + let Ok(r) = app.session.execute("links.findFiles", &json!({})) else { return }; + if d.fields.get("search") != Some(&r["id"]) { + d.fields.insert("state".into(), json!("stopped")); + return; + } + update(d, &r); + if let Some(e) = r["error"].as_str() { + app.status(e); + } +} + +/// Stop the search the dialog started once the dialog is gone (closed, or another in its place). +pub(super) fn stop_when_closed(app: &mut VectorcraftApp) { + let Some(id) = app.ui.links_search else { return }; + let open = app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND && d.fields.get("search").and_then(Value::as_u64) == Some(id)); + if !open { + app.ui.links_search = None; + vectorcraft_engine::cmd::linkfiles::stop(&mut app.session, id); + } +} + +/// The folder of the dialog's document, when it was saved. +fn document_folder(app: &VectorcraftApp, d: &Dialog) -> Option { + let uid = d.fields.get("document").and_then(Value::as_u64)?; + let path = app.session.documents().iter().find(|doc| doc.uid == uid)?.path.clone()?; + std::path::Path::new(&path).parent().map(|p| p.to_string_lossy().into_owned()) +} + +/// A button of the dialog's. +enum Act { + Pick, + Stop, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + if d.str("state") == "searching" { + follow(app, d); + ui.ctx().request_repaint_after(Duration::from_millis(100)); + } + let state = d.str("state"); + let searching = state == "searching"; + widgets::dim_label(ui, tl!("Choose the file to relink each linked file to. Files with the size and contents saved with the link come first.")); + ui.add_space(8.0); + let missing = rows(d, "missing"); + let found = rows(d, "found"); + let mut picks = rows(d, "picks"); + picks.resize(missing.len(), Value::Null); + let mut touched = rows(d, "touched"); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + for (i, (row, pick)) in missing.iter().zip(picks.iter_mut()).enumerate() { + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + ui.label(egui::RichText::new(row["name"].as_str().unwrap_or_default()).font(theme::semibold(12.0)).color(t.text_strong)); + let path = row["path"].as_str().unwrap_or_default(); + ui.add(egui::Label::new(egui::RichText::new(path).size(11.0).color(t.text_dim)).truncate()).on_hover_text(path); + let files = files_for(&found, row); + let mut chose = None; + for f in files { + let file = f["path"].as_str().unwrap_or_default(); + let p = std::path::Path::new(file); + ui.horizontal(|ui| { + let name = p.file_name().map_or_else(|| file.to_string(), |n| n.to_string_lossy().into_owned()); + if widgets::radio(ui, &name, *pick == f["path"], true) { + chose = Some(f["path"].clone()); + } + let tag = match f["match"].as_str() { + Some("same") => tl!("Same file"), + Some("size") => tl!("Same size"), + _ => "", + }; + if !tag.is_empty() { + ui.label(egui::RichText::new(tag).size(11.0).color(t.accent)); + } + }); + let folder = p.parent().map_or_else(String::new, |f| f.to_string_lossy().into_owned()); + ui.add(egui::Label::new(egui::RichText::new(folder).size(11.0).color(t.text_dim)).truncate()).on_hover_text(file); + } + if !files.is_empty() && widgets::radio(ui, tl!("Don't Relink"), pick.is_null(), true) { + chose = Some(Value::Null); + } + if let Some(c) = chose { + *pick = c; + if !touched.contains(&json!(i)) { + touched.push(json!(i)); + } + } + if files.is_empty() && !state.is_empty() { + widgets::dim_label(ui, if searching { tl!("Searching…") } else { tl!("Not found") }); + } + ui.add_space(3.0); + }); + }); + } + }); + }); + d.fields.insert("picks".into(), json!(picks)); + d.fields.insert("touched".into(), json!(touched)); + ui.add_space(10.0); + let mut act = None; + ui.horizontal(|ui| { + if crate::picks::can(app, &PickRequest::Folder) && ui.add_enabled(!searching, egui::Button::new(tl!("Find in Folder…"))).clicked() { + act = Some(Act::Pick); + } + if searching { + if ui.button(tl!("Stop")).clicked() { + act = Some(Act::Stop); + } + ui.add(egui::Spinner::new().size(12.0).color(t.accent)); + } + }); + super::missing_fonts::progress(ui, d); + if let Some(folder) = document_folder(app, d) { + ui.add_space(8.0); + let copy = d.bool("copy"); + if widgets::check_tip(ui, tl!("Copy into the document's folder"), copy, true, &folder) { + d.fields.insert("copy".into(), json!(!copy)); + } + } + let r = match act { + Some(Act::Pick) => crate::picks::in_dialog(app, d, |app, d| match crate::picks::folder(app) { + Some(folder) => start_search(app, d, &folder), + None => Ok(()), + }), + Some(Act::Stop) => app.run("links.findFiles", json!({ "stop": true })).map(|_| follow(app, d)), + None => Ok(()), + }; + if let Err(e) = r { + app.status(e); + } + false +} + +/// With `searchFolder` (agents): search that folder, and stay open. Otherwise Relink: each row's +/// file, one `links.relink` each (copied into the document's folder with `copy`). The status line +/// names the first file that fails to relink, and the other files still relink. From Linked File +/// Not Found, the rows left without a file and the rows that failed to relink go back to it, then +/// the modified files are offered for update. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let folder = d.str("searchFolder"); + if !folder.is_empty() { + let mut d = d.clone(); + d.fields.remove("searchFolder"); + // The folder is searched once: a search that doesn't start leaves the dialog without it. + if let Some(open) = app.ui.dialog.as_mut().filter(|open| open.kind == KIND) { + open.fields.remove("searchFolder"); + } + start_search(app, &mut d, &folder)?; + let state = d.fields.get("state").cloned().unwrap_or(Value::Null); + app.ui.dialog = Some(d); + return Ok(json!({ "state": state })); + } + activate(app, d)?; + let picks = rows(d, "picks"); + let (mut relinked, mut failed, mut rest) = (vec![], vec![], vec![]); + for (i, row) in rows(d, "missing").into_iter().enumerate() { + let Some(path) = picks.get(i).and_then(Value::as_str) else { + rest.push(row); + continue; + }; + let mut params = json!({ "ids": row["ids"], "path": path }); + // A placed document is relinked where its file is. + if d.bool("copy") && row["ids"].as_array().is_some_and(|ids| ids.iter().all(|id| is_image(app, id))) { + params["copy"] = json!(true); + } + match app.run("links.relink", params) { + Ok(r) => relinked.extend(r["relinked"].as_array().cloned().unwrap_or_default()), + Err(e) => { + failed.push(json!({ "name": row["name"], "error": e })); + rest.push(row); + } + } + } + app.ui.dialog = None; + let n = relinked.len(); + match failed.first() { + Some(f) => { + let (name, reason) = (f["name"].as_str().unwrap_or_default(), f["error"].as_str().unwrap_or_default()); + app.status(format!("Relinked {n} image(s); {name} couldn't be relinked: {reason}")); + } + None if n > 0 => app.status(format!("Relinked {n} image(s)")), + None => {} + } + if d.bool("back") { + super::missing_links::ask_next(app, rest, rows(d, "modified")); + } + Ok(json!({ "relinked": relinked, "failed": failed })) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::Instant; + + use vectorcraft_engine::Session; + use vectorcraft_engine::cmd::findfiles::Rules; + + use super::*; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + /// A fresh temporary folder (canonical, as a search reports it). + fn folder(tag: &str) -> PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("find-links-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) + } + + /// An app that reads files, shows a folder picker and whose searches may go anywhere in `root`, + /// on two threads. + fn app_for(root: &Path) -> VectorcraftApp { + 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.services.pick_folder = Some(Box::new(|| None)); + app.session.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); + app.session.search_threads = Some(2); + app + } + + fn write(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + /// A PNG of one color, a different one for each `n`. + fn png(n: u8) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(40, 30, image::Rgba([200, n.wrapping_mul(40), 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// A document linking to files `names` in `root/orig`, saved as `root/doc/doc.vectorcraft`; the + /// files are then deleted and the document opened again → each file's bytes. + fn opened_without(app: &mut VectorcraftApp, root: &Path, names: &[&str]) -> Vec> { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut files = vec![]; + for (n, name) in (0..).zip(names) { + let path = root.join("orig").join(name); + write(&path, &png(n)); + app.run("file.place", json!({"path": path})).unwrap(); + files.push(png(n)); + } + let doc = root.join("doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + std::fs::remove_dir_all(root.join("orig")).unwrap(); + crate::io::open_path(app, &doc.to_string_lossy()).unwrap(); + files + } + + /// Draw the dialog until its search ends (10 seconds at most). + fn searched(app: &mut VectorcraftApp) { + let until = Instant::now() + Duration::from_secs(10); + while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { + frame(app); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) + } + + /// The pick of the row named `name`. + fn pick<'a>(d: &'a Dialog, name: &str) -> &'a Value { + let i = d.fields["missing"].as_array().unwrap().iter().position(|r| r["name"] == name).unwrap(); + &d.fields["picks"][i] + } + + /// Find in Folder… in Linked File Not Found: each missing file's same file is chosen, and Relink + /// relinks them. + #[test] + fn find_in_folder_relinks_the_missing_files_to_the_files_found() { + let root = folder("relink"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png", "b.png"]); + assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); + assert!(frame(&mut app).contains("Find in Folder…")); + let (a, b) = (root.join("found/sub/a.png"), root.join("found/b.png")); + write(&a, &bytes[0]); + write(&b, &bytes[1]); + write(&root.join("found/other/b.png"), b"another file"); + // What Find in Folder… does with the folder picked. + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(root.join("found"))); + let r = super::super::confirm(&mut app).unwrap(); + assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); + assert_eq!(kind(&app), Some(KIND)); + searched(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("state"), pick(d, "a.png"), pick(d, "b.png")), ("done".to_string(), &json!(a), &json!(b))); + let text = frame(&mut app); + for label in + ["Find Linked Files", "a.png", "b.png", "Same file", "Don't Relink", "Find in Folder…", "Copy into the document's folder", "Relink"] + { + assert!(text.contains(label), "{label} in {text}"); + } + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "Relinked 2 image(s)"); + assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 0); + } + + /// The files left without a file, or whose file fails to relink, go back to Linked File Not + /// Found; the failure is named in the status line, and the other files still relink. + #[test] + fn files_not_relinked_go_back_to_linked_file_not_found() { + let root = folder("back"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png", "b.png", "c.png"]); + write(&root.join("found/a.png"), &bytes[0]); + write(&root.join("found/b.png"), b"not an image"); + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(root.join("found"))); + super::super::confirm(&mut app).unwrap(); + searched(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + // b.png's only file is chosen, c.png has none. + assert_eq!((pick(d, "b.png"), pick(d, "c.png")), (&json!(root.join("found/b.png")), &Value::Null)); + assert!(frame(&mut app).contains("Not found")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.status.starts_with("Relinked 1 image(s); b.png couldn't be relinked: "), "{}", app.ui.status); + let d = app.ui.dialog.as_ref().expect("Linked File Not Found again"); + let names: Vec<&str> = d.fields["missing"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap()).collect(); + assert_eq!((d.kind.as_str(), names.len()), (super::super::missing_links::KIND, 2)); + assert!(names.contains(&"b.png") && names.contains(&"c.png"), "{names:?}"); + super::super::cancel(&mut app); + assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 2); + } + + /// A file is chosen when the choice is clear: the only same file, else the only file. A row the + /// user chose for keeps its choice as the search goes on. + #[test] + fn a_file_is_chosen_only_when_the_choice_is_clear() { + let row = |p: &str| json!({"name": "a.png", "path": p, "ids": [1]}); + let mut d = Dialog::new(KIND, json!({"missing": [row("/x/a.png"), row("/y/a.png"), row("/z/a.png")], "picks": [], "touched": []})); + let file = |p: &str, m: &str| json!({"path": p, "match": m}); + let answer = |y: Vec| { + json!({"state": "searching", "links": [ + {"path": "/x/a.png", "ids": [1], "files": [file("/f/a.png", "same"), file("/g/a.png", "name")]}, + {"path": "/y/a.png", "ids": [1], "files": y}, + {"path": "/z/a.png", "ids": [1], "files": [file("/f/a.png", "size"), file("/g/a.png", "name")]}, + ]}) + }; + update(&mut d, &answer(vec![file("/f/a.png", "name")])); + assert_eq!(d.fields["picks"], json!(["/f/a.png", "/f/a.png", null])); + // Don't Relink chosen for the first row: the next answer leaves it, and the second row has + // two files now. + d.fields.insert("picks".into(), json!([null, "/f/a.png", null])); + d.fields.insert("touched".into(), json!([0])); + update(&mut d, &answer(vec![file("/f/a.png", "name"), file("/g/a.png", "name")])); + assert_eq!(d.fields["picks"], json!([null, null, null])); + } + + /// A search of another folder starts the choices over: a file chosen from the last folder's + /// results isn't kept. + #[test] + fn another_folder_starts_the_choices_over() { + let root = folder("again"); + let mut app = app_for(&root); + opened_without(&mut app, &root, &["a.png"]); + super::super::cancel(&mut app); + // Two files of the name and neither the same file: the search reads both folders. + write(&root.join("one/a.png"), b"one file"); + write(&root.join("one/x/a.png"), b"another file"); + std::fs::create_dir_all(root.join("two")).unwrap(); + app.run("ui.findLinksInFolder", json!({"folder": root.join("one")})).unwrap(); + searched(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["found"][0]["files"].as_array().map(Vec::len), Some(2)); + // The second file chosen, then another folder searched, which has no file of the name. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("picks".into(), json!([root.join("one/x/a.png")])); + d.fields.insert("touched".into(), json!([0])); + d.fields.insert("searchFolder".into(), json!(root.join("two"))); + super::super::confirm(&mut app).unwrap(); + searched(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((pick(d, "a.png"), &d.fields["touched"]), (&Value::Null, &json!([]))); + } + + #[test] + fn cancel_closes_the_dialog_and_stops_its_search() { + let root = folder("cancel"); + // Enough folders that the search usually still runs when the dialog closes. + for a in 0..40 { + for b in 0..40 { + std::fs::create_dir_all(root.join(format!("tree/f{a}/g{b}"))).unwrap(); + } + } + let mut app = app_for(&root); + app.session.search_threads = Some(1); + opened_without(&mut app, &root, &["a.png"]); + super::super::cancel(&mut app); + app.run("ui.findLinksInFolder", json!({"folder": root.join("tree")})).unwrap(); + let id = app.ui.links_search.expect("the dialog's search"); + assert_eq!((kind(&app), app.ui.dialog.as_ref().unwrap().bool("back")), (Some(KIND), false)); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.links_search.is_none()); + // Stopped by the close, unless the walker finished the folders first. + let r = app.session.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(id), "{r}"); + match r["state"].as_str() { + Some("stopped") => assert_eq!(r["stopped"], "stop", "{r}"), + state => assert_eq!(state, Some("done"), "{r}"), + } + } + + /// Copy into the document's folder, for a saved document: the file is copied there first. + #[test] + fn copy_into_the_documents_folder_copies_before_relinking() { + let root = folder("copy"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png"]); + super::super::cancel(&mut app); + write(&root.join("found/a.png"), &bytes[0]); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); + searched(&mut app); + assert!(frame(&mut app).contains("Copy into the document's folder")); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().unwrap().len(), app.ui.dialog.is_none()), (1, true), "{r}"); + let copy = root.join("doc/a.png"); + assert_eq!(std::fs::read(©).unwrap(), bytes[0]); + let c = app.run("links.check", json!({})).unwrap(); + assert_eq!((c["missing"].as_u64(), c["links"][0]["path"].as_str()), (Some(0), Some(&*copy.to_string_lossy())), "{c}"); + // An unsaved document has no folder to copy into. + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("file.place", json!({"path": root.join("found/a.png")})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); + searched(&mut app); + assert!(!frame(&mut app).contains("Copy into the document's folder")); + } + + /// With Copy into the document's folder, a placed document is relinked where its file is. + #[test] + fn copy_leaves_placed_documents_where_they_are() { + let root = folder("placed"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let badge = root.join("orig/badge.vectorcraft"); + std::fs::create_dir_all(badge.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": badge})).unwrap(); + let found = root.join("found/badge.vectorcraft"); + write(&found, &std::fs::read(&badge).unwrap()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("file.place", json!({"path": badge, "link": true})).unwrap(); + let doc = root.join("doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + std::fs::remove_dir_all(root.join("orig")).unwrap(); + crate::io::open_path(&mut app, &doc.to_string_lossy()).unwrap(); + super::super::cancel(&mut app); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); + searched(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().unwrap().len(), &r["failed"]), (1, &json!([])), "{r}"); + assert!(!root.join("doc/badge.vectorcraft").exists()); + assert_eq!(app.run("links.check", json!({})).unwrap()["links"][0]["path"], json!(found)); + } + + /// The Links panel's Find in Folder… (`ui.findLinksInFolder`): the selected linked files, else + /// the missing ones, in a folder the user picks. + #[test] + fn the_links_panel_looks_for_the_selected_or_the_missing_files() { + let root = folder("panel"); + let mut app = app_for(&root); + app.services.pick_folder = None; + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.findLinksInFolder"), "no folder picker (the web)"); + let picked = root.join("found").to_string_lossy().into_owned(); + let answer = picked.clone(); + app.services.pick_folder = Some(Box::new(move || Some(answer.clone()))); + assert!(crate::menus::enabled(&app, "ui.findLinksInFolder")); + // Nothing to look for: no folder is asked for. + assert_eq!(app.run("ui.findLinksInFolder", json!({})).err().as_deref(), Some("No linked files to look for")); + let here = root.join("found/here.png"); + write(&here, &png(1)); + let id = app.run("file.place", json!({"path": here})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let menu = crate::tests_labels::painted_text(&mut app, crate::panels::links::menu); + assert!(menu.contains("Find in Folder…"), "{menu}"); + app.run("ui.findLinksInFolder", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["missing"][0]["name"].as_str(), d.str("folder")), (KIND, Some("here.png"), picked)); + // here.png isn't missing: the dialog asks about linked files. + assert!(frame(&mut app).contains("Choose the file to relink each linked file to.")); + searched(&mut app); + assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); + } + + /// The Links panel's Find in Folder… asks for the folder off the UI thread (#867): Find Linked + /// Files opens on the folder when the folder picker answers. + #[test] + fn find_in_folder_asks_for_the_folder_off_the_ui_thread() { + let root = folder("later"); + let mut app = app_for(&root); + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services); + 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(); + let here = root.join("found/here.png"); + write(&here, &png(1)); + let id = app.run("file.place", json!({"path": here})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + // Nothing opens until the picker answers. + assert!(app.run("ui.findLinksInFolder", json!({})).is_err()); + assert_eq!((shown.requests(), kind(&app)), (vec![PickRequest::Folder], None)); + let found = root.join("found").to_string_lossy().into_owned(); + shown.answer(&[found.as_str()]); + crate::picks::poll(&mut app, &egui::Context::default()); + let d = app.ui.dialog.as_ref().expect("Find Linked Files"); + assert_eq!((d.kind.as_str(), d.str("folder")), (KIND, found)); + searched(&mut app); + assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); + } +} diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs index 4eed25db..3ac57ea4 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -292,27 +292,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add(egui::Spinner::new().size(12.0).color(t.accent)); } }); - if searching { - widgets::dim_name(ui, &d.str("folder")); - } - if let Some(s) = d.fields.get("searched") { - let (folders, files) = (s["folders"].to_string(), s["files"].to_string()); - widgets::dim_name( - ui, - &crate::i18n::fmt(tl!("{folders} folder(s) and {files} file(s) searched"), &[("folders", &folders), ("files", &files)]), - ); - } - let count = |k: &str| d.fields.get(k).and_then(Value::as_u64).filter(|n| *n > 0).map(|n| n.to_string()); - if let Some(n) = count("skipped") { - let text = tl!("{count} folder(s) skipped, such as other apps' and the system's folders and hidden ones"); - widgets::dim_name(ui, &crate::i18n::fmt(text, &[("count", &n)])); - } - if let Some(n) = count("unreadable") { - widgets::dim_name(ui, &crate::i18n::fmt(tl!("{count} folder(s) or file(s) couldn't be read"), &[("count", &n)])); - } - if matches!(d.fields.get("stopped").and_then(Value::as_str), Some("time" | "limit")) { - widgets::dim_label(ui, tl!("The search stopped at its limit: search a smaller folder.")); - } + progress(ui, d); // Once files are found: what copying them means, and Add Fonts. if found.iter().any(|f| f["files"].as_array().is_some_and(|files| !files.is_empty())) { ui.add_space(8.0); @@ -345,6 +325,32 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// A folder search's progress under the dialog's buttons (fields `state`, `folder`, `searched`, +/// `skipped`, `unreadable` and `stopped`), in Find Linked Files too. +pub(super) fn progress(ui: &mut egui::Ui, d: &Dialog) { + if d.str("state") == "searching" { + widgets::dim_name(ui, &d.str("folder")); + } + if let Some(s) = d.fields.get("searched") { + let (folders, files) = (s["folders"].to_string(), s["files"].to_string()); + widgets::dim_name( + ui, + &crate::i18n::fmt(tl!("{folders} folder(s) and {files} file(s) searched"), &[("folders", &folders), ("files", &files)]), + ); + } + let count = |k: &str| d.fields.get(k).and_then(Value::as_u64).filter(|n| *n > 0).map(|n| n.to_string()); + if let Some(n) = count("skipped") { + let text = tl!("{count} folder(s) skipped, such as other apps' and the system's folders and hidden ones"); + widgets::dim_name(ui, &crate::i18n::fmt(text, &[("count", &n)])); + } + if let Some(n) = count("unreadable") { + widgets::dim_name(ui, &crate::i18n::fmt(tl!("{count} folder(s) or file(s) couldn't be read"), &[("count", &n)])); + } + if matches!(d.fields.get("stopped").and_then(Value::as_str), Some("time" | "limit")) { + widgets::dim_label(ui, tl!("The search stopped at its limit: search a smaller folder.")); + } +} + /// Find Fonts (`discard`): Type › Find Font on the first font the document misses, to replace it; /// this Missing Fonts dialog doesn't come back. Otherwise (agents), with `searchFolder`: search that /// folder, and stay open; else Add Fonts. diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index c96583cf..690ef5ac 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -1,13 +1,15 @@ //! The linked files a document opened without, one at a time: Replace picks another file //! (`links.relink`), and with Apply to All the other missing files are looked for by name in its //! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the -//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are -//! offered for update (`links.update`). Files another app changes while the document is open are -//! offered the same way ([`ask_update_changed`], from [`vectorcraft_engine::link_watch`]). +//! rest. Find in Folder… looks for every missing file in a folder and its subfolders (dialog +//! `findLinks`, [`super::find_links`]). Afterwards, with Preferences › Update Links: Ask When +//! Modified, the modified links are offered for update (`links.update`). Files another app changes +//! while the document is open are offered the same way ([`ask_update_changed`], from +//! [`vectorcraft_engine::link_watch`]). //! //! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of -//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty) -//! and `discard` (Ignore). +//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty), +//! `discard` (Ignore) and `searchFolder` (set it and confirm for Find in Folder… on that folder). use serde_json::{Value, json}; @@ -59,6 +61,17 @@ fn open(app: &mut VectorcraftApp, missing: Vec, modified: Vec) { app.ui.dialog = Some(Dialog::new(KIND, json!({ "missing": missing, "modified": modified, "applyToAll": false, "path": "" }))); } +/// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for +/// update. +pub(super) fn ask_next(app: &mut VectorcraftApp, rest: Vec, modified: Vec) { + if rest.is_empty() { + app.ui.dialog = None; + ask_update(app, modified); + } else { + open(app, rest, modified); + } +} + /// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again. fn ask_update(app: &mut VectorcraftApp, ids: Vec) { ask(app, ids, tl!("{count} linked image(s) changed since this document was saved. Show the new versions?")); @@ -83,7 +96,7 @@ fn ask(app: &mut VectorcraftApp, ids: Vec, question: &str) { super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids })); } -fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); let Some(current) = missing.first() else { return true }; @@ -104,6 +117,18 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); ui.add_space(8.0); widgets::dim_label(ui, tl!("Replace it with another file, or ignore it: its images keep showing the preview saved with the document.")); + if crate::picks::can(app, &crate::picks::PickRequest::Folder) { + ui.add_space(8.0); + if ui.button(tl!("Find in Folder…")).clicked() { + let r = crate::picks::in_dialog(app, d, |app, d| match crate::picks::folder(app) { + Some(folder) => super::find_links::from_missing_links(app, d, &folder), + None => Ok(()), + }); + if let Err(e) = r { + app.status(e); + } + } + } if rest > 0 { ui.add_space(10.0); let all = d.bool("applyToAll"); @@ -116,8 +141,20 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } /// Replace (with `path`, else a picked file) or Ignore (`discard`) the first missing file, then -/// ask about the next. +/// ask about the next. With `searchFolder`: Find in Folder… on that folder. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let folder = d.str("searchFolder"); + if !folder.is_empty() { + let mut d = d.clone(); + // The folder is searched once: a search that doesn't start leaves the dialog without it. + if let Some(open) = app.ui.dialog.as_mut().filter(|open| open.kind == KIND) { + open.fields.remove("searchFolder"); + } + super::find_links::from_missing_links(app, &mut d, &folder)?; + let state = d.fields.get("state").cloned().unwrap_or(Value::Null); + app.ui.dialog = Some(d); + return Ok(json!({ "state": state })); + } let missing = d.fields.get("missing").and_then(Value::as_array).cloned().unwrap_or_default(); let modified = d.fields.get("modified").and_then(Value::as_array).cloned().unwrap_or_default(); let all = d.bool("applyToAll"); @@ -148,12 +185,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { rest.retain(|row| row["ids"].as_array().is_none_or(|ids| ids.iter().any(|id| !done.contains(id)))); } } - if rest.is_empty() { - app.ui.dialog = None; - ask_update(app, modified); - } else { - open(app, rest, modified); - } + ask_next(app, rest, modified); Ok(out) } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 212668fb..7ff56acb 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -29,6 +29,7 @@ mod export_as; mod export_for_screens; pub mod eyedropper; pub mod file_info; +pub mod find_links; pub mod flare_options; pub mod flatten; pub mod flattener_presets; @@ -261,6 +262,7 @@ registry! { RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC, MissingLinks: [missing_links::KIND] => missing_links::SPEC, MissingFonts: [missing_fonts::KIND] => missing_fonts::SPEC, + FindLinks: [find_links::KIND] => find_links::SPEC, TextImport: [text_import::KIND] => text_import::SPEC, PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC, PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC, @@ -346,11 +348,13 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { } /// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits -/// for its turn again, the search a Missing Fonts dialog started stops once that dialog is gone, -/// and with no dialog open the next document's Missing Fonts dialog opens (one dialog at a time). +/// for its turn again, the search a Missing Fonts or Find Linked Files dialog started stops once +/// that dialog is gone, and with no dialog open the next document's Missing Fonts dialog opens +/// (one dialog at a time). pub(crate) fn settle(app: &mut VectorcraftApp) { missing_fonts::requeue_replaced(app); missing_fonts::stop_when_closed(app); + find_links::stop_when_closed(app); missing_fonts::open_next(app); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 58660136..730a74dc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1460,6 +1460,13 @@ lighting Light Světlo Relinked {n} image(s); not in that folder: {} Znovu propojeno obrázků: {n}; nejsou v dané složce: {} Relinked {} image(s) Znovu propojeno obrázků: {} Relink… Znovu propojit… + Find Linked Files Najít propojené soubory + Find Linked Files in Folder… Najít propojené soubory ve složce… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Pro každý propojený soubor vyberte soubor, ke kterému jej chcete znovu propojit. Soubory s velikostí a obsahem uloženými s propojením jsou uvedeny jako první. + Same file Stejný soubor + Same size Stejná velikost + Don't Relink Znovu nepropojovat + Copy into the document's folder Kopírovat do složky dokumentu Select an image to see its Link Info. Vyberte obrázek, abyste viděli informace o propojení. Show Embedded Zobrazit vložené Show Link Info Zobrazit informace o propojení @@ -4141,6 +4148,9 @@ effect Regular Pravidelný @msg larger than 256 MB větší než 256 MB @msg the files come to more than 1 GB: add fewer at a time soubory mají dohromady více než 1 GB: přidávejte jich méně najednou @msg every numbered name for it is taken in the Fonts folder ve složce Fonts jsou obsazena všechna číslovaná jména pro tento soubor +@msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} +@msg No linked files to look for Žádné propojené soubory k vyhledání +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Znovu propojeno obrázků: {n}; nepodařilo se znovu propojit {name}: {reason} @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `corners` must be an array of anchor indices, not {v} `corners` musí být pole indexů kotevních bodů, ne {v} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9b1025ab..ffa02707 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2181,6 +2181,13 @@ Relinked {n} image(s); not in that folder: {} {n} Bild(er) neu verknüpft; nicht in diesem Ordner: {} Relinked {} image(s) {} Bild(er) neu verknüpft Relink… Neu verknüpfen… + Find Linked Files Verknüpfte Dateien suchen + Find Linked Files in Folder… Verknüpfte Dateien in Ordner suchen… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Wählen Sie für jede verknüpfte Datei die Datei aus, mit der sie neu verknüpft werden soll. Dateien mit der Größe und dem Inhalt, die mit der Verknüpfung gespeichert sind, stehen oben. + Same file Gleiche Datei + Same size Gleiche Größe + Don't Relink Nicht neu verknüpfen + Copy into the document's folder In den Ordner des Dokuments kopieren Reload Plug-ins Plug-ins neu laden Remove Entfernen Remove Anchor Points Ankerpunkte entfernen @@ -3856,6 +3863,9 @@ theme Appearance Erscheinungsbild @msg larger than 256 MB größer als 256 MB @msg the files come to more than 1 GB: add fewer at a time die Dateien sind zusammen größer als 1 GB: Fügen Sie weniger auf einmal hinzu @msg every numbered name for it is taken in the Fonts folder alle nummerierten Namen dafür sind im Schriftenordner bereits vergeben +@msg every numbered name for {name} is taken in the document's folder alle nummerierten Namen für {name} sind im Ordner des Dokuments bereits vergeben +@msg No linked files to look for Keine verknüpften Dateien zum Suchen +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} {n} Bild(er) neu verknüpft; {name} konnte nicht neu verknüpft werden: {reason} # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Dienste diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2a3a31b5..52683d4d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2232,6 +2232,13 @@ Relinked {n} image(s); not in that folder: {} Se volvieron a vincular {n} imagen(es); no están en esa carpeta: {} Relinked {} image(s) Se volvieron a vincular {} imagen(es) Relink… Volver a vincular… + Find Linked Files Buscar archivos vinculados + Find Linked Files in Folder… Buscar archivos vinculados en una carpeta… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Elige el archivo con el que volver a vincular cada archivo vinculado. Los archivos con el tamaño y el contenido guardados con el vínculo aparecen primero. + Same file Mismo archivo + Same size Mismo tamaño + Don't Relink No volver a vincular + Copy into the document's folder Copiar en la carpeta del documento Reload Plug-ins Volver a cargar plugins Remove Quitar Remove Anchor Points Quitar puntos de ancla @@ -3956,6 +3963,9 @@ axis Both Ambos @msg larger than 256 MB mayor de 256 MB @msg the files come to more than 1 GB: add fewer at a time los archivos suman más de 1 GB: agrega menos a la vez @msg every numbered name for it is taken in the Fonts folder todos los nombres numerados para él ya existen en la carpeta Fonts +@msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento +@msg No linked files to look for No hay archivos vinculados que buscar +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Se volvieron a vincular {n} imagen(es); no se pudo volver a vincular {name}: {reason} # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 874693f3..ce3fc5da 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2229,6 +2229,13 @@ Relinked {n} image(s); not in that folder: {} {n} image(s) liée(s) à nouveau ; absentes de ce dossier : {} Relinked {} image(s) {} image(s) liée(s) à nouveau Relink… Lier à nouveau… + Find Linked Files Rechercher des fichiers liés + Find Linked Files in Folder… Rechercher des fichiers liés dans un dossier… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Choisissez pour chaque fichier lié le fichier auquel le lier à nouveau. Les fichiers dont la taille et le contenu correspondent à ceux enregistrés avec le lien apparaissent en premier. + Same file Même fichier + Same size Même taille + Don't Relink Ne pas lier à nouveau + Copy into the document's folder Copier dans le dossier du document Reload Plug-ins Recharger les modules externes Remove Supprimer Remove Anchor Points Supprimer les points d’ancrage @@ -3953,6 +3960,9 @@ axis Both Les deux @msg larger than 256 MB plus de 256 Mo @msg the files come to more than 1 GB: add fewer at a time les fichiers dépassent 1 Go : ajoutez-en moins à la fois @msg every numbered name for it is taken in the Fonts folder tous les noms numérotés pour ce fichier sont déjà pris dans le dossier Fonts +@msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document +@msg No linked files to look for Aucun fichier lié à rechercher +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Lien rétabli pour {n} image(s), mais impossible de lier à nouveau {name} ({reason}) # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 85434f92..d234ac57 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2232,6 +2232,13 @@ Relinked {n} image(s); not in that folder: {} Immagini ricollegate: {n}; non presenti in quella cartella: {} Relinked {} image(s) Immagini ricollegate: {} Relink… Ricollega… + Find Linked Files Trova file collegati + Find Linked Files in Folder… Trova file collegati in una cartella… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Scegli il file a cui ricollegare ogni file collegato. I file con le dimensioni e il contenuto salvati con il collegamento compaiono per primi. + Same file Stesso file + Same size Stesse dimensioni + Don't Relink Non ricollegare + Copy into the document's folder Copia nella cartella del documento Reload Plug-ins Ricarica plug-in Remove Rimuovi Remove Anchor Points Rimuovi punti di ancoraggio @@ -3956,6 +3963,9 @@ axis Both Entrambi @msg larger than 256 MB più grande di 256 MB @msg the files come to more than 1 GB: add fewer at a time i file superano 1 GB: aggiungine meno alla volta @msg every numbered name for it is taken in the Fonts folder tutti i nomi numerati per il file sono già usati nella cartella Fonts +@msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento +@msg No linked files to look for Nessun file collegato da cercare +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Immagini ricollegate: {n}; impossibile ricollegare {name}: {reason} # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9d105867..534dd4ec 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2201,6 +2201,13 @@ Relink 再リンク Relink to Folder… フォルダーに再リンク… Relink… 再リンク… + Find Linked Files リンクファイルを検索 + Find Linked Files in Folder… フォルダー内のリンクファイルを検索… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. リンクファイルごとに、再リンク先のファイルを選んでください。リンクに保存されたサイズと内容が同じファイルが先頭に表示されます。 + Same file 同じファイル + Same size 同じサイズ + Don't Relink 再リンクしない + Copy into the document's folder ドキュメントのフォルダーにコピー Remove 削除 Remove Asset アセットを削除 Remove Brush Stroke ブラシストロークを削除 @@ -3971,6 +3978,9 @@ theme Appearance 外観 @msg larger than 256 MB 256 MB を超えています @msg the files come to more than 1 GB: add fewer at a time ファイルの合計が 1 GB を超えています: 一度に追加するファイルを減らしてください @msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています +@msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています +@msg No linked files to look for 検索するリンクファイルがありません +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} 画像を {n} 枚再リンクしましたが、{name} は再リンクできませんでした: {reason} @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `corners` must be an array of anchor indices, not {v} `corners` はアンカーポイントのインデックスの配列にしてください ({v} は使えません) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6e045551..48922b74 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2228,6 +2228,13 @@ Relinked {n} image(s); not in that folder: {} Восстановлена связь с изображениями: {n}; нет в этой папке: {} Relinked {} image(s) Восстановлена связь с изображениями: {} Relink… Восстановить связь… + Find Linked Files Найти связанные файлы + Find Linked Files in Folder… Найти связанные файлы в папке… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Выберите для каждого связанного файла файл, с которым нужно восстановить связь. Сначала идут файлы с размером и содержимым, сохранёнными вместе со связью. + Same file Тот же файл + Same size Тот же размер + Don't Relink Не восстанавливать связь + Copy into the document's folder Копировать в папку документа Reload Plug-ins Перезагрузить модули Remove Убрать Remove Anchor Points Убрать опорные точки @@ -3952,6 +3959,9 @@ axis Both Оба @msg larger than 256 MB больше 256 МБ @msg the files come to more than 1 GB: add fewer at a time файлы занимают больше 1 ГБ: добавляйте меньше за раз @msg every numbered name for it is taken in the Fonts folder все нумерованные имена для него уже заняты в папке Fonts +@msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа +@msg No linked files to look for Нет связанных файлов для поиска +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Восстановлена связь с изображениями: {n}; не удалось восстановить связь с {name}: {reason} # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 398969e0..c37ee4fd 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2224,6 +2224,13 @@ Relinked {n} image(s); not in that folder: {} Відновлено зв’язок із зображеннями: {n}; немає в цій папці: {} Relinked {} image(s) Відновлено зв’язок із зображеннями: {} Relink… Відновити зв’язок… + Find Linked Files Знайти пов’язані файли + Find Linked Files in Folder… Знайти пов’язані файли в папці… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Виберіть для кожного пов’язаного файлу файл, з яким потрібно відновити зв’язок. Спершу йдуть файли з розміром і вмістом, збереженими разом зі зв’язком. + Same file Той самий файл + Same size Той самий розмір + Don't Relink Не відновлювати зв’язок + Copy into the document's folder Копіювати в папку документа Reload Plug-ins Перезавантажити модулі Remove Прибрати Remove Anchor Points Забрати опорні точки @@ -3940,6 +3947,9 @@ axis Both Обидві @msg larger than 256 MB більше 256 МБ @msg the files come to more than 1 GB: add fewer at a time файли займають понад 1 ГБ: додавайте менше за раз @msg every numbered name for it is taken in the Fonts folder усі нумеровані імена для нього вже зайняті в папці Fonts +@msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа +@msg No linked files to look for Немає пов’язаних файлів для пошуку +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Відновлено зв’язок із зображеннями: {n}; не вдалося відновити зв’язок із {name}: {reason} Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 34e610f8..951d268b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2154,6 +2154,13 @@ Relinked {n} image(s); not in that folder: {} 已重新链接 {n} 张图像;不在该文件夹中:{} Relinked {} image(s) 已重新链接 {} 张图像 Relink… 重新链接… + Find Linked Files 查找链接文件 + Find Linked Files in Folder… 在文件夹中查找链接文件… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. 请为每个链接文件选择要重新链接到的文件。大小和内容与链接中保存的相同的文件排在最前面。 + Same file 相同文件 + Same size 相同大小 + Don't Relink 不重新链接 + Copy into the document's folder 复制到文档所在的文件夹 Reload Plug-ins 重新载入增效工具 Remove 移除 Remove Anchor Points 移除锚点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 90116266..bba6eb79 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2210,6 +2210,13 @@ Relinked {n} image(s); not in that folder: {} 已重新連結 {n} 張影像;不在該資料夾中:{} Relinked {} image(s) 已重新連結 {} 張影像 Relink… 重新連結… + Find Linked Files 尋找連結檔案 + Find Linked Files in Folder… 在資料夾中尋找連結檔案… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. 請為每個連結檔案選擇要重新連結的檔案。大小和內容與連結所儲存相同的檔案會排在最前面。 + Same file 相同檔案 + Same size 相同大小 + Don't Relink 不重新連結 + Copy into the document's folder 複製到文件所在的資料夾 Reload Plug-ins 重新載入增效模組 Remove 移除 Remove Anchor Points 移除錨點 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1ba4c93f..f3f8da39 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -665,6 +665,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.findLinksInFolder", + "Find Linked Files in Folder…", + "", + "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)", + ), ( "ui.packageDialog", "Package…", @@ -1366,6 +1372,9 @@ pub fn run_ui_command(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.findLinksInFolder" => { + crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) + } "ui.packageDialog" => crate::dialogs::package::open(app), "file.showPackage" => crate::dialogs::package::show_package(app, p), "docInfo.save" => crate::panels::doc_info::save_report(app, p), @@ -1971,6 +1980,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.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.epsOptionsDialog" => app.session.active().is_some(), "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 7e10159d..154b26eb 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -1,8 +1,9 @@ //! Links panel: every image in the document, linked files with their status (missing, modified) //! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects //! its image; 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, Embed / Unembed, -//! Placement Options, Show filters, Sort, thumbnail size and Show in Folder. +//! 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, Show filters, Sort, +//! thumbnail size and Show in Folder. //! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -456,6 +457,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { 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)); } + if menu_item(ui, tl!("Find in Folder…"), (!linked.is_empty() || missing_any) && app.services.pick_folder.is_some(), false) { + crate::menus::invoke(app, "ui.findLinksInFolder", json!({ "ids": linked })); + } if menu_item(ui, tl!("Go To Link"), sel.len() == 1, false) && let Some(id) = sel.first().and_then(|r| r["id"].as_u64()) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 530cb65a..28e93f87 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -382,6 +382,10 @@ pub struct UiState { /// dialog is gone. #[serde(skip)] pub dialog_search: Option, + /// The search the Find Linked Files dialog started (`links.findFiles`'s `id`), stopped once the + /// dialog is gone. + #[serde(skip)] + pub links_search: Option, /// The DXF Options chosen last (`document.exportDxf` options; null: never used). #[serde(default)] pub dxf_options: Value, @@ -641,6 +645,7 @@ impl Default for UiState { pending_fonts: vec![], fonts_dialog: None, dialog_search: None, + links_search: None, dxf_options: Value::Null, eps_options: Value::Null, offset_path: Value::Null, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 017f1dde..e66732c5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -449,6 +449,26 @@ Then, with the preference `updateLinks: "askWhenModified"`, modified linked file the front and a linked file of the active document changed meanwhile (asked once per change of a file); with `"automatically"` the files are read again at once, with `"manually"` the Links panel shows them as modified. +Find Linked Files: in the `missingLinks` dialog, `ui.dialog.set {field: "searchFolder", value}` then `ui.dialog.confirm` +is Find in Folder…: the dialog becomes `findLinks`, which looks in that folder and its subfolders for every file the +dialog still asks about (`links.findFiles`). Fields: `document` (the document's uid), `missing` (`[{name, path, ids}]`), +`modified`, `back`, `state` (empty, `searching`, `done`, `stopped` or `failed`), `search` (the search's `id`), `folder`, +`found` (`links.findFiles`'s `links`, with the files found), `searched`, `skipped`, `unreadable`, `stopped`, `error`, +`picks` (the file chosen for each row of `missing`, or null; with each search result, a row not chosen in the dialog +gets its only same file, else its only file found), `touched` (the rows whose file was chosen in the dialog; search +results leave their pick as it is), `copy` (Copy into the document's folder, shown for a saved document) and +`searchFolder`. `ui.dialog.set {field: "searchFolder", value}` then `ui.dialog.confirm` searches that folder and keeps +the dialog open. The fields change as frames draw the dialog; `links.findFiles {}` reports the search directly. +`ui.dialog.confirm` without `searchFolder` is Relink: each row with a file runs `links.relink {ids, path, copy}`, one +undo step each (`copy` for images only: a placed document is relinked where its file is), and it returns `{relinked, +failed}`; the status line names the first file that failed. With `back` (opened from `missingLinks`), the rows left +without a file and those that failed go back to that dialog, then the modified files are offered for update. +`ui.dialog.cancel` closes it and stops its search. `ui.findLinksInFolder {folder?, ids?}` (Find in Folder… in the Links +panel menu) asks for a folder (or takes `folder`), then opens the dialog for the files of `ids`, else of the selected +linked images and placed documents, else the missing ones; with nothing to look for, it fails with "No linked files to +look for" and asks for no folder. On the web, `ui.findLinksInFolder` is disabled, `links.findFiles` can't start a search +and the dialog doesn't open. + Missing fonts: once the missing linked file questions are answered, or at once when there are none, `app.open` of a document whose type uses fonts that aren't available (`text.missingFonts`) opens the `missingFonts` dialog, one document at a time, and so does `links.editOriginal` of a placed document. When another dialog takes its place (Import PDF for a diff --git a/docs/mcp.md b/docs/mcp.md index b6ca1477..1971951a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -65,10 +65,10 @@ the suite) keep the agent to the folders you give it: - Every file a command reads must lie inside the **read root**: `open_file`, `file.place`, relinking and updating links, the images an SVG links to, swatch library, graphic style library and colour-profile loads, plug-ins, - `file.newFromTemplate`, preset imports, the folders `text.findFontFiles` searches. + `file.newFromTemplate`, preset imports, the folders `text.findFontFiles` and `links.findFiles` search. - Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, - library and preset saves to a path. + library and preset saves to a path, the copies `links.relink {copy}` makes. - `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read root like any read, so an agent can see what it was given (#1163). - Read and write access are separate. Give the same folder twice for both; a root left out grants none of its access (with only `--automation-read-root`, nothing can be written to disk, though `export` and @@ -1934,6 +1934,24 @@ comes back to the front, following the same preference. `links.check` reports ev another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. Without a file system (the web), linked images show their previews. +`links.findFiles {folder}` looks in an absolute folder and its subfolders, on separate threads, for the files of the +active document's missing links, or of the images and placed documents `ids` whatever their status, and returns at once +with an `id` and the search's `state` (`searching` while it runs). It finds the files with each linked file's name, in +any letter case, and lists them best first as `{path, size, match, caseDiffers?}`: `same` when the file has the size and +hash saved with the link, `size` when it has the saved size and the link saved no hash (or the file is larger than 1 GB, +which a search doesn't hash), else `name`. Within each kind, a file whose name has the link's letter case comes first. +Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that +differs, and a file with more matching names comes first; files that still tie are listed by path. `links.findFiles {}` +returns the last search's state, and `{stop: true}` stops it. A search looks for at most 1,000 linked files, keeps the +first 10 files of each one's name and up to 10 that are the same file, and ends once every linked file has its same +file; it searches only the folders `text.findFontFiles` may search, with the same `maxSeconds`, depth and count limits. +`links.relink {ids, path, copy: true}` copies the file into the saved document's folder and links images to the copy, +never replacing a file there: the names `Name.png`, `Name 2.png` up to `Name 99.png` are tried in turn until one is +free, which gets the copy (`copied`), or is a file with the same contents, which is used (`kept`). With `copy`, a call +that includes a placed document fails and relinks nothing; placed documents are relinked without `copy`, where their +files are. Find in Folder… in the Linked File Not Found dialog and in the Links panel menu (`ui.findLinksInFolder`) runs +both. The web build has no folder search. + Placed documents (a `.vectorcraft` file placed linked) work the same way: the document keeps the file's bytes and a preview (JPEG or PNG, 1 px per point, at most 1024 px a side), and a save writes the preview (Include Linked Files: the file); output reads the file again, or uses the preview with a warning when it is gone or changed. @@ -1946,6 +1964,9 @@ the document; so does `object.expand`. {"name":"run_command","arguments":{"command":"links.check","params":{}}} {"name":"run_command","arguments":{"command":"links.relink","params":{"ids":[12],"path":"/new/photo.png"}}} {"name":"run_command","arguments":{"command":"links.update","params":{}}} +{"name":"run_command","arguments":{"command":"links.findFiles","params":{"folder":"/Users/me/Archive"}}} +{"name":"run_command","arguments":{"command":"links.findFiles","params":{}}} +{"name":"run_command","arguments":{"command":"links.relink","params":{"ids":[12],"path":"/Users/me/Archive/2024/photo.png","copy":true}}} ``` Placing a text file (`.txt`) sets it as area type: `file.place {path | name+dataBase64, text?: {characterSet?: @@ -2898,8 +2919,9 @@ drive, are searched as any other folder is. `text.addFontFiles {files}` copies files that the session's last search found into VectorCraft's `Fonts` folder, then scans the fonts again, and type set in them redraws. It copies only regular `.ttf`, `.otf`, `.ttc`, `.otc`, `.woff` and `.woff2` files and suitcase fonts (with their resource fork), at most 256 MB a file and 1 GB in all, and lists any -other file in `skipped`. It never replaces a file: a file with the same contents is kept (`kept`), and when another -file has the name, the copy gets a number (`Name 2.otf`). Copy only fonts you own or are licensed to install. `command.batch` returns before a search ends; later requests over MCP or the control channel poll +other file in `skipped`. It never replaces a file: the names `Name.otf`, `Name 2.otf` up to `Name 99.otf` are tried in +turn until one is free, which gets the copy, or is a file with the same contents, which is kept (`kept`). +Copy only fonts you own or are licensed to install. `command.batch` returns before a search ends; later requests over MCP or the control channel poll `text.findFontFiles {}`. `vectorcraft-cli run` stops the search when its last step ends. The web build has no Fonts folder and no folder search. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d406b451..55ee730c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -486,6 +486,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). + - 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. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - **Tool cursors:** the Eyedropper has an original pipette glyph with its tip at the hotspot; Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted