diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 1bff30d2b..c6c692c35 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -197,13 +197,24 @@ impl FindPlan { } } +/// How Find Missing compares file names: the part after the last `/` or `\`, in lower case. A +/// library made on Windows keeps Windows paths, whose `\` isn't a separator to `Path` on macOS or +/// Linux. Files on disk are named by the same rule, so a macOS or Linux file name that holds a +/// `\` (`a\b.jpg`) still meets its photo, and a `b.jpg` beside it makes that match ambiguous +/// rather than a guess. With no `a\b.jpg` there, such a photo may be found as `b.jpg`, if the +/// size on record agrees. +fn name_key(path: &str) -> String { + path.rsplit(['/', '\\']).next().unwrap_or_default().to_lowercase() +} + /// The file-system half of Find Missing Photos: which `candidates` are gone, and which file in /// `folder` (recursively) is each one's. Touches only the disk — never the session — so the app /// runs it on a worker thread (walking a big folder on a network share and hashing look-alikes /// takes a while) and relinks the result with `library.findMissing {found, missing, ambiguous}`. /// /// Matching, per photo: -/// 1. a file with the same name (any letter case), the same size and the same content hash; +/// 1. a file with the same name ([`name_key`]: any letter case), the same size and the same +/// content hash; /// 2. renamed: a file of the same size with the same content hash (only same-size files are /// read, so this stays cheap); /// 3. without a hash to confirm (or the file was edited in place): the one file with the same @@ -228,8 +239,9 @@ pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::res let mut by_name: HashMap> = HashMap::new(); let files: Vec = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f.as_str())).collect(); for f in &files { - if let Some(n) = Path::new(f).file_name() { - by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone()); + let name = name_key(f); + if !name.is_empty() { + by_name.entry(name).or_default().push(f.clone()); } } let mut by_size: Option>> = None; @@ -239,7 +251,7 @@ pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::res let mut used: HashSet = HashSet::new(); let mut plan = FindPlan::default(); for c in &lost { - let name = Path::new(&c.path).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + let name = name_key(&c.path); let size = c.size; let hash = c.hash.as_deref(); let named: Vec<&String> = by_name diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 191595ebe..a688426ec 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -263,6 +263,70 @@ fn find_missing_plan_is_rechecked_when_applied() { let _ = std::fs::remove_dir_all(&dir); } +/// A library made on Windows names its originals with Windows paths; opened on macOS or Linux +/// they are missing until Find Missing finds them by name, there as everywhere: the name follows +/// the last `/` or `\`, any letter case, the size checked when one is on record, look-alikes +/// reported rather than guessed, and undo puts back the path exactly as spelled. +#[test] +fn find_missing_finds_the_files_of_a_library_made_on_windows() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let dir = temp_dir("missing-windows"); + for (rel, bytes) in [("found/a.jpg", &b"x"[..]), ("found/deep/b.jpg", b"xyz"), ("found/one/twin.jpg", b"x"), ("found/two/twin.jpg", b"x")] { + std::fs::create_dir_all(dir.join(rel).parent().unwrap()).unwrap(); + std::fs::write(dir.join(rel), bytes).unwrap(); + } + let mut s = Session::new(); + // as a Windows library may list them: no content hash, and a size only for b + let mut add = |path: &str, size: u64| { + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + p.file_size = size; + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let (a, b, twin) = (add(r"C:\Users\me\Photos\a.jpg", 0), add(r"D:\Shoot\2019\B.JPG", 3), add(r"C:\Users\me\Photos\twin.jpg", 0)); + let path_of = |s: &Session, id: PhotoId| match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => String::new(), + }; + let r = s.execute("library.findMissing", &json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); + assert_eq!(path_of(&s, a), dir.join("found").join("a.jpg").to_string_lossy(), "found by its name: {r}"); + assert_eq!(path_of(&s, b), dir.join("found").join("deep").join("b.jpg").to_string_lossy(), "found by its name, any case, and size: {r}"); + assert_eq!(path_of(&s, twin), r"C:\Users\me\Photos\twin.jpg", "two files of its name: not guessed: {r}"); + assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "both look-alikes reported: {r}"); + assert_eq!(r["missing"], 1, "{r}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!( + (path_of(&s, a), path_of(&s, b)), + (r"C:\Users\me\Photos\a.jpg".to_string(), r"D:\Shoot\2019\B.JPG".to_string()), + "one undo step puts back the Windows paths as they were spelled" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// On macOS and Linux `\` may be part of a file name. Find Missing names the files it finds by +/// the same rule as the missing photos, so `a\b.jpg` isn't relinked to a `b.jpg` beside it +/// unasked: the two are reported as look-alikes. +#[cfg(unix)] +#[test] +fn find_missing_does_not_take_a_look_alike_for_a_file_name_with_a_backslash() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = temp_dir("missing-backslash"); + std::fs::create_dir_all(dir.join("found")).unwrap(); + for name in [r"a\b.jpg", "b.jpg"] { + std::fs::write(dir.join("found").join(name), b"x").unwrap(); + } + let mut s = Session::new(); + let gone = dir.join("gone").join(r"a\b.jpg").to_string_lossy().to_string(); + let id = s.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: gone.clone() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let r = s.execute("library.findMissing", &json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); + assert!(matches!(&s.catalog.photo(id).unwrap().source, Source::File { path } if *path == gone), "not relinked to b.jpg: {r}"); + assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "both reported: {r}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn missing_files_are_found_and_relinked() { let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id())); diff --git a/docs/whats-new.md b/docs/whats-new.md index dcf203893..41a8c7d14 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -27,6 +27,14 @@ - Saving a sidecar keeps another application's properties even when its namespace uses prefixes such as `lc` or `xmp`. Those foreign properties no longer become LightCraft settings or ratings when imported. +### Find Missing Photos finds the files of a library made on Windows +- A library made on Windows records its originals with Windows paths. Opened on macOS or Linux, its photos are + missing until relinked, and Find Missing Photos found them only by their content, so it missed every photo it had + no fingerprint for (such as photos brought in from a Lightroom catalog) or that had been edited since, even in a + folder holding every file under the same name. It now matches them by name as it does any other photo: the name + after the last `/` or `\`, in any letter case, checked against the size when the library has one. Several files + of that name are still reported rather than guessed, and undo puts back the paths as they were. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges