mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user