mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -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:
@@ -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<Value> {
|
||||
d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The ids in `rows` (`[{ids}]`).
|
||||
fn ids(rows: &[Value]) -> Vec<Value> {
|
||||
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<Value>, modified: Vec<Value>, back: bool) -> Result<Dialog, String> {
|
||||
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<Value> {
|
||||
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<String>, ids: Option<Vec<Value>>) -> Result<Value, String> {
|
||||
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<Value> = 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<String> {
|
||||
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<Value, String> {
|
||||
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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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<Value>| {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Value>, modified: Vec<Value>) {
|
||||
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<Value>, modified: Vec<Value>) {
|
||||
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<Value>) {
|
||||
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<Value>, 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<Value, String> {
|
||||
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<Value, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user