diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 3438579a2..f5d4b74a1 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -272,7 +272,14 @@ impl Libraries { .map(|e| (e.path.clone(), e.lib.clone())) .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { - Some(p) => (file_name(&p), read_file(&p)?), + Some(p) => { + let ext = extension(&p); + let base = file_name(&p); + // Color books (.acb) name the tab from the book title inside the file; a sandbox + // copy of an import often lands as tmp….tmp.acb (#2996). + let file = if ext == "acb" || temporary_library_file_name(&base) { library_file_name(lib.name(), &ext) } else { base }; + (file, read_file(&p)?) + } None => (library_file_name(lib.name(), ext), encode(&lib)?), }; self.rescan(); @@ -302,6 +309,12 @@ impl Libraries { } } +/// A library file name that looks like a host temporary (e.g. macOS `tmp1F2C.tmp.acb`). +fn temporary_library_file_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.contains(".tmp.") +} + /// The file name of library `name` with extension `ext`: characters file systems reject become /// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`. pub fn library_file_name(name: &str, ext: &str) -> String { diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index 0783e159d..0f25a04ec 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -392,6 +392,50 @@ fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() { let _ = std::fs::remove_dir_all(dir); } +/// A color book opened from a host temporary path (macOS sandbox copies) is stored under its title +/// when copied into the user library folder (#2996). +#[test] +fn copying_a_color_book_from_a_temporary_path_uses_its_title() { + fn acb_str(out: &mut Vec, s: &str) { + let units: Vec = s.encode_utf16().collect(); + out.extend((units.len() as u32).to_be_bytes()); + units.iter().for_each(|u| out.extend(u.to_be_bytes())); + } + let mut bytes = b"8BCB".to_vec(); + bytes.extend(1u16.to_be_bytes()); + bytes.extend(3000u16.to_be_bytes()); + for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] { + acb_str(&mut bytes, s); + } + bytes.extend(1u16.to_be_bytes()); + bytes.extend(7u16.to_be_bytes()); + bytes.extend(0u16.to_be_bytes()); + bytes.extend(0u16.to_be_bytes()); + acb_str(&mut bytes, "185"); + bytes.extend(b"TST001"); + bytes.extend([228, 0, 43]); + bytes.extend(b"spflspot"); + + let dir = temp_dir("acb-temp"); + let user = dir.join("user"); + let elsewhere = dir.join("elsewhere"); + std::fs::create_dir_all(&user).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + let source = elsewhere.join("tmp1F2C.tmp.acb"); + std::fs::write(&source, &bytes).unwrap(); + let source = source.to_string_lossy().to_string(); + + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string())); + let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone(); + assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": loaded }))["name"], "Test Book®"); + let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded })); + assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Test Book®.acb"), Some("Test Book®"), Some(true))); + assert_eq!(r["path"], user.join("Test Book®.acb").to_string_lossy().to_string()); + assert_eq!(std::fs::read(user.join("Test Book®.acb")).unwrap(), bytes); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_library_group_stays_one_group_when_a_swatch_has_its_name() { use vectorcraft_testkit::ase::{Block, ase};