#2617: Find Missing matches Windows paths' file names on macOS and Linux

Find Missing Photos took a missing photo's file name with Path::file_name,
which doesn't split on `\` on macOS or Linux, so a library made on Windows
had photos it could only find by content: those with no hash on record
(Lightroom-catalog imports, files edited since) were never found. Both the
missing photos and the files found on disk are now named by one rule: the
part after the last `/` or `\`, in lower case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Clyde C Brown
2026-10-22 13:31:51 -06:00
co-authored by Claude Opus 5.5
parent c66014e65f
commit f6a4770cf2
3 changed files with 90 additions and 4 deletions
+16 -4
View File
@@ -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<String, Vec<String>> = HashMap::new();
let files: Vec<String> = 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<HashMap<u64, Vec<String>>> = None;
@@ -239,7 +251,7 @@ pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::res
let mut used: HashSet<String> = 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
+64
View File
@@ -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()));
+10
View File
@@ -1,5 +1,15 @@
# What's new in LightCraft
## 2026-10-22
### 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