collective

Browse / github.com/storytold/lightcraft / patch

Issue #2589: a Lightroom import never creates a photo already in Recently Deleted

Issue #2589: a Lightroom import never creates a photo already in Recently Deleted Part of #2589 ## Problem Importing a Lightroom catalog into a library that has photos in Recently Deleted lost photos one at a time while the import counted them as added. A catalog photo whose file has the same bytes as a library photo was imported as a **copy of that photo's record**: its `deleted` flag included (so it landed in Recently Deleted), its virtual-copy fields (so it could become a "copy" of nothing), and its edits, history, versions, ratings and written metadata (title, keywords, face regions). A new virtual copy of a trashed master was also created trashed. Catalog photos matc

Status
open
Score
0.00
Adopters
0 other operators
Author
THEtheChad (imported from pull request #3141, open)
Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0
Size
+403 −18 in 24 files
Review
pending
Shared
2026-10-28
Id
ff639ca6acd612e6ad25dfe6

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/import/commit.rs crates/engine/src/import/job.rs crates/engine/src/import/types.rs crates/engine/src/lightroom_catalog.rs crates/engine/src/lightroom_catalog/tests.rs crates/ui-egui/locales/cs-formats.json crates/ui-egui/locales/de-formats.json crates/ui-egui/locales/es-formats.json crates/ui-egui/locales/fi-formats.json crates/ui-egui/locales/fr-formats.json crates/ui-egui/locales/id-formats.json crates/ui-egui/locales/it-formats.json crates/ui-egui/locales/ja-formats.json crates/ui-egui/locales/ka-formats.json crates/ui-egui/locales/pl-formats.json crates/ui-egui/locales/pt-br-formats.json crates/ui-egui/locales/ru-formats.json crates/ui-egui/locales/sv-formats.json crates/ui-egui/locales/uk-formats.json crates/ui-egui/locales/vi-formats.json crates/ui-egui/locales/zh-hans-formats.json crates/ui-egui/locales/zh-hant-formats.json crates/ui-egui/src/lightroom_import.rs docs/lightroom-catalog-import.md

diff --git a/crates/engine/src/import/commit.rs b/crates/engine/src/import/commit.rs--- a/crates/engine/src/import/commit.rs+++ b/crates/engine/src/import/commit.rs@@ -213,7 +213,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare                 }                 report.imported.push(id.0);             }-            PreparedItem::Duplicate { path, existing, reason, hash } => {+            PreparedItem::Duplicate { path, existing, reason, hash, file_meta } => {                 let existing = existing.or_else(|| {                     let h = hash?;                     new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id))@@ -232,7 +232,13 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare                             report.restored.push(id.0);                         }                     }-                    _ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }),+                    _ => report.duplicates.push(Duplicate {+                        path,+                        existing: existing.map(|i| i.0),+                        reason,+                        existing_deleted: trashed.is_some(),+                        file_meta,+                    }),                 }             }             PreparedItem::Failed(path, e) => report.failed.push((path, e)),diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs--- a/crates/engine/src/import/job.rs+++ b/crates/engine/src/import/job.rs@@ -73,6 +73,8 @@ pub(super) enum PreparedItem {         existing: Option<PhotoId>,         reason: &'static str,         hash: Option<String>,+        /// The metadata the file itself carries, when it was read ([`Duplicate::file_meta`]).+        file_meta: Option<Box<lightcraft_catalog::Meta>>,     },     Failed(String, String),     Kept(Kept),@@ -141,6 +143,7 @@ impl Prepared {                     existing: Some(existing),                     reason: if existing_path.is_some() { "path" } else { "content" },                     hash: info.content_hash,+                    file_meta: Some(Box::new(info.meta)),                 });             } else {                 items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));@@ -261,7 +264,7 @@ impl ImportJob {                     self.done.fetch_add(1, Relaxed);                 }                 Some((id, _)) => {-                    out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None });+                    out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None, file_meta: None });                     self.done.fetch_add(1, Relaxed);                 }                 None => todo.push(f),@@ -326,7 +329,13 @@ impl ImportJob {                 && let Some(existing) = self.by_hash.get(h)             {                 self.done.fetch_add(1, Relaxed);-                plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }));+                plan.slots.push(Slot::Item(PreparedItem::Duplicate {+                    path,+                    existing: *existing,+                    reason: "content",+                    hash: Some(h.clone()),+                    file_meta: Some(Box::new(info.meta)),+                }));                 continue;             }             // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time filesdiff --git a/crates/engine/src/import/types.rs b/crates/engine/src/import/types.rs--- a/crates/engine/src/import/types.rs+++ b/crates/engine/src/import/types.rs@@ -146,6 +146,12 @@ pub struct Duplicate {     /// That photo is in Recently Deleted (see [`OnDeleted`]).     #[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")]     pub existing_deleted: bool,+    /// The metadata the file itself carries (EXIF, IPTC, embedded XMP), as a file import reads it;+    /// `None` when the file wasn't read (a path match). A Lightroom import makes a record of its+    /// own for a duplicate by content and starts it from these, not from the other photo's record,+    /// whose metadata may have been edited (#2589).+    #[serde(skip)]+    pub file_meta: Option<Box<lightcraft_catalog::Meta>>, }  #[derive(Clone, Debug, Default, Serialize)]diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs--- a/crates/engine/src/lightroom_catalog.rs+++ b/crates/engine/src/lightroom_catalog.rs@@ -866,7 +866,8 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co     refuse_non_media(&mut data);     validate(&data).map_err(error)?;     let mut warnings = data.warnings.clone();-    let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();+    let duplicates: HashMap<_, _> =+        files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), (PhotoId(id), d.file_meta.as_deref())))).collect();     let mut by_path: HashMap<_, _> =         s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect();     let mut ids = HashMap::new();@@ -879,6 +880,9 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co     let mut promoted = HashSet::new();     let mut unmapped = BTreeMap::new();     let mut preserved = 0;+    // Library photos in Recently Deleted that a catalog photo matched: they stay there (only an+    // import of the files themselves brings a photo back, see `OnDeleted`), and the report says so.+    let mut in_trash = HashSet::new();     // Masters precede their virtual copies.     let mut photos: Vec<_> = data.photos.iter().collect();     photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);@@ -903,6 +907,9 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co             .filter(|id| s.catalog.photo(*id).is_some_and(|p| source_path(p).is_some_and(|path| key(path) == key(&src.path))));         let existing_id = indexed.or_else(|| if master == 0 { by_path.get(&key(&src.path)).copied() } else { None });         let mut p = if let Some(id) = existing_id.and_then(|id| s.catalog.photo(id)) {+            if id.deleted {+                in_trash.insert(id.id);+            }             (**id).clone()         } else if master != 0 {             let master_id = ids.get(&master).copied().ok_or_else(|| error(format!("virtual copy {}: missing master", src.source_id)))?;@@ -914,13 +921,53 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co             p.id = s.catalog.alloc_photo_id();             p.copy_of = Some(master_id);             p.copy_name = Some(text(&src.image, "copyName").to_string());+            // a new copy of a master in Recently Deleted is imported, not trashed with it+            p.deleted = false;             p-        } else if let Some(duplicate) = duplicates.get(&key(&src.path)).and_then(|id| s.catalog.photo(*id)) {+        } else if let Some((duplicate, file_meta)) =+            duplicates.get(&key(&src.path)).and_then(|(id, meta)| s.catalog.photo(*id).cloned().map(|p| (p, *meta)))+        {             // Catalogs can contain distinct originals with identical bytes and independent edits.-            let mut p = (**duplicate).clone();-            p.id = s.catalog.alloc_photo_id();-            p.source = Source::File { path: src.path.clone() };-            p.file_name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().into()).unwrap_or_else(|| src.path.clone());+            // The new record takes only what the bytes determine from the photo with the same+            // bytes, never that photo's own state: not its Recently Deleted (#2589), virtual copy,+            // edits, history, versions, ratings or the metadata a person writes. Its metadata is+            // what its own file carries, as a file import reads it (copyright, creator…).+            let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone());+            let mut p = Photo::new(+                s.catalog.alloc_photo_id(),+                Source::File { path: src.path.clone() },+                &name,+                &duplicate.format,+                duplicate.width,+                duplicate.height,+                &now,+            );+            p.kind = duplicate.kind;+            p.file_size = duplicate.file_size;+            p.captured = duplicate.captured.clone();+            p.duration = duplicate.duration;+            p.as_shot_wb = duplicate.as_shot_wb;+            p.content_hash = duplicate.content_hash.clone();+            p.embedded_lens = duplicate.embedded_lens;+            p.preview_only = duplicate.preview_only.clone();+            p.preview_checked = duplicate.preview_checked.clone();+            if let Some(meta) = file_meta {+                p.meta = meta.clone();+            } else {+                // (the file wasn't read: only the exposure description, which no command edits;+                // not GPS, which placing a photo on the map does)+                let m = &duplicate.meta;+                p.meta.camera = m.camera.clone();+                p.meta.lens = m.lens.clone();+                p.meta.focal_mm = m.focal_mm;+                p.meta.aperture = m.aperture;+                p.meta.shutter = m.shutter.clone();+                p.meta.iso = m.iso;+                p.meta.focus = m.focus.clone();+            }+            // the settings a file import gives it (as-shot white balance, embedded lens+            // corrections, the default preset); the catalog's settings apply on top+            crate::import::apply_import_defaults(s, &mut p);             p         } else {             let id = s.catalog.alloc_photo_id();@@ -1093,7 +1140,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co     }     s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");     Ok(AppliedImport {-        report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),+        report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"inRecentlyDeleted":in_trash.len(),"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),         index: index.clone(),     }) }diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs--- a/crates/engine/src/lightroom_catalog/tests.rs+++ b/crates/engine/src/lightroom_catalog/tests.rs@@ -157,6 +157,247 @@ fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() {     assert!(s.catalog.albums().next().is_none());     assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact }+/// A library photo at `path`, already committed.+fn library_photo(s: &mut crate::Session, path: &str, edit: impl FnOnce(&mut Photo)) -> PhotoId {+    let id = s.catalog.alloc_photo_id();+    let mut p = Photo::new(id, Source::File { path: path.into() }, "a.ARW", "ARW", 20, 10, "before");+    p.content_hash = Some("same-bytes".into());+    edit(&mut p);+    s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap();+    id+}+/// A catalog photo whose file has the same bytes as a library photo in Recently Deleted (or a+/// virtual copy) is a new record of its own: it was counted as imported but landed in Recently+/// Deleted (or became a "copy" of nothing it was copied from), so a large import into a library+/// with a trash went missing photo by photo (#2589).+#[test]+fn a_content_duplicate_of_a_trashed_photo_or_virtual_copy_is_a_new_live_photo() {+    let trashed: fn(&mut Photo) = |p| p.deleted = true;+    let virtual_copy: fn(&mut Photo) = |p| {+        p.copy_of = Some(PhotoId(999));+        p.copy_name = Some("Copy 1".into());+    };+    for (case, edit) in [("trashed", trashed), ("virtual copy", virtual_copy)] {+        let mut s = crate::Session::new();+        let other = library_photo(&mut s, "/elsewhere/a.ARW", |p| {+            edit(p);+            // the other photo's own state, which the new record must not inherit+            let mut d = (*p.develop).clone();+            d.light.shadows = 77.0;+            p.develop = std::sync::Arc::new(d);+            p.edited = Some("earlier".into());+            p.history.push(lightcraft_catalog::HistoryStep { label: "Other photo's edit".into(), settings: p.develop.clone() });+            p.versions.push(lightcraft_catalog::Version {+                name: "Other photo's version".into(),+                created: "before".into(),+                settings: p.develop.clone(),+                auto: false,+            });+            p.meta.title = "Other photo's title".into();+            p.meta.keywords = vec!["other-photo".into()];+            p.local_baseline = Some(7);+            // what the bytes determine is shared+            p.meta.camera = "Same Camera".into();+            p.file_size = 4096;+        });+        let mut data = sample();+        data.photos.retain(|p| number(&p.image, "masterImage") == 0);+        let files = crate::import::ImportReport {+            duplicates: vec![crate::import::Duplicate {+                path: "/missing/test.ARW".into(),+                existing: Some(other.0),+                reason: "content",+                existing_deleted: case == "trashed",+                // what the file itself carries+                file_meta: Some(Box::new(lightcraft_catalog::Meta { camera: "Same Camera".into(), ..Default::default() })),+            }],+            ..Default::default()+        };+        let undo0 = s.undo.len();+        let r = apply_prepared(+            &mut s,+            data,+            ApplyPrepared {+                update_existing: false,+                files,+                index: &mut ImportIndex::default(),+                archive_path: None,+                missing_sources: &HashSet::new(),+                undo0,+            },+        )+        .unwrap()+        .report;+        assert_eq!(r["imported"], 1, "{case}: {r}");+        assert_eq!(r["inRecentlyDeleted"], 0, "{case}: {r}");+        let new = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap();+        assert_ne!(new.id, other, "{case}");+        assert!(!new.deleted, "{case}: the new record must not be in Recently Deleted");+        assert_eq!((new.copy_of, new.copy_name.as_deref()), (None, None), "{case}: the new record is no virtual copy");+        assert_ne!(new.imported, "before", "{case}: imported now, not when the other photo was");+        assert_eq!(new.rating, 5, "{case}: the catalog's own metadata applies");+        assert_ne!(new.develop.light.shadows, 77.0, "{case}: no edits from the other photo");+        assert!(new.history.iter().all(|h| h.label != "Other photo's edit"), "{case}: no history from the other photo");+        assert!(new.versions.is_empty(), "{case}: no versions from the other photo");+        assert_ne!(new.meta.title, "Other photo's title", "{case}");+        assert!(!new.meta.keywords.iter().any(|k| k == "other-photo"), "{case}");+        assert_eq!(new.local_baseline, None, "{case}");+        assert_eq!((new.meta.camera.as_str(), new.file_size), ("Same Camera", 4096), "{case}: what the bytes determine is kept");+        assert_eq!(new.content_hash.as_deref(), Some("same-bytes"), "{case}");+        assert_eq!(s.catalog.photo(other).unwrap().deleted, case == "trashed", "{case}: the other photo is left as it was");+    }+}+/// A content duplicate starts from the settings a file import gives a raw (its as-shot white+/// balance and embedded lens corrections), as its file would on its own, before the catalog's+/// settings go on top; not from the generic defaults (#2589).+#[test]+fn a_content_duplicate_raw_starts_from_its_camera_defaults() {+    let mut s = crate::Session::new();+    let other = library_photo(&mut s, "/elsewhere/a.DNG", |p| {+        p.deleted = true;+        p.format = "DNG".into();+        p.kind = lightcraft_catalog::MediaKind::Raw;+        p.as_shot_wb = Some((5200.0, 4.0));+        p.embedded_lens = Some(lightcraft_develop::EmbeddedLens::default());+    });+    let mut data = sample();+    data.photos.retain(|p| number(&p.image, "masterImage") == 0);+    // a catalog row with no develop settings of its own+    data.photos[0].settings.clear();+    let files = crate::import::ImportReport {+        duplicates: vec![crate::import::Duplicate {+            path: "/missing/test.ARW".into(),+            existing: Some(other.0),+            reason: "content",+            existing_deleted: true,+            file_meta: None,+        }],+        ..Default::default()+    };+    let undo0 = s.undo.len();+    let r = apply_prepared(+        &mut s,+        data,+        ApplyPrepared {+            update_existing: false,+            files,+            index: &mut ImportIndex::default(),+            archive_path: None,+            missing_sources: &HashSet::new(),+            undo0,+        },+    )+    .unwrap()+    .report;+    let new = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap();+    assert_eq!(*new.develop, new.camera_defaults(), "{r}");+    assert_eq!((new.develop.wb.temp, new.develop.wb.tint), (5200.0, 4.0), "the file's as-shot white balance");+    assert!(new.develop.optics.lens_profile, "the file's embedded lens corrections are on");+}+/// A content duplicate keeps the metadata its file carries (copyright, rights, creator), as a file+/// import of it would, when the catalog's XMP doesn't restate it; it reads the file's, not the+/// other photo's, which a person may have edited (#2589).+#[test]+fn a_content_duplicate_keeps_the_rights_metadata_in_its_file() {+    let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-duplicate-rights-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(dir.join("Photos")).unwrap();+    let source = dir.join("source.lrcat");+    std::fs::write(dir.join("Photos/test.ARW"), b"same bytes").unwrap();+    std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();+    let mut s = crate::Session::new();+    let other = library_photo(&mut s, "/elsewhere/a.ARW", |p| {+        p.deleted = true;+        // edited on the other record after its import+        p.meta.copyright = "Other photo's notice".into();+        p.meta.creator = "Other photo's creator".into();+        p.meta.gps = Some((1.0, 2.0));+    });+    let file_meta = lightcraft_catalog::Meta {+        camera: "Same Camera".into(),+        copyright: "© Jo Example".into(),+        copyright_status: lightcraft_catalog::CopyrightStatus::Copyrighted,+        usage_terms: "No reuse".into(),+        creator: "Jo Example".into(),+        ..Default::default()+    };+    let probed = file_meta.clone();+    s.media.file_probe = Some(Arc::new(move |_| {+        Ok(crate::media::ProbeInfo {+            format: "ARW".into(),+            width: 20,+            height: 10,+            content_hash: Some("same-bytes".into()),+            meta: probed.clone(),+            ..Default::default()+        })+    }));+    let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap();+    let prepared = job.prepare(&AtomicBool::new(false)).unwrap();+    let r = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap().report;+    let new = s.catalog.photos().find(|p| p.id != other).unwrap_or_else(|| panic!("no new record: {r}"));+    assert_eq!(new.content_hash.as_deref(), Some("same-bytes"), "{r}");+    assert_eq!(new.rating, 5, "the catalog's own metadata applies: {r}");+    let m = &new.meta;+    assert_eq!(+        (m.copyright.as_str(), m.copyright_status, m.usage_terms.as_str(), m.creator.as_str(), m.camera.as_str()),+        ("© Jo Example", file_meta.copyright_status, "No reuse", "Jo Example", "Same Camera"),+        "the file's own metadata: {r}"+    );+    assert_eq!(m.gps, None, "nothing from the other photo's record");+    std::fs::remove_dir_all(dir).unwrap();+}+/// A catalog photo matched (by path) to a library photo in Recently Deleted leaves it there, as+/// the import was set up to do (a Lightroom import doesn't restore, see `OnDeleted`), and the report+/// counts it so the photos don't go missing unexplained (#2589). A new virtual copy of it is+/// imported, not trashed with its master.+#[test]+fn a_catalog_photo_matching_a_trashed_record_stays_there_and_is_counted() {+    let mut s = crate::Session::new();+    let trashed = library_photo(&mut s, "/missing/test.ARW", |p| p.deleted = true);+    let r = apply(&mut s, sample(), false).unwrap();+    assert_eq!(r["mapping"]["1"], trashed.0, "{r}");+    assert!(s.catalog.photo(trashed).unwrap().deleted, "the photo stays in Recently Deleted");+    assert_eq!(r["inRecentlyDeleted"], 1, "{r}");+    let copy = s.catalog.photos().find(|p| p.copy_of == Some(trashed)).unwrap();+    assert!(!copy.deleted, "the new virtual copy is imported, not trashed");+    assert_eq!(r["imported"], 1, "only the copy was added: {r}");+}+/// Importing the catalog again after its master and virtual copy were both moved to Recently+/// Deleted matches each through the import index: each is counted once, and neither is restored.+#[test]+fn a_reimport_counts_each_trashed_match_once() {+    let mut s = crate::Session::new();+    // one import index across both imports, as a library on disk keeps in Interop/+    let mut index = ImportIndex::default();+    let mut import = |s: &mut crate::Session| {+        let undo0 = s.undo.len();+        apply_prepared(+            s,+            sample(),+            ApplyPrepared {+                update_existing: false,+                files: Default::default(),+                index: &mut index,+                archive_path: None,+                missing_sources: &HashSet::new(),+                undo0,+            },+        )+        .unwrap()+        .report+    };+    let first = import(&mut s);+    let ids: Vec<PhotoId> = ["1", "2"].iter().map(|k| PhotoId(first["mapping"][k].as_u64().unwrap())).collect();+    for id in &ids {+        s.commit("Delete", Op::SetDeleted { id: *id, deleted: true }).unwrap();+    }+    let r = import(&mut s);+    assert_eq!(r["mapping"], first["mapping"], "{r}");+    assert_eq!(r["inRecentlyDeleted"], 2, "{r}");+    assert_eq!(r["imported"], 0, "{r}");+    assert!(ids.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));+} #[test] fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() {     let mut s = crate::Session::new();diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json--- a/crates/ui-egui/locales/cs-formats.json+++ b/crates/ui-egui/locales/cs-formats.json@@ -267,6 +267,7 @@   "Update “{name}” with Current Settings": "Aktualizovat „{name}“ podle aktuálního nastavení",   "{n} preset{}": "Přednastavení: {n}{:.0}",   "{count} more warnings.": "Další varování: {count}.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "Fotografie katalogu v nedávno odstraněných této knihovny: {in_trash}. Zůstávají tam; chcete-li je vidět, obnovte je z nedávno odstraněných.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Neimportované obrázky katalogu: {skipped}. Jejich záznamy v katalogu jsou neúplné.",   "{photos} catalog records processed; {collections} collections.": "Zpracováno záznamů katalogu: {photos}; kolekcí: {collections}.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Chybějící originály: {missing}. Jejich záznamy jsou v katalogu dostupné; použijte Najít chybějící fotografie a znovu je propojte.",diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json--- a/crates/ui-egui/locales/de-formats.json+++ b/crates/ui-egui/locales/de-formats.json@@ -269,6 +269,7 @@   "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet",   "· {in_source} identical copies": "· {in_source} identische Kopien",   "{count} more warnings.": "{count} weitere Warnungen.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} Katalogfotos liegen in dieser Bibliothek unter „Zuletzt gelöscht“ und bleiben dort; stelle sie dort wieder her, um sie zu sehen.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} Katalogbilder wurden nicht importiert, weil ihre Einträge im Katalog unvollständig sind.",   "{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen.",diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json--- a/crates/ui-egui/locales/es-formats.json+++ b/crates/ui-egui/locales/es-formats.json@@ -269,6 +269,7 @@   "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor",   "· {in_source} identical copies": "· {in_source} copias idénticas",   "{count} more warnings.": "{count} advertencias más.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} fotos del catálogo están en Eliminadas recientemente de esta biblioteca y permanecen allí; restáuralas desde Eliminadas recientemente para verlas.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "No se importaron {skipped} imágenes del catálogo porque sus registros en el catálogo están incompletos.",   "{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos.",diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json--- a/crates/ui-egui/locales/fi-formats.json+++ b/crates/ui-egui/locales/fi-formats.json@@ -257,6 +257,7 @@   "{pid} (missing)": "{pid} (puuttuu)",   "{sel_n} selected · {counted}": "Valittu: {sel_n} · {counted}",   "{size} GB": "{size} Gt",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "Katalogin kuvia tämän kirjaston Äskettäin poistetuissa: {in_trash}. Ne jäävät sinne; palauta ne Äskettäin poistetuista nähdäksesi ne.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",   "{stage}… {:.0}%": "{stage}… {:.0}%",   "{s} s of AV1 video": "{s} s AV1-videota",diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json--- a/crates/ui-egui/locales/fr-formats.json+++ b/crates/ui-egui/locales/fr-formats.json@@ -269,6 +269,7 @@   "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur",   "· {in_source} identical copies": "· {in_source} copies identiques",   "{count} more warnings.": "{count} avertissements supplémentaires.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} photos du catalogue sont dans « Supprimés récemment » de cette photothèque et y restent ; restaurez-les depuis « Supprimés récemment » pour les voir.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} images du catalogue n’ont pas été importées, car leurs entrées dans le catalogue sont incomplètes.",   "{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens.",diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json--- a/crates/ui-egui/locales/id-formats.json+++ b/crates/ui-egui/locales/id-formats.json@@ -296,6 +296,7 @@   "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” sudah terdaftar sebagai orang. Jika digabungkan, keduanya menjadi satu orang; Urungkan akan memisahkannya lagi.",   "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini",   "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} foto katalog ada di Baru dihapus pada pustaka ini dan tetap di sana; pulihkan dari Baru dihapus untuk melihatnya.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",   "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",   "Added {n} photo{}": "{n} foto ditambahkan{:.0}",diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json--- a/crates/ui-egui/locales/it-formats.json+++ b/crates/ui-egui/locales/it-formats.json@@ -250,6 +250,7 @@   "{s} s of AV1 video": "{s} s di video AV1",   "Delete {n} effect preset{}? This can't be undone.": "Eliminare i preset effetto ({n})?{:.0} L'operazione non si può annullare.",   "{count} more warnings.": "Altri avvisi: {count}.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} foto del catalogo sono in Eliminate di recente di questa libreria e vi restano; ripristinale da Eliminate di recente per vederle.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} immagini del catalogo non sono state importate perché i loro record nel catalogo sono incompleti.",   "· {in_source} identical copies": "· copie identiche: {in_source}",   "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Unione {what} aggiunta a {mp}, la dimensione massima di un panorama (la dimensione piena è {full})",diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json--- a/crates/ui-egui/locales/ja-formats.json+++ b/crates/ui-egui/locales/ja-formats.json@@ -273,6 +273,7 @@   "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました",   "· {in_source} identical copies": "・同一のコピー {in_source} 件",   "{count} more warnings.": "ほかに{count}件の警告があります。",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "カタログの写真 {in_trash} 枚は、このライブラリの「最近削除した写真」にあり、そのまま残ります。表示するには「最近削除した写真」から復元してください。",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "カタログ内のレコードが不完全なため、{skipped}件の画像を読み込めませんでした。",   "{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。",diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json--- a/crates/ui-egui/locales/ka-formats.json+++ b/crates/ui-egui/locales/ka-formats.json@@ -279,6 +279,7 @@   "Updated {name}": "Updated {name}",   "new, inside {parent}": "new, inside {parent}",   "{count} more warnings.": "{count} more warnings.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "კატალოგის ფოტოები ამ ბიბლიოთეკის ახლახან წაშლილებში: {in_trash}. ისინი იქ რჩება; სანახავად აღადგინეთ ისინი ახლახან წაშლილებიდან.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "კატალოგის {skipped} სურათი არ დაიმპორტდა, რადგან მათი ჩანაწერები კატალოგში არასრულია.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",   "{n} JPEG files": "{n} JPEG files",diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json--- a/crates/ui-egui/locales/pl-formats.json+++ b/crates/ui-egui/locales/pl-formats.json@@ -261,6 +261,7 @@   "Update “{name}” with Current Settings": "Zaktualizuj „{name}” bieżącymi ustawieniami",   "{n} preset{}": "Ustawienia: {n}{:.0}",   "{count} more warnings.": "Pozostałe ostrzeżenia: {count}.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "Zdjęcia katalogu w Ostatnio usuniętych tej biblioteki: {in_trash}. Pozostają tam; przywróć je z Ostatnio usuniętych, aby je zobaczyć.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.",   "{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia Znajdź brakujące zdjęcia, aby je ponownie powiązać.",diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json--- a/crates/ui-egui/locales/pt-br-formats.json+++ b/crates/ui-egui/locales/pt-br-formats.json@@ -273,6 +273,7 @@   "{n} originals opened in the other editor": "{n} originais abertos no outro editor",   "· {in_source} identical copies": "· {in_source} cópias idênticas",   "{count} more warnings.": "Mais {count} avisos.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} fotos do catálogo estão em Excluídos recentemente desta biblioteca e continuam lá; restaure-as de Excluídos recentemente para vê-las.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} imagens do catálogo não foram importadas porque seus registros no catálogo estão incompletos.",   "{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente.",diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json--- a/crates/ui-egui/locales/ru-formats.json+++ b/crates/ui-egui/locales/ru-formats.json@@ -269,6 +269,7 @@   "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}",   "· {in_source} identical copies": "· идентичных копий: {in_source}",   "{count} more warnings.": "Других предупреждений: {count}.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "Фото каталога в «Недавно удалённых» этой библиотеки: {in_trash}. Они остаются там; чтобы увидеть их, восстановите их из «Недавно удалённых».",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не импортированы изображения каталога: {skipped}. Их записи в каталоге неполные.",   "{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки.",diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json--- a/crates/ui-egui/locales/sv-formats.json+++ b/crates/ui-egui/locales/sv-formats.json@@ -296,6 +296,7 @@   "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",   "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",   "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} katalogfoton finns i Nyligen raderade i det här biblioteket och ligger kvar där; återställ dem från Nyligen raderade för att se dem.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",   "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",   "Added {n} photo{}": "{n} foto tillagda{:.0}",diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json--- a/crates/ui-egui/locales/uk-formats.json+++ b/crates/ui-egui/locales/uk-formats.json@@ -269,6 +269,7 @@   "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}",   "· {in_source} identical copies": "· ідентичних копій: {in_source}",   "{count} more warnings.": "Інших попереджень: {count}.",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "Фото каталогу в нещодавно видалених цієї бібліотеки: {in_trash}. Вони залишаються там; щоб побачити їх, відновіть їх із нещодавно видалених.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не імпортовано зображення каталогу: {skipped}. Їхні записи в каталозі неповні.",   "{photos} catalog records processed; {collections} collections.": "Оброблено записів каталогу: {photos}; колекцій: {collections}.",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не знайдено оригінали: {missing}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання.",diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json--- a/crates/ui-egui/locales/vi-formats.json+++ b/crates/ui-egui/locales/vi-formats.json@@ -257,6 +257,7 @@   "{pid} (missing)": "{pid} (thiếu)",   "{sel_n} selected · {counted}": "Đã chọn {sel_n} · {counted}",   "{size} GB": "{size} GB",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "{in_trash} ảnh của danh mục nằm trong Đã xóa gần đây của thư viện này và vẫn ở đó; hãy khôi phục chúng từ Đã xóa gần đây để xem.",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",   "{stage}… {:.0}%": "{stage}… {:.0}%",   "{s} s of AV1 video": "{s} s of AV1 video",diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json--- a/crates/ui-egui/locales/zh-hans-formats.json+++ b/crates/ui-egui/locales/zh-hans-formats.json@@ -273,6 +273,7 @@   "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件",   "· {in_source} identical copies": "· {in_source} 个相同副本",   "{count} more warnings.": "另有 {count} 条警告。",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "目录中有 {in_trash} 张照片位于此图库的“最近删除”中,并将保留在那里;如需查看,请从“最近删除”中恢复。",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 张图像未导入,因为它们在目录中的记录不完整。",   "{photos} catalog records processed; {collections} collections.": "已处理 {photos} 条目录记录;{collections} 个收藏集。",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。",diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json--- a/crates/ui-egui/locales/zh-hant-formats.json+++ b/crates/ui-egui/locales/zh-hant-formats.json@@ -273,6 +273,7 @@   "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案",   "· {in_source} identical copies": "· {in_source} 個相同副本",   "{count} more warnings.": "另有 {count} 則警告。",+  "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.": "目錄中有 {in_trash} 張照片位於此圖庫的「最近刪除」中,並會保留在那裡;如需檢視,請從「最近刪除」中復原。",   "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 張影像未匯入,因為它們在目錄中的記錄不完整。",   "{photos} catalog records processed; {collections} collections.": "已處理 {photos} 筆目錄記錄;{collections} 個收藏集。",   "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。",diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs--- a/crates/ui-egui/src/lightroom_import.rs+++ b/crates/ui-egui/src/lightroom_import.rs@@ -303,7 +303,7 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask,         }     };     if matches!(kind, Kind::Import) && value.get("cancelled") != Some(&Value::Bool(true)) {-        app.lightroom_result = Some(result_dialog(&value, &message));+        app.lightroom_result = Some(result_dialog(&value, &message, &app.session.catalog));         show_result(app);     }     app.lightroom_last = Some(value);@@ -318,8 +318,9 @@ fn show_result(app: &mut LightcraftApp) { }  /// The lasting result window for an inspect or import: counts, failures, missing originals, the-/// skipped images (one line, in the UI language) and at most eight warnings.-fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog {+/// skipped images (one line, in the UI language) and at most eight warnings. Show in Library goes+/// to the lowest mapped photo that is not in Recently Deleted, where All Photos can show it.+fn result_dialog(value: &Value, summary: &str, catalog: &lightcraft_catalog::Catalog) -> crate::state::Dialog {     let report = value.get("report").unwrap_or(value);     let mut details = Vec::new();     if let Some(photos) = report["photos"].as_u64() {@@ -340,6 +341,13 @@ fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog {             missing = missing         ));     }+    let in_trash = report["inRecentlyDeleted"].as_u64().unwrap_or(0);+    if in_trash > 0 {+        details.push(crate::i18n::tr_format!(+            "{in_trash} catalog photos are in this library's Recently Deleted and stay there; restore them from Recently Deleted to see them.",+            in_trash = in_trash+        ));+    }     // images whose catalog records are incomplete come as data (`skipped`), said here in the UI     // language; their ids are in the report for the CLI and agents     let skipped = report["skipped"].as_array().map_or(0, Vec::len);@@ -359,7 +367,10 @@ fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog {             details.push(crate::i18n::tr_format!("{count} more warnings.", count = warnings.len() - 8));         }     }-    let first = value.get("error").is_none().then(|| report["mapping"].as_object()?.values().filter_map(Value::as_u64).min()).flatten();+    // a catalog photo can map to a library photo left in Recently Deleted (#2589), which All+    // Photos doesn't show+    let live = |id: &u64| catalog.photo(lightcraft_catalog::PhotoId(*id)).is_some_and(|p| !p.deleted);+    let first = value.get("error").is_none().then(|| report["mapping"].as_object()?.values().filter_map(Value::as_u64).filter(live).min()).flatten();     crate::state::Dialog::LightroomResult {         summary: summary.to_string(),         library: report["library"].as_str().or_else(|| value["library"].as_str()).map(str::to_string),@@ -673,6 +684,33 @@ mod tests {         assert!(!h.app.session.visible().is_empty());     } +    /// Show in Library goes to a photo All Photos shows: not a master left in Recently Deleted+    /// whose new virtual copy the import added (#2589), and nowhere when no mapped photo is shown.+    #[test]+    fn show_in_library_skips_photos_in_recently_deleted() {+        use lightcraft_catalog::{Op, Photo, Source};+        let mut s = lightcraft_engine::Session::new();+        let mut add = |deleted: bool| {+            let id = s.catalog.alloc_photo_id();+            let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "a.jpg", "JPEG", 2, 2, "now");+            p.deleted = deleted;+            s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap();+            id.0+        };+        let (trashed, live) = (add(true), add(false));+        assert!(trashed < live);+        let first = |mapping: Value| {+            let crate::state::Dialog::LightroomResult { first, .. } = result_dialog(&json!({"report": {"mapping": mapping}}), "done", &s.catalog)+            else {+                panic!("not a Lightroom result")+            };+            first+        };+        assert_eq!(first(json!({"1": trashed, "2": live})), Some(live));+        assert_eq!(first(json!({"1": trashed})), None, "only a photo in Recently Deleted: no Show in Library");+        assert_eq!(first(json!({"1": live + 100})), None, "no longer in the library");+    }+     #[test]     fn lightroom_failure_stays_readable_without_offering_a_success_action() {         let mut h = demo();@@ -707,18 +745,38 @@ mod tests {             {"image": 9, "reason": "masterSkipped"},         ]}});         crate::i18n::set_language(crate::i18n::Locale::En);-        let crate::state::Dialog::LightroomResult { details: en, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") };+        let crate::state::Dialog::LightroomResult { details: en, .. } = result_dialog(&value, "done", &Default::default()) else {+            panic!("not a Lightroom result")+        };         assert_eq!(en.len(), 4, "{en:?}");         assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete.");         assert_eq!(&en[2..], ["w1", "w2"]);     } +    #[test]+    fn photos_left_in_recently_deleted_are_one_line() {+        let value = json!({"report": {"photos": 3, "collections": 0, "inRecentlyDeleted": 2}});+        crate::i18n::set_language(crate::i18n::Locale::En);+        let crate::state::Dialog::LightroomResult { details, .. } = result_dialog(&value, "done", &Default::default()) else {+            panic!("not a Lightroom result")+        };+        assert_eq!(details.len(), 2, "{details:?}");+        assert!(details[1].starts_with("2 catalog photos are in this library's Recently Deleted"), "{details:?}");+        let value = json!({"report": {"photos": 3, "collections": 0, "inRecentlyDeleted": 0}});+        let crate::state::Dialog::LightroomResult { details, .. } = result_dialog(&value, "done", &Default::default()) else {+            panic!("not a Lightroom result")+        };+        assert_eq!(details.len(), 1, "no line when none are: {details:?}");+    }+     #[test]     fn skipped_images_do_not_take_the_warning_slots() {         let warnings: Vec<_> = (0..10).map(|i| format!("w{i}")).collect();         let skipped: Vec<_> = (0..500).map(|i| json!({"image": i, "reason": "missingFile"})).collect();         let value = json!({"report": {"warnings": warnings, "skipped": skipped}});-        let crate::state::Dialog::LightroomResult { details, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") };+        let crate::state::Dialog::LightroomResult { details, .. } = result_dialog(&value, "done", &Default::default()) else {+            panic!("not a Lightroom result")+        };         // one line for the skipped images, then the usual eight warnings and the rest counted         assert_eq!(details.len(), 10, "{details:?}");         assert!(details[0].starts_with("500 catalog images were not imported"));diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md--- a/docs/lightroom-catalog-import.md+++ b/docs/lightroom-catalog-import.md@@ -52,7 +52,8 @@ lightcraft-cli run --library "LightCraft Library" library.importLightroom path=" The import itself (`library.importLightroom`, `lightcraft-cli run`) adds records to the currently open LightCraft library; only the desktop window above can make a new library for it first. The desktop result stays open until dismissed and shows the destination library, the number of records added, missing originals and warnings.-**Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay+**Show in Library** opens All Photos, clears filters and selects an imported record (not one left in Recently+Deleted; the button is left out when every record is there). Errors stay readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result is also available through `library.importLightroom {"status":true}` during that session.