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:
parent
71dc30b951
commit
af2af2a2f5
|
|
@ -154,6 +154,13 @@ pub enum Op {
|
|||
file_name: String,
|
||||
source: Source,
|
||||
},
|
||||
/// Point a photo at its file's new location (a moved or renamed original found again). Unlike
|
||||
/// [`Op::SetFile`], undo and redo never move files.
|
||||
Relink {
|
||||
id: PhotoId,
|
||||
file_name: String,
|
||||
source: Source,
|
||||
},
|
||||
/// The name shown for a colour label (`None` = its colour's name).
|
||||
SetLabelName {
|
||||
label: ColorLabel,
|
||||
|
|
@ -468,6 +475,11 @@ impl Catalog {
|
|||
let p = self.photo_mut(id)?;
|
||||
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
|
||||
}
|
||||
Op::Relink { id, file_name, source } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
let old_name = std::mem::replace(&mut p.file_name, file_name);
|
||||
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) }
|
||||
}
|
||||
Op::SetFile { id, file_name, source } => {
|
||||
if file_name.trim().is_empty() {
|
||||
return Err(CatalogError::Invalid("empty file name".into()));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -747,6 +747,30 @@ mod tests {
|
|||
assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_missing_and_locate() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("a")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("b")).unwrap();
|
||||
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]);
|
||||
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
|
||||
std::fs::write(dir.join("a/one.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default());
|
||||
app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap();
|
||||
std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap();
|
||||
let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
// Locate: an explicit file
|
||||
std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap();
|
||||
let id = app.session.catalog.photos().next().unwrap().id;
|
||||
app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
run_item(&mut app, "photo.locate", json!({"path": dir.join("one-renamed.png").to_string_lossy()})).unwrap();
|
||||
let p = app.session.catalog.photo(id).unwrap();
|
||||
assert_eq!(p.file_name, "one-renamed.png");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_text_per_platform() {
|
||||
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z");
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
|
||||
("file.addFolder", "Add Folder…", None, "File"),
|
||||
("file.addFromDevice", "Add from Device", None, ""),
|
||||
("file.findMissing", "Find Missing Photos…", None, "File"),
|
||||
("photo.locate", "Locate Missing File…", None, ""),
|
||||
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
|
||||
("file.importPresets", "Import Presets…", None, "File"),
|
||||
("file.exportPresets", "Export Presets…", None, "File"),
|
||||
|
|
@ -590,6 +592,31 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
app.ui.quit = true;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.findMissing" => {
|
||||
let folder = match p.get("folder").and_then(Value::as_str) {
|
||||
Some(f) => Some(f.to_string()),
|
||||
None => app.services.pick_folder.as_mut().and_then(|f| f()),
|
||||
};
|
||||
let Some(folder) = folder else { return Some(Ok(Value::Null)) };
|
||||
let r = app.run("library.findMissing", json!({"folder": folder}));
|
||||
if let Ok(v) = &r {
|
||||
let n = v["found"].as_array().map_or(0, Vec::len);
|
||||
let left = v["missing"].as_u64().unwrap_or(0);
|
||||
app.toast(&egui::Context::default(), format!("Found {n} missing photo{}; {left} still missing", if n == 1 { "" } else { "s" }));
|
||||
}
|
||||
r
|
||||
}
|
||||
"photo.locate" => {
|
||||
let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => app.services.pick_files.as_mut().and_then(|f| f().into_iter().next()),
|
||||
};
|
||||
match path {
|
||||
Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"file.addFromDevice" => {
|
||||
// a camera / card: review its DCIM folder, copying into the library by default
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
|
|
|
|||
|
|
@ -626,6 +626,11 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
let _ = app.run("photo.rotateRight", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
// the original moved or its drive is gone: point the photo at the file again
|
||||
let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if !std::path::Path::new(path).exists());
|
||||
if missing && ui.button("Locate Missing File…").clicked() {
|
||||
let _ = app.run("photo.locate", json!({}));
|
||||
}
|
||||
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new("Show in Finder")).clicked() {
|
||||
let _ = app.run("app.showInFinder", json!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,10 @@ pub struct Renderer {
|
|||
quick_tried: HashMap<Slot, u64>,
|
||||
/// Jobs that failed (unreadable or missing file…), by slot and job key, with the error: not
|
||||
/// requested again until the key changes (an edit, another size) — the grid asks every frame.
|
||||
failed: HashMap<Slot, (u64, String)>,
|
||||
failed: HashMap<Slot, (u64, String, u64)>,
|
||||
/// The catalog revision seen at the last poll: a failure is retried once the catalog changes
|
||||
/// (a relinked or re-imported file).
|
||||
catalog_rev: u64,
|
||||
/// Prefetch slot → key of the last job submitted (each runs once).
|
||||
prefetched: HashMap<Slot, u64>,
|
||||
/// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures).
|
||||
|
|
@ -135,6 +138,7 @@ impl Default for Renderer {
|
|||
stages: HashMap::new(),
|
||||
quick_tried: HashMap::new(),
|
||||
failed: HashMap::new(),
|
||||
catalog_rev: 0,
|
||||
prefetched: HashMap::new(),
|
||||
variant_used: HashMap::new(),
|
||||
frame: 0,
|
||||
|
|
@ -161,7 +165,9 @@ impl Renderer {
|
|||
|
||||
/// Request a render for `slot` (no-op if already current or pending at the same priority).
|
||||
pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) {
|
||||
if self.textures.get(&slot).is_some_and(|t| t.key == job.key) || self.failed.get(&slot).is_some_and(|f| f.0 == job.key) {
|
||||
if self.textures.get(&slot).is_some_and(|t| t.key == job.key)
|
||||
|| self.failed.get(&slot).is_some_and(|f| f.0 == job.key && f.2 == self.catalog_rev)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(&(key, prio)) = self.pending.get(&slot)
|
||||
|
|
@ -333,6 +339,7 @@ impl Renderer {
|
|||
|
||||
/// Collect finished jobs into textures. Returns true if anything changed.
|
||||
pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool {
|
||||
self.catalog_rev = session.catalog.revision;
|
||||
self.frame += 1;
|
||||
// wasm: run one job per frame on this thread, timed with the host clock
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
@ -366,7 +373,7 @@ impl Renderer {
|
|||
Err(e) => {
|
||||
if !matches!(slot, Slot::Prefetch(_)) {
|
||||
log::warn!("render {slot:?}: {e}");
|
||||
self.failed.insert(slot, (r.key, e));
|
||||
self.failed.insert(slot, (r.key, e, self.catalog_rev));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| X. Cross-cutting behaviours (BEHAV) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) |
|
||||
| Y. Menus | 69 | 8 | 8 | 8 | 46/47 (98%) | 18/23 (78%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 63 | 12 | 5 | 1 | 49/52 (94%) | 12/23 (52%) |
|
||||
| Lightroom Classic extras | 7 | 20 | 52 | 9 | — | 6/21 (29%) |
|
||||
| **Total** | 308 | 63 | 129 | 37 | 193/198 (97%) | 96/144 (67%) |
|
||||
| Lightroom Classic extras | 8 | 20 | 51 | 9 | — | 7/21 (33%) |
|
||||
| **Total** | 309 | 63 | 128 | 37 | 193/198 (97%) | 97/144 (67%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **67.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 80.6% of 144 · P2 17.7% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **68.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 81.2% of 144 · P2 17.7% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -707,7 +707,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
|||
| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |
|
||||
| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |
|
||||
| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ⬜ | | |
|
||||
| LRC-LIB-MISSING | Missing files & relink | P1 | ⬜ | | |
|
||||
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate` | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. No missing-photos collection |
|
||||
| LRC-LIB-CONVERT | Convert to DNG | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | writer exists, not exposed |
|
||||
| LRC-LIB-PREVIEWS | Build / discard previews | P1 | 🟡 | `cmd:library.clearPreviews`, `crates/preview/src/lib.rs` | disk thumbnail cache; no build-1:1 / smart previews |
|
||||
| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue