mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 05:42:38 +00:00
Keep library links working when the project folder is moved or cloned (#2132)
Fixes #2122 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39acc36438
commit
7b34dbab00
13 files changed
+201
-10
No files matched your search
@@ -464,7 +464,7 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
|
||||
if ext(out) == "figcraft" {
|
||||
// Through the engine, so the version history is kept.
|
||||
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
|
||||
write(out, &figcraft_engine::cmd::file::file_bytes_at(ds, out).map_err(|e| CliError::Failed(e.to_string()))?)?;
|
||||
} else {
|
||||
write_doc(&ds.doc, ds.page, out)?;
|
||||
}
|
||||
|
||||
@@ -508,7 +508,13 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
let lib = figcraft_engine::library::Cache::default().load(&path).map_err(failed)?;
|
||||
let mut doc = Document::new("Consumer");
|
||||
let m = &lib.manifest;
|
||||
doc.libraries_mut().enabled.push(figcraft_doc::LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.clone(), version: m.version });
|
||||
doc.libraries_mut().enabled.push(figcraft_doc::LibraryLink {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
path: path.clone(),
|
||||
version: m.version,
|
||||
..Default::default()
|
||||
});
|
||||
let map = figcraft_engine::library::import(&mut doc, &lib, &lib_ds.assets(), false).map_err(failed)?;
|
||||
let mut local = lib_ds.remap(&map);
|
||||
local.theme = doc.libraries.collections.iter().find(|r| r.key == lib_ds.theme).map(|r| r.local.clone()).unwrap_or_default();
|
||||
|
||||
@@ -152,6 +152,11 @@ pub struct LibraryLink {
|
||||
pub name: String,
|
||||
/// Where the library file is.
|
||||
pub path: String,
|
||||
/// Where the library file is from the folder of the document that links it ('/'-separated;
|
||||
/// written on save): followed first when the document is opened, so the link survives moving
|
||||
/// or cloning the folder that holds both. Empty for server links and older files.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub relative: String,
|
||||
/// The library version this document last saw (when it was added or its updates accepted).
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
@@ -628,6 +628,90 @@ pub fn absolute_path(path: &str) -> String {
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
/// `path` with `.` and `..` taken out without asking the file system (`..` at the root stays there).
|
||||
fn lexical(path: &std::path::Path) -> std::path::PathBuf {
|
||||
use std::path::Component;
|
||||
let mut out = std::path::PathBuf::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir if matches!(out.components().next_back(), Some(Component::Normal(_))) => {
|
||||
out.pop();
|
||||
}
|
||||
Component::ParentDir if out.has_root() => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The folder of the document saved at `doc_path`, absolute.
|
||||
fn doc_folder(doc_path: &str) -> Option<std::path::PathBuf> {
|
||||
lexical(std::path::Path::new(&absolute_path(doc_path))).parent().map(std::path::Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Library file `lib` from the folder of the document at `doc_path`, '/'-separated; `None` when
|
||||
/// there is no such path (another drive, say).
|
||||
fn relative_to(doc_path: &str, lib: &str) -> Option<String> {
|
||||
use std::path::Component;
|
||||
let folder = doc_folder(doc_path)?;
|
||||
let lib = lexical(std::path::Path::new(&absolute_path(lib)));
|
||||
let (from, to): (Vec<_>, Vec<_>) = (folder.components().collect(), lib.components().collect());
|
||||
let same = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
|
||||
// Not even the root in common: no relative path leads there.
|
||||
if same == 0 || !matches!(to.get(same..), Some([_, ..])) {
|
||||
return None;
|
||||
}
|
||||
let up = from.get(same..).unwrap_or_default().iter().map(|_| Some(".."));
|
||||
let down = to.get(same..).unwrap_or_default().iter().map(|c| match c {
|
||||
Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
});
|
||||
up.chain(down).collect::<Option<Vec<_>>>().map(|parts| parts.join("/"))
|
||||
}
|
||||
|
||||
/// Before saving the document to `doc_path`: each library file link also says where the file is
|
||||
/// from the document's folder ([`LibraryLink::relative`]).
|
||||
pub fn relate_links(doc: &mut Document, doc_path: &str) {
|
||||
if doc.libraries.enabled.is_empty() {
|
||||
return;
|
||||
}
|
||||
for l in &mut doc.libraries_mut().enabled {
|
||||
l.relative = if crate::library_server::is_server_link(&l.path) { None } else { relative_to(doc_path, &l.path) }.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// After opening the document at `doc_path`: library file links go by its folder. A library
|
||||
/// found at its [relative](LibraryLink::relative) place is followed (the folder was moved or
|
||||
/// cloned), and a relative `path` is taken from the document's folder, not the working directory.
|
||||
/// The open document keeps only the absolute `path`.
|
||||
pub fn resolve_links(doc: &mut Document, doc_path: &str) {
|
||||
if doc.libraries.enabled.iter().all(|l| crate::library_server::is_server_link(&l.path) && l.relative.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let Some(folder) = doc_folder(doc_path) else { return };
|
||||
for l in &mut doc.libraries_mut().enabled {
|
||||
if crate::library_server::is_server_link(&l.path) {
|
||||
l.relative.clear();
|
||||
continue;
|
||||
}
|
||||
let moved = Some(l.relative.as_str()).filter(|r| !r.is_empty()).map(|r| lexical(&folder.join(r)));
|
||||
// Only a place on this computer is looked at (see `Session::fetch_library`).
|
||||
if let Some(p) =
|
||||
moved.filter(|p| p.to_str().is_some_and(craft_docpath::is_local) && crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
|
||||
{
|
||||
l.path = p.to_string_lossy().into_owned();
|
||||
} else if !l.path.is_empty()
|
||||
&& std::path::Path::new(&l.path).is_relative()
|
||||
&& let Some(p) = lexical(&folder.join(&l.path)).to_str().filter(|p| craft_docpath::is_local(p))
|
||||
{
|
||||
l.path = p.to_string();
|
||||
}
|
||||
// Written again on each save, from `path`.
|
||||
l.relative.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The enabled library `key` (its id, name or path).
|
||||
pub fn find_link<'a>(doc: &'a Document, key: &str) -> Option<&'a LibraryLink> {
|
||||
let l = &doc.libraries.enabled;
|
||||
@@ -1257,7 +1341,13 @@ pub fn adopt_pasted(doc: &mut Document, src: &Document, src_path: Option<&str>,
|
||||
if doc.libraries.link(&m.id).is_none()
|
||||
&& let Some(path) = src_path
|
||||
{
|
||||
doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version });
|
||||
doc.libraries_mut().enabled.push(LibraryLink {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
path: path.to_string(),
|
||||
version: m.version,
|
||||
relative: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,3 +641,57 @@ fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
|
||||
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
|
||||
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
|
||||
}
|
||||
|
||||
/// Library `lid` is listed without an error, and `library.updates` reports no errors.
|
||||
fn link_found(s: &mut Session, lid: &str) {
|
||||
let list = ex(s, "library.list", json!({}));
|
||||
let l = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == lid).unwrap_or_else(|| panic!("{list}"));
|
||||
assert!(l.get("error").is_none_or(Value::is_null), "{l}");
|
||||
let u = ex(s, "library.updates", json!({}));
|
||||
assert_eq!(u["errors"], json!([]), "{u}");
|
||||
}
|
||||
|
||||
/// A library next to the documents that use it stays linked when their folder is moved or cloned;
|
||||
/// files from before relative links, and relative paths written by hand, keep working too.
|
||||
#[test]
|
||||
fn library_links_follow_a_moved_folder() {
|
||||
let d = dir("moved-folder");
|
||||
let (a, b) = (d.join("a"), d.join("b"));
|
||||
std::fs::create_dir_all(a.join("lib")).unwrap();
|
||||
std::fs::create_dir_all(a.join("pages")).unwrap();
|
||||
let mut lib = Session::new();
|
||||
kit(&mut lib);
|
||||
let lid = publish_to(&mut lib, &path_str(&a.join("lib").join("kit.figcraft")));
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "file.saveAs", json!({"path": path_str(&a.join("pages").join("Page.figcraft"))}));
|
||||
ex(&mut s, "library.add", json!({"path": path_str(&a.join("lib").join("kit.figcraft"))}));
|
||||
ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0}));
|
||||
ex(&mut s, "file.save", json!({}));
|
||||
// The folder moves (or is cloned elsewhere): the library keeps its place next to the page.
|
||||
std::fs::rename(&a, &b).unwrap();
|
||||
let mut moved = Session::empty();
|
||||
ex(&mut moved, "file.open", json!({"path": path_str(&b.join("pages").join("Page.figcraft"))}));
|
||||
link_found(&mut moved, &lid);
|
||||
|
||||
// A file from before relative links: the absolute path is followed.
|
||||
let old = d.join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
let mut doc = Document::new("Old");
|
||||
let abs = path_str(&b.join("lib").join("kit.figcraft"));
|
||||
doc.libraries_mut().enabled.push(LibraryLink { id: lid.clone(), name: "Kit".into(), path: abs, version: 1, ..Default::default() });
|
||||
std::fs::write(old.join("Old.figcraft"), figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
let mut s = Session::empty();
|
||||
ex(&mut s, "file.open", json!({"path": path_str(&old.join("Old.figcraft"))}));
|
||||
link_found(&mut s, &lid);
|
||||
|
||||
// A relative path written by hand goes by the document's folder, not the working directory.
|
||||
let mut doc = Document::new("Hand");
|
||||
let rel = "../lib/kit.figcraft".to_string();
|
||||
doc.libraries_mut().enabled.push(LibraryLink { id: lid.clone(), name: "Kit".into(), path: rel, version: 1, ..Default::default() });
|
||||
std::fs::write(b.join("pages").join("Hand.figcraft"), figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
let mut s = Session::empty();
|
||||
ex(&mut s, "file.open", json!({"path": path_str(&b.join("pages").join("Hand.figcraft"))}));
|
||||
link_found(&mut s, &lid);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ use open::append_pages;
|
||||
pub(crate) use open::open;
|
||||
use open::{import, open_recent, revert};
|
||||
pub(crate) use save::save_ends_preview;
|
||||
pub use save::{FileSnapshot, file_bytes, thumbnail};
|
||||
pub use save::{FileSnapshot, file_bytes, file_bytes_at, thumbnail};
|
||||
use save::{color_profile, mark_saved, save, save_as};
|
||||
pub use version::MAX_AUTOSAVES;
|
||||
pub(crate) use version::{add_version, keep_in_history, read_version, unique_id};
|
||||
|
||||
@@ -75,7 +75,9 @@ pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec<String
|
||||
pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> {
|
||||
let bytes = read(path)?;
|
||||
let sibling = if variables.is_none() { sibling_variables(path) } else { None };
|
||||
load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path))
|
||||
let (mut loaded, rest) = load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path))?;
|
||||
crate::library::resolve_links(&mut loaded.doc, path);
|
||||
Ok((loaded, rest))
|
||||
}
|
||||
|
||||
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
|
||||
|
||||
@@ -42,6 +42,11 @@ pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
|
||||
FileSnapshot::of(ds).encode()
|
||||
}
|
||||
|
||||
/// [`file_bytes`] for the file at `path` (library links are stored relative to its folder too).
|
||||
pub fn file_bytes_at(ds: &DocState, path: &str) -> Result<Vec<u8>> {
|
||||
FileSnapshot::of(ds).encode_at(Some(path))
|
||||
}
|
||||
|
||||
/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the
|
||||
/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then
|
||||
/// [`save`](Self::save) the bytes back on the session's thread.
|
||||
@@ -73,8 +78,17 @@ impl FileSnapshot {
|
||||
|
||||
/// The file's bytes: content, thumbnail and version history.
|
||||
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||
self.encode_at(self.path.as_deref())
|
||||
}
|
||||
|
||||
/// [`encode`](Self::encode) for the file at `path`: library links also say where their
|
||||
/// files are from its folder.
|
||||
fn encode_at(&self, path: Option<&str>) -> Result<Vec<u8>> {
|
||||
let mut doc = (*self.doc).clone();
|
||||
doc.current_page = Some(self.page);
|
||||
if let Some(path) = path {
|
||||
crate::library::relate_links(&mut doc, path);
|
||||
}
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
@@ -177,7 +191,7 @@ fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
|
||||
{
|
||||
Arc::make_mut(&mut snapshot.doc).name = file_stem(path);
|
||||
}
|
||||
let bytes = snapshot.encode()?;
|
||||
let bytes = snapshot.encode_at(path.as_deref().or(snapshot.path.as_deref()))?;
|
||||
let Some(path) = path else {
|
||||
// The download can still be refused: the caller marks the file saved once it was delivered.
|
||||
let name = format!("{}.figcraft", ds.doc.name);
|
||||
|
||||
@@ -646,7 +646,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
l.path = path.clone();
|
||||
l.name = m.name.clone();
|
||||
}
|
||||
None => libs.enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.clone(), version: m.version }),
|
||||
None => libs.enabled.push(LibraryLink {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
path: path.clone(),
|
||||
version: m.version,
|
||||
relative: String::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
let mut out = assets_json(&lib);
|
||||
|
||||
@@ -453,7 +453,13 @@ fn library_setup(path: &str) -> (Session, DesignSystem, Session, DesignSystem, V
|
||||
let lib = crate::library::Cache::default().load(path).unwrap();
|
||||
let mut doc = Document::new("Consumer");
|
||||
let m = &lib.manifest;
|
||||
doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version });
|
||||
doc.libraries_mut().enabled.push(LibraryLink {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
path: path.to_string(),
|
||||
version: m.version,
|
||||
..Default::default()
|
||||
});
|
||||
let map = crate::library::import(&mut doc, &lib, &ds.assets(), false).unwrap();
|
||||
let mut local = ds.remap(&map);
|
||||
local.theme = doc.libraries.collections.iter().find(|r| r.key == ds.theme).map(|r| r.local.clone()).unwrap();
|
||||
|
||||
@@ -56,6 +56,7 @@ fn a_plugin_gets_library_names_but_not_their_links() {
|
||||
name: "Kit".into(),
|
||||
path: LINK.into(),
|
||||
version: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
|
||||
install(&mut s, manifest("org.test.libs", "read", &[]), &script(&[("library.list", json!({}))], OnRefusal::Fail, tail));
|
||||
@@ -73,6 +74,7 @@ fn a_plugin_gets_a_fixed_message_for_library_update_failures() {
|
||||
name: "Kit".into(),
|
||||
path: LINK.into(),
|
||||
version: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
|
||||
install(&mut s, manifest("org.test.upd", "read", &[]), &script(&[("library.updates", json!({}))], OnRefusal::Fail, tail));
|
||||
|
||||
@@ -61,7 +61,7 @@ impl LiveLink for Link {
|
||||
/// A session with `prefs` that opened a file (from disk) whose libraries include [`LINK`].
|
||||
fn opened(prefs: Prefs, tag: &str) -> (Session, Arc<Mutex<Wire>>, std::path::PathBuf) {
|
||||
let mut d = Document::new("Someone else's file");
|
||||
d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3 });
|
||||
d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3, ..Default::default() });
|
||||
let path = std::env::temp_dir().join(format!("figcraft-beacon-{}-{tag}.figcraft", std::process::id()));
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&d, None).unwrap()).unwrap();
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -13,7 +13,13 @@ const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret";
|
||||
#[test]
|
||||
fn the_assets_panel_checks_an_unknown_library_server_only_when_asked() {
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 1 });
|
||||
s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink {
|
||||
id: "lib".into(),
|
||||
name: "Kit".into(),
|
||||
path: LINK.into(),
|
||||
version: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.left_tab = crate::LeftTab::Assets;
|
||||
u.app.ui.libraries = true;
|
||||
|
||||
Reference in new issue
Block a user