diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 00643221c..6a6b6e6a7 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -57,8 +57,8 @@ pub use scan::{ScanInput, ScanOutput, ScanProgress, scan, scan_with}; #[cfg(test)] use scan::{library_files, library_hashes}; pub use types::{ - Duplicate, ImportCandidate, ImportMode, ImportOptions, ImportReport, ImportUprightMode, ImportWarning, ImportWarningCode, Kept, Moved, OnDeleted, - Organize, + Duplicate, FileFacts, ImportCandidate, ImportMode, ImportOptions, ImportReport, ImportUprightMode, ImportWarning, ImportWarningCode, Kept, Moved, + OnDeleted, Organize, }; #[cfg(test)] diff --git a/crates/engine/src/import/commit.rs b/crates/engine/src/import/commit.rs index 7d1a69fdd..a731488a0 100644 --- 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 } => { 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,9 @@ 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 }) + } } } 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 index ac94f4030..3269edb02 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -1,6 +1,6 @@ use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock}; use super::files::{Seen, expand, probe_all, reusable_probes, sidecars_of}; -use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; +use super::types::{FileFacts, ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; /// Import files/folders. See the module docs. @@ -74,6 +74,8 @@ pub(super) enum PreparedItem { existing: Option, reason: &'static str, hash: Option, + /// What the file itself says, when it was read ([`Duplicate::file`]). + file: Option>, }, Failed(String, String), Kept(Kept), @@ -142,6 +144,7 @@ impl Prepared { existing: Some(existing), reason: if existing_path.is_some() { "path" } else { "content" }, hash: info.content_hash, + file: Some(Box::new(FileFacts { meta: info.meta, captured: info.captured })), }); } else { items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); @@ -262,7 +265,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: None }); self.done.fetch_add(1, Relaxed); } None => todo.push(f), @@ -333,18 +336,17 @@ impl ImportJob { break; } } - if let Some(h) = &info.content_hash - && !opts.local - && 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()) })); - continue; - } + // same bytes as a library photo: skipped below, once its capture time is known + let duplicate_of = + info.content_hash.as_ref().filter(|_| !opts.local).and_then(|h| self.by_hash.get(h).map(|existing| (h.clone(), *existing))); // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files - // and names the copy when the file itself has none + // and names the copy when the file itself has none (a duplicate needs only that time) let raw = info.kind == MediaKind::Raw; - let packet = found_sidecar.and_then(|f| std::fs::read_to_string(f).ok()).or_else(|| info.xmp.clone().filter(|_| raw)); + let packet = if duplicate_of.is_some() && info.captured.is_some() { + None + } else { + found_sidecar.and_then(|f| std::fs::read_to_string(f).ok()).or_else(|| info.xmp.clone().filter(|_| raw)) + }; let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(sc) => Some(sc), @@ -360,6 +362,17 @@ impl ImportJob { if info.captured.is_none() { info.captured = info.file_modified.clone(); } + if let Some((hash, existing)) = duplicate_of { + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Duplicate { + path, + existing, + reason: "content", + hash: Some(hash), + file: Some(Box::new(FileFacts { meta: info.meta, captured: info.captured })), + })); + continue; + } let stored = match (mode, &self.copy_root) { (ImportMode::Copy | ImportMode::Move, Some(root)) if !crate::import_move::inside(Path::new(&path), root) diff --git a/crates/engine/src/import/types.rs b/crates/engine/src/import/types.rs index 6ad3e2153..f342bf4c9 100644 --- a/crates/engine/src/import/types.rs +++ b/crates/engine/src/import/types.rs @@ -146,6 +146,21 @@ 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, + /// What the file itself says, 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, which may have been edited (#2589). + #[serde(skip)] + pub file: Option>, +} + +/// What a file import reads from a file itself, before anything in the library is edited. +#[derive(Clone, Debug, Default)] +pub struct FileFacts { + /// The metadata the file carries (EXIF, IPTC, embedded XMP). + pub meta: lightcraft_catalog::Meta, + /// The capture time a file import gives it: the file's, else its XMP sidecar's, else the + /// file's modification time. + pub captured: Option, } #[derive(Clone, Debug, Default, Serialize)] diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 03190dd8f..7bbe3ddbf 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -943,7 +943,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.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(); @@ -956,6 +957,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); @@ -980,6 +984,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)))?; @@ -991,13 +998,55 @@ 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)) = + 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, capture time or the metadata a person writes. Its + // metadata and capture time are what its own file gives it, as a file import reads them + // (copyright, creator…); the catalog's capture time, when it has one, applies on top. + 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.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(file) = file { + p.meta = file.meta.clone(); + p.captured = file.captured.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, nor the capture time, which + // Edit Capture Time 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(); @@ -1116,7 +1165,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":album_count,"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":album_count,"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 index 7e93131ce..1a8898778 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -157,6 +157,254 @@ 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: Some(Box::new(crate::import::FileFacts { + meta: lightcraft_catalog::Meta { camera: "Same Camera".into(), ..Default::default() }, + captured: 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; + 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: 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) and the +/// capture time its file gives it, as a file import of it would, when the catalog doesn't restate +/// them; 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_and_capture_time_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)); + p.captured = Some("1999-12-31T23:00:00".into()); + }); + 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(), + captured: Some("2024-05-06T07:08:09".into()), + ..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"); + // the catalog row has no capture time: the file's, not the other photo's edited one + assert_eq!(new.captured.as_deref(), Some("2024-05-06T07:08:09"), "{r}"); + 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 = ["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 index 1eb882f09..c60430c12 100644 --- 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 index 79390f136..9aa45bfb6 100644 --- 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 index 33e5f52fc..a81dddb28 100644 --- 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 index 1daab3dab..3babe2c75 100644 --- 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 index 294273781..537dd7ed1 100644 --- 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 index 7fc325522..c596fa594 100644 --- 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 index 7efae90e9..d9c6a5efe 100644 --- 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 index 99c14bf16..91cbbc995 100644 --- 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 index 460368261..c77de0407 100644 --- 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 index 20c7f840d..19fb9cdfe 100644 --- 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 index f17f2c0fd..7da70ee39 100644 --- 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 index 76b7af427..f84b1f23c 100644 --- 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 index 9a71a0288..6cd199d12 100644 --- 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 index 36022ac1a..5f08d7a7d 100644 --- 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 index d5e28bff0..f4d482868 100644 --- 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 index 7406c39b8..9a06a534d 100644 --- 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 index d5c4eb279..81b5d3c82 100644 --- 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 index 098c989d5..1b67f19ca 100644 --- 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 index a96c5ae84..e4da7c209 100644 --- 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.