diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 39ce50a29..5ff6f3e7d 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -6,7 +6,7 @@ //! pattern) into the document; `swatch.library.save` writes the document's ([`palette_io`]). //! `swatch.library.copyToUser` copies a loaded library into the user library folder. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -104,6 +104,9 @@ pub struct Libraries { /// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the /// loaded id and the User Defined id [`Libraries::get`] finds for it. copied: Vec<(String, String)>, + /// The name each User Defined file was saved under ([`Libraries::write`]), lower case id first: + /// a format that keeps no title (`.ase`) reads its name from the file name instead. + saved_as: HashMap, } #[derive(Clone, Debug)] @@ -152,6 +155,7 @@ impl Libraries { /// Set the user library folder and list its libraries. pub fn set_user_dir(&mut self, dir: Option) { self.user_dir = dir; + self.saved_as.clear(); self.rescan(); } @@ -218,16 +222,14 @@ impl Libraries { let device = crate::file_access::is_device_name; let id = format!("user/{file}").to_lowercase(); let fallback = |other: &str| library_file_name(other, ext).to_lowercase() != file.to_lowercase(); - // A format that keeps no title (`.ase`) reads its name from the file name, so the same - // library saved again looks like another one, and only the file name tells them apart. - let titled = L::read(bytes, stem(&file)).is_ok_and(|lib| lib.name() == name); - if titled - && self.extra.iter().any(|e| { - e.info.id.to_lowercase() == id && e.lib.name() != name && (device(name) || device(e.lib.name()) || fallback(e.lib.name())) - }) - { + let owner = |e: &Extra| self.saved_as.get(&e.info.id.to_lowercase()).map_or(e.lib.name(), String::as_str).to_string(); + let held = self.extra.iter().any(|e| { + let other = owner(e); + e.info.id.to_lowercase() == id && other != name && (device(name) || device(&other) || fallback(&other)) + }); + if held { // Saved again, it goes back to the file it was given the first time. - let own = self.extra.iter().find(|e| e.info.category == "user" && e.lib.name() == name && extension(&e.info.id) == extension(&file)); + let own = self.extra.iter().find(|e| e.info.category == "user" && owner(e) == name && extension(&e.info.id) == extension(&file)); file = match own.and_then(|e| e.info.id.strip_prefix("user/")) { Some(own) => own.to_string(), None => { @@ -239,6 +241,7 @@ impl Libraries { let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); // The app's own folder, under a name made safe: no root applies. crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_file(&path, bytes)))?; + self.saved_as.insert(format!("user/{file}").to_lowercase(), name.to_string()); self.rescan(); out["path"] = json!(path); out["library"] = json!(format!("user/{file}")); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 22d79d432..bcf479811 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -792,18 +792,32 @@ fn a_library_named_like_a_device_keeps_the_library_with_its_file_name() { } } -/// `.ase` keeps no title, so a library named like a device is saved over its own file again, not -/// beside it. +/// `.ase` keeps no title, so a library's name comes back from its file name. Whichever is saved +/// first, `COM¹` and `COM¹-` keep a file each, and saving either again replaces only itself. After +/// a restart `COM¹` can't be told from `COM¹-`, and gets a new file, not the other's. #[test] -fn a_device_named_library_without_a_title_is_not_copied_on_every_save() { - let dir = temp_dir("device-ase"); - let mut s = session(); - s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); - let p = json!({"user": true, "name": "COM¹", "format": "ase"}); - let first = run(&mut s, "swatch.library.save", p.clone())["library"].clone(); - assert_eq!(run(&mut s, "swatch.library.save", p)["library"], first); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - let _ = std::fs::remove_dir_all(&dir); +fn device_named_libraries_without_a_title_keep_their_own_files() { + for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹")] { + let dir = temp_dir("device-ase"); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let save = |s: &mut Session, name: &str| { + run(s, "swatch.library.save", json!({"user": true, "name": name, "format": "ase"}))["library"].as_str().unwrap().to_string() + }; + let a = save(&mut s, first); + assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}"); + let b = save(&mut s, second); + assert_ne!(a, b); + assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}"); + assert_eq!(save(&mut s, second), b, "saving {second} again replaces {second}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + // The next time the app starts. + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let again = save(&mut s, "COM¹"); + assert!(again != a && again != b, "{again}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3); + let _ = std::fs::remove_dir_all(&dir); + } } /// #3602: a library titled with a Windows device name is saved under a name that is not one.