Links: Find in Folder… looks for missing linked files in a folder and its subfolders

Find in Folder… in Linked File Not Found and in the Links panel menu
looks in a folder the user picks, and in its subfolders, for the files
with the names of missing linked files, in any letter case. In the
Links panel menu it looks for the selected linked files when there are
any. It uses the folder search of the Missing Fonts dialog. Find
Linked Files lists each file it looks for with the files found for it.
A file with the size and contents saved with the link comes first. A
file of the saved size comes next when the link saved no hash or the
file is larger than 1 GB, and any other file of the name comes last.
Within each group, a file named in the same 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 before one with fewer. A file is chosen
when it is the only file found, or the only one with the saved
contents, and the user can choose another file or Don't Relink.

Relink relinks each file chosen, and each relink is one undo step.
With Copy into the document's folder, which shows for a saved
document, each image's file is copied next to the document first. The
file's name, then Name 2.png, Name 3.png and so on, are tried in turn
until one is free, which gets the copy, or is a file with the same
contents, which is used. A placed document is relinked where its file
is. The status line names the first file that fails to relink, and the
other files still relink. When Find Linked Files opens from Linked
File Not Found, the missing files left without a choice, and any that
fail to relink, go back to that dialog. On the web, Find in Folder… is
disabled in the Links panel menu, and links.findFiles can't start a
search.

- links.findFiles searches on separate threads and returns at once;
  links.findFiles {} reports the search, and {stop: true} stops it. It
  looks for at most 1,000 linked files at once: the active document's
  missing ones, or those of the objects it is given. It hashes a file
  only when a link of its name saved the file's size and a hash, and
  only when the file is 1 GB or smaller. It ends once every linked
  file has its same file, and it searches only the folders
  text.findFontFiles may search, with the same maxSeconds, depth and
  count limits.
- links.relink {copy} copies the file it is given into the saved
  document's folder and links images to the copy; a call that includes
  a placed document fails. Each name it writes is checked against the
  automation write root. text.addFontFiles and links.relink {copy}
  make their copies with fileio::copy_new, which never replaces a
  file. The text.addFontFiles params text and docs/mcp.md say which
  names are tried for a copy.
- vectorcraft-doc adds links::hash_reader, which hashes what a reader
  reads as hash_bytes hashes bytes.
- ui.findLinksInFolder is the Links panel's Find in Folder… for agents;
  docs/control-protocol.md lists the dialog's fields.

Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-12 08:16:41 +07:00
committed by bflatastic
parent b865547bc0
commit b3f832cbcd
28 changed files with 1769 additions and 85 deletions
+45
View File
@@ -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<Option<Placed>> {
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;
+17 -34
View File
@@ -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<CommandSpec> {
"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<Value> {
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<Placed, String> {
use std::io::ErrorKind;
fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<super::fileio::Placed, String> {
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<Pla
if total.saturating_add(len) > 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
+267
View File
@@ -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<CommandSpec> {
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<u64>,
/// The file's size and hash when it was last read.
pub(crate) size: Option<u64>,
pub(crate) hash: Option<String>,
}
/// 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<Sought>,
/// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]).
pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>,
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<String, Vec<usize>>,
/// Each linked file's saved size and hash.
saved: Vec<(Option<u64>, Option<String>)>,
/// The size of each file read, by the path the search reports ([`findfiles::plain`]).
sizes: Arc<Mutex<HashMap<PathBuf, u64>>>,
}
impl LinkFiles {
fn new(links: &[Sought], sizes: Arc<Mutex<HashMap<PathBuf, u64>>>) -> Self {
let mut names: HashMap<String, Vec<usize>> = 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<usize> {
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<Option<String>> = 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<HashMap<PathBuf, u64>>) -> MutexGuard<'_, HashMap<PathBuf, u64>> {
sizes.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn find(s: &mut Session, p: &Value) -> Result<Value> {
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<Sought> = 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<(&Path, bool)>> = 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<Value> = l
.links
.iter()
.zip(found)
.map(|(link, files)| {
let mut files: Vec<(u8, bool, usize, &Path, Option<u64>)> = 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<Value> = 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();
}
}
+84 -7
View File
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<LinkedFi
.collect()
}
/// The linked files of the open document `st` that `links.findFiles` looks for, each with the
/// objects in the layers that show it: the files of objects `ids` whatever their status, else the
/// files that can't be found.
pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec<NodeId>)> {
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<Value> {
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<Value> {
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<NodeId>, String)> = vec![];
let mut not_found = vec![];
@@ -778,9 +801,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
(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<NodeId>)> = vec![];
@@ -796,12 +819,30 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
_ => 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 &copy_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<Value> {
}
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 ----------
+2
View File
@@ -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());
+6
View File
@@ -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<cmd::fontfiles::FontSearch>,
/// 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<cmd::linkfiles::LinkSearch>,
/// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]).
/// Tests set it.
pub search_rules: Option<cmd::findfiles::Rules>,
@@ -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;
+362
View File
@@ -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::<Vec<_>>().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::<Vec<_>>();
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<mpsc::Receiver<()>>);
impl Visitor for Blocked {
fn wants(&self, _: &str) -> bool {
true
}
fn items(&self, _: &Path) -> Vec<usize> {
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(&copy).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(&copy).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<Value>| 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");
}