LRC-LIB-MISSING: find missing originals and relink them

- library.missing lists photos whose file isn't where the library expects it; photo.relink
  points one at its new location; library.findMissing {folder} relinks every missing photo
  whose file (same name and size) is somewhere in a folder, in one undoable step. New
  Op::Relink: like a rename in the catalog, but undo/redo never move files.
- UI: File → Find Missing Photos…, photo menu → Locate Missing File… (when the file is gone).
- Renderer: a remembered failure is retried once the catalog changes (a relinked file).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 09:07:23 -04:00
co-authored by Claude Opus 5.5
parent 71dc30b951
commit af2af2a2f5
9 changed files with 241 additions and 7 deletions
+118
View File
@@ -0,0 +1,118 @@
//! Missing originals: photos whose file is no longer where the library expects it (moved,
//! renamed, on an unplugged drive), and relinking them — one at a time (`photo.relink`) or by
//! searching a folder for files with the same name and size (`library.findMissing`).
use std::collections::HashMap;
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// Library photos whose original file can't be found: (id, path).
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
if cfg!(target_arch = "wasm32") {
return Vec::new();
}
s.catalog
.photos()
.filter_map(|p| match &p.source {
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
_ => None,
})
.collect()
}
fn relink_op(id: PhotoId, path: &str) -> Op {
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
Op::Relink { id, file_name, source: Source::File { path: path.to_string() } }
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.relink";
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?;
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let abs = std::path::absolute(path).map_err(|e| bad(C, e.to_string()))?;
if !abs.is_file() {
return Err(bad(C, format!("{path}: no such file")));
}
if !matches!(s.catalog.photo(id).map(|p| &p.source), Some(Source::File { .. })) {
return Err(bad(C, "only photos from files can be relinked"));
}
let abs = abs.to_string_lossy().to_string();
s.commit("Relink Photo", relink_op(id, &abs))?;
s.media.forget(id);
Ok(json!({"id": id.0, "path": abs}))
}
/// Search `folder` (recursively) for each missing photo's file name — and its size when the
/// library knows it — and relink every match in one undoable step.
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findMissing";
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
if !Path::new(folder).is_dir() {
return Err(bad(C, format!("{folder}: not a folder")));
}
let lost = missing(s);
if lost.is_empty() {
return Ok(json!({"found": [], "missing": 0}));
}
// file name (lower case) → candidate paths
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
for f in crate::import::expand(&[folder.to_string()], None) {
if let Some(n) = Path::new(&f).file_name() {
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f);
}
}
let mut ops = Vec::new();
let mut found = Vec::new();
for (id, old) in &lost {
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let size = s.catalog.photo(*id).map(|p| p.file_size).unwrap_or(0);
let hit = by_name.get(&name).and_then(|c| {
c.iter().find(|f| size == 0 || std::fs::metadata(f).is_ok_and(|m| m.len() == size)).or_else(|| (c.len() == 1 && size == 0).then(|| &c[0]))
});
if let Some(path) = hit {
ops.push(relink_op(*id, path));
found.push(json!({"id": id.0, "from": old, "to": path}));
}
}
let still = lost.len() - found.len();
if !ops.is_empty() {
s.commit("Find Missing Photos", Op::Batch { ops })?;
for f in &found {
if let Some(id) = f["id"].as_u64() {
s.media.forget(PhotoId(id));
}
}
}
Ok(json!({"found": found, "missing": still}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "library.missing", "Missing Photos", [], None, "{} → [{id, path}] — photos whose original file isn't where the library expects it", always, |s, _| {
Ok(Value::Array(missing(s).into_iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect()))
}),
cmd!(
"photo.relink",
"Locate Photo",
[],
None,
"{id?, path} — point a photo at its file's new location (undo never moves files)",
always,
relink
),
cmd!(
"library.findMissing",
"Find Missing Photos",
[],
None,
"{folder} — relink every missing photo whose file (same name and size) is somewhere in the folder → {found: [{id, from, to}], missing}",
always,
find_missing
),
]
}
+2
View File
@@ -15,6 +15,7 @@ mod library;
mod manage;
mod masks;
mod merge;
mod missing;
mod organize;
mod prefs;
mod preset_files;
@@ -123,6 +124,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(export::specs());
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v
})
}
+39
View File
@@ -143,3 +143,42 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
assert!(s.execute("library.browse", &serde_json::json!({"path": dir.join("a.png").to_string_lossy()})).is_err());
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()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("old")).unwrap();
std::fs::create_dir_all(dir.join("moved/deeper")).unwrap();
let png = |p: &std::path::Path, seed: u8| {
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
.unwrap();
std::fs::write(p, b).unwrap();
};
png(&dir.join("old/a.png"), 1);
png(&dir.join("old/b.png"), 2);
let mut s = Session::new().with_fs();
s.execute("library.import", &serde_json::json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(0));
// the files move away
std::fs::rename(dir.join("old/a.png"), dir.join("moved/a.png")).unwrap();
std::fs::rename(dir.join("old/b.png"), dir.join("moved/deeper/b.png")).unwrap();
let m = s.execute("library.missing", &serde_json::json!({})).unwrap();
assert_eq!(m.as_array().map(Vec::len), Some(2));
// one by hand
let id = m[0]["id"].as_u64().unwrap();
let name = std::path::Path::new(m[0]["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().to_string();
let new = if name == "a.png" { dir.join("moved/a.png") } else { dir.join("moved/deeper/b.png") };
s.execute("photo.relink", &serde_json::json!({"id": id, "path": new.to_string_lossy()})).unwrap();
assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(1));
assert!(s.execute("photo.relink", &serde_json::json!({"id": id, "path": dir.join("nope.png").to_string_lossy()})).is_err());
// the rest by searching a folder
let r = s.execute("library.findMissing", &serde_json::json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
assert_eq!((r["found"].as_array().map(Vec::len), r["missing"].as_u64()), (Some(1), Some(0)), "{r}");
// undo points the photo back at the old path but doesn't move any file
s.execute("edit.undo", &serde_json::json!({})).unwrap();
assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(1));
assert!(dir.join("moved/a.png").exists() && dir.join("moved/deeper/b.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}