# Conflicts:
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-23 17:21:17 -04:00
3 changed files with 88 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()));
+8
View File
@@ -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