diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0e..c4236e17 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -803,6 +803,7 @@ "Local folders": "Lokale Ordner", "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", "Locate Missing File…": "Fehlende Datei suchen…", + "Locate Missing Files…": "Fehlende Dateien suchen…", "Locate Photo": "Foto suchen", "Location": "Ort", "Lock": "Sperren", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88..4dab1be2 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -764,6 +764,7 @@ "Local folders": "Carpetas locales", "Local library — no cloud account needed": "Biblioteca local: no requiere cuenta en la nube", "Locate Missing File…": "Localizar archivo que falta…", + "Locate Missing Files…": "Localizar archivos que faltan…", "Locate Photo": "Localizar foto", "Location": "Ubicación", "Lock": "Bloquear", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ff..23ed6119 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -761,6 +761,7 @@ "Local folders": "Dossiers locaux", "Local library — no cloud account needed": "Bibliothèque locale : aucun compte cloud nécessaire", "Locate Missing File…": "Localiser le fichier manquant…", + "Locate Missing Files…": "Localiser les fichiers manquants…", "Locate Photo": "Localiser la photo", "Location": "Lieu", "Lock": "Verrouiller", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c3..a2e5993b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -866,6 +866,7 @@ "Local folders": "ローカルフォルダ", "Local library — no cloud account needed": "ローカルのライブラリ(クラウドアカウント不要)", "Locate Missing File…": "見つからないファイルを指定…", + "Locate Missing Files…": "見つからないファイルを指定…", "Location": "保存場所", "Long Edge": "長辺", "Looking for photos…": "写真を検索中…", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f111..57947eed 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -772,6 +772,7 @@ "Local folders": "Pastas locais", "Local library — no cloud account needed": "Biblioteca local — não precisa de conta na nuvem", "Locate Missing File…": "Localizar arquivo ausente…", + "Locate Missing Files…": "Localizar arquivos ausentes…", "Locate Photo": "Localizar foto", "Location": "Local", "Lock": "Travar", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a0..91db0770 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -637,6 +637,7 @@ "Local folders": "Локальные папки", "Local library — no cloud account needed": "Локальная библиотека — учётная запись в облаке не нужна", "Locate Missing File…": "Указать отсутствующий файл…", + "Locate Missing Files…": "Указать отсутствующие файлы…", "Locate Photo": "Найти фото", "Location": "Местоположение", "Long Edge": "Длинная сторона", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f..c0d174c0 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1024,6 +1024,7 @@ "Local folders": "Локальні теки", "Local library — no cloud account needed": "Локальна бібліотека — хмарний обліковий запис не потрібен", "Locate Missing File…": "Знайти відсутній файл…", + "Locate Missing Files…": "Знайти відсутні файли…", "Long Edge": "Довгий край", "Looking for photos…": "Пошук фото…", "Luminance Range": "Діапазон яскравості", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9..55ea8a0b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -858,6 +858,7 @@ "Local folders": "本地文件夹", "Local library — no cloud account needed": "本地图库 — 无需云账户", "Locate Missing File…": "定位缺失的文件…", + "Locate Missing Files…": "定位缺失的文件…", "Locate Photo": "定位照片", "Location": "位置", "Long Edge": "长边", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e9..5c6eb681 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -858,6 +858,7 @@ "Local folders": "本機資料夾", "Local library — no cloud account needed": "本機照片庫,無需雲端帳號", "Locate Missing File…": "尋找遺失的檔案…", + "Locate Missing Files…": "尋找遺失的檔案…", "Locate Photo": "尋找照片", "Location": "位置", "Long Edge": "長邊", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e..86dad806 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1336,9 +1336,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let within = match p.get("within") { + None | Some(Value::Null) => None, + Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), + Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), + Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), + }; + let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; let folder = match p.get("folder").and_then(Value::as_str) { Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| { + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) }) { Picked::Now(v) => v.into_iter().next(), @@ -1352,7 +1359,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option| { // relinked under the session as it is now: photos relinked meanwhile and files diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c8..fda17cbc 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1707,6 +1707,7 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } else if n.path != "/" { resp.context_menu(|ui| { folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); synchronize_item(app, ui, n, &n.name); ui.separator(); if ui @@ -1721,6 +1722,19 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } } +/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing +/// background finder relink only this folder's photos (including the folders inside it). +fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let r = ui.button(crate::i18n::tr("Locate Missing Files…")); + register(ui.ctx(), "folderLocateMissing", r.rect); + if r.clicked() { + if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + /// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which /// scans the folder in the background. fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { @@ -1812,6 +1826,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F ui.close(); } folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); synchronize_item(app, ui, n, &label); ui.separator(); if ui diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bf..d99716ad 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1487,6 +1487,10 @@ fn folders_app(paths: &[&str]) -> Headless { } fn folders_app_sized(paths: &[&str], size: [f32; 2]) -> Headless { + folders_app_with_services(paths, size, Services { png: None, ..Default::default() }) +} + +fn folders_app_with_services(paths: &[&str], size: [f32; 2], services: Services) -> Headless { use lightcraft_catalog::{Op, Photo, Source}; let mut session = lightcraft_engine::Session::new(); for path in paths { @@ -1494,7 +1498,7 @@ fn folders_app_sized(paths: &[&str], size: [f32; 2]) -> Headless { let p = Photo::new(id, Source::File { path: (*path).into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); } - let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let app = LightcraftApp::new(session, services); let mut h = Headless::new(app, size, 1.0); let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); assert_eq!(r["ok"], true, "{r}"); @@ -1699,6 +1703,59 @@ fn right_click_opens_a_folders_menu_from_the_triangle_too() { assert!(!popup_open(&h), "the startup disk offers nothing"); } +/// Issue #1194: a folder's menu chooses the folder's new location, then the bulk finder relinks +/// only the missing photos from that folder and the folders inside it. +#[test] +fn a_folder_menu_locates_its_missing_tree_only() { + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let base = std::env::temp_dir().join(format!("lc-ui-locate-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let _cleanup = Cleanup(base.clone()); + let new_trip = base.join("new/trip"); + std::fs::create_dir_all(new_trip.join("day2")).unwrap(); + for path in [new_trip.join("a.jpg"), new_trip.join("day2/b.jpg"), new_trip.join("c.jpg")] { + std::fs::write(path, b"photo").unwrap(); + } + + let root = base.to_string_lossy().replace('\\', "/"); + let old = format!("{root}/old"); + let old_trip = format!("{old}/trip"); + let paths = [format!("{old_trip}/a.jpg"), format!("{old_trip}/day2/b.jpg"), format!("{old}/other/c.jpg")]; + let picked = new_trip.to_string_lossy().to_string(); + let services = Services { pick_folder: Some(Box::new(move || Some(picked.clone()))), png: None, ..Default::default() }; + let refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut h = folders_app_with_services(&refs, [1400.0, 900.0], services); + + click(&mut h, &format!("libraryFolderToggle:{old}")); + right_click(&mut h, &format!("source:libfolder:{old_trip}")); + assert!(has(&h, "folderLocateMissing"), "the folder menu offers the action"); + click(&mut h, "folderLocateMissing"); + assert!(h.step_until(T, |h| h.app.ui.last_find_missing.is_some()), "the background search finished"); + let result = h.app.ui.last_find_missing.as_ref().unwrap(); + assert_eq!(result["found"].as_array().map(Vec::len), Some(2), "{result}"); + assert_eq!(result["missing"], 0, "{result}"); + + let sources: Vec = h + .app + .session + .catalog + .photos() + .filter_map(|photo| match &photo.source { + lightcraft_catalog::Source::File { path } => Some(path.clone()), + _ => None, + }) + .collect(); + assert!(sources.contains(&new_trip.join("a.jpg").to_string_lossy().to_string()), "{sources:?}"); + assert!(sources.contains(&new_trip.join("day2/b.jpg").to_string_lossy().to_string()), "{sources:?}"); + assert!(sources.contains(&format!("{old}/other/c.jpg")), "another missing folder stays untouched: {sources:?}"); +} + /// Right-click a folder ▸ Set Color Label ▸ a colour: the folder's row shows the label's dot (a /// row without a label shows none), and None takes it off again. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..5214f9a4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (folder-tree Locate Missing Files for issue #1194) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -707,7 +707,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | -| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | +| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-LIB-MISSING: folder and disk context menus locate a moved tree recursively without relinking missing photos outside it (issue #1194) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |