mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 ©_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 ----------
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user