From 3285e0282d9bddd9e24263dfdb0d8e0f83d78ea6 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:51:10 +0200 Subject: [PATCH 1/2] LR-LIB-DELETE: importing a deleted photo's file again brings it back (#706) Import a photo, delete it, import the same file again: the import skipped it as a duplicate "already in the library" while the grid showed it in Recently Deleted, and the user was stuck (issues #298, #706). The default for a file whose photo is in Recently Deleted is now to restore the photo, with its edits, rating and albums (`onDeleted: restore`; `skip` keeps the old behaviour, `fresh` imports a clean copy). A photo matched by content whose file is gone is relinked to the imported file. The import review checks such files, badges them "Will be restored", counts them as "in Recently Deleted" and starts its choice on Restore them; dropped files say "Restored 1 photo from Recently Deleted". Browsing a folder in Local, Auto Import and a Lightroom catalog import keep skipping: only an import the user asked for brings a photo back. --- crates/engine/src/cmd/browse.rs | 4 +- crates/engine/src/cmd/library.rs | 8 +- crates/engine/src/import.rs | 33 +++++- crates/engine/src/lightroom_job.rs | 5 +- crates/engine/src/tests_import.rs | 109 +++++++++++++++++++- crates/mcp/src/tools.rs | 2 +- crates/ui-egui/locales/de-formats.json | 2 + crates/ui-egui/locales/es-formats.json | 2 + crates/ui-egui/locales/fr-formats.json | 2 + crates/ui-egui/locales/ja-formats.json | 2 + crates/ui-egui/locales/pt-br-formats.json | 2 + crates/ui-egui/locales/ru-formats.json | 2 + crates/ui-egui/locales/uk-formats.json | 2 + crates/ui-egui/locales/zh-hans-formats.json | 2 + crates/ui-egui/locales/zh-hant-formats.json | 2 + crates/ui-egui/src/headless.rs | 39 ++++--- crates/ui-egui/src/import.rs | 38 +++++-- docs/parity.md | 2 +- docs/whats-new.md | 10 ++ 19 files changed, 232 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 6406db47..814e1d63 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result { v.sort(); v }; - let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?; + // looking at a folder never brings its deleted photos back (importing the files does) + let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() }; + let report = import_with(s, &files, &opts)?; stamp_browsed(s, &dir_s, &files); s.browse = Some(Browse { path: dir_s.clone(), subfolders }); s.source = LibrarySource::Folder; diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 27e96f4f..88163716 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -82,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result { } let on_deleted = match str_param(p, "onDeleted") { Some(v) => { - crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))? + crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))? } None => Default::default(), }; @@ -1246,7 +1246,9 @@ pub fn specs() -> Vec { return Ok(json!({"imported": [], "folder": folder})); } let mode = if s.import_defaults.auto_copy { "copy" } else { "add" }; - let mut params = json!({"paths": fresh, "mode": mode}); + // a photo deleted from the library doesn't come back on its own because its file + // is still in the watched folder (a copy's source is not a known path) + let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"}); if let Some(a) = s.import_defaults.auto_album.clone() { match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) { Some(al) => params["album"] = json!(al.id.0), @@ -1326,7 +1328,7 @@ pub fn specs() -> Vec { "Import Photos", ["File"], Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", + "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", always, |s, p| { let req = import_params(s, p)?; diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 29ea013e..ebbcf0c3 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -6,6 +6,8 @@ //! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel //! on native targets. //! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch). +//! A duplicate (by path or content) of a photo in Recently Deleted is restored instead, with +//! its edits, unless the import says otherwise ([`OnDeleted`]). //! 5. *Add* in place (the photo points at the original file), *copy* into the library's //! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy, or *move* //! there: placed like a copy (with its XMP sidecars), and the original removed only once its @@ -53,12 +55,19 @@ pub enum ImportMode { } /// What an import does with a file the library already has in Recently Deleted. +/// +/// Someone who imports such a file wants the photo in the library again, so the default brings +/// it back (issues #298, #706: before, the import skipped it as "already in the library" while +/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits, +/// rating and albums come back with the photo and Reset Edits gives a clean start — where +/// importing afresh would silently drop them. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum OnDeleted { /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). - #[default] Skip, - /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]). + /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose + /// file is no longer where the library had it is relinked to the imported file. + #[default] Restore, /// Delete the trashed record permanently and import the file as a new photo. Fresh, @@ -78,7 +87,7 @@ impl OnDeleted { /// What an import does besides adding the photos. #[derive(Clone, Debug, Default)] pub struct ImportOptions { - /// A file that is in Recently Deleted: skip (default), restore, or import afresh. + /// A file that is in Recently Deleted: restore (default), skip, or import afresh. pub on_deleted: OnDeleted, pub mode: ImportMode, /// Applied to every imported photo (one History entry). @@ -1170,6 +1179,16 @@ fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> P PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) } +/// A trashed photo's file is not where the library had it (never on the web, where paths are +/// storage keys, not files). +fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool { + !cfg!(target_arch = "wasm32") + && match cat.photo(id).map(|p| &p.source) { + Some(Source::File { path }) => !Path::new(path).exists(), + _ => false, + } +} + /// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; /// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are /// taken back. @@ -1210,9 +1229,15 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare }); let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); match trashed { - Some(id) if opts.on_deleted == OnDeleted::Restore => { + // (browsing a folder in Local only looks: it never restores) + Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => { if !report.restored.contains(&id.0) { ops.push(Op::SetDeleted { id, deleted: false }); + // matched by content from another path, and the file the record + // points at is gone (moved, renamed): the photo follows the file + if reason == "content" && trashed_file_is_gone(&s.catalog, id) { + ops.push(crate::cmd::missing::relink_op(id, &path)); + } report.restored.push(id.0); } } diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index fd939341..33ed64a2 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -141,7 +141,8 @@ pub struct LightroomJob { impl LightroomJob { /// Snapshot session state only. This constructor performs no filesystem work. pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result { - let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + // a photo deleted here stays deleted: the catalog's record is matched, not restored + let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() }; let import = ImportJob::new(s, opts)?; let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop")); Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir }) @@ -242,7 +243,7 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) let undo0 = s.undo.len(); prepared.normal.revalidate_add(s); - let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + let options = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() }; let now = prepared.now.clone(); let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?; let applied = lightroom_catalog::apply_prepared( diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 670039ad..3bf0e149 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -870,8 +870,8 @@ fn missing_photos_skip_local_browse_records() { assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); } -/// Import a file that is in Recently Deleted (issue #298): by default it is skipped but the -/// report says it is in the trash; `onDeleted: restore` brings it back with its edits; +/// Import a file that is in Recently Deleted (issues #298, #706): by default the photo comes +/// back with its edits; `onDeleted: skip` leaves it there but the report says it is in the trash; /// `onDeleted: fresh` replaces the trashed record with a new one. fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) { let src = temp_dir(tag); @@ -884,14 +884,115 @@ fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) { (s, src, id) } +/// Issue #706: import a photo, delete it, import the same file again. Before, the import skipped +/// it as "already in the library" (a duplicate by path) while the grid showed it deleted, and the +/// user was stuck. Now the photo is restored: it is back in All Photos, with its edits. +#[test] +fn importing_a_deleted_photo_again_brings_it_back() { + let (mut s, src, id) = trashed_photo("trash-reimport"); + assert_eq!(s.visible_cloned().len(), 0, "deleted: not in All Photos"); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + assert_eq!(ids(&r, "duplicates"), 0, "not reported as a duplicate: {r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 4, "back with its edits"); + assert_eq!(s.catalog.len(), 1, "no second record"); + assert_eq!(s.visible_cloned(), vec![lightcraft_catalog::PhotoId(id)], "shown again in All Photos"); + // undo puts it back in Recently Deleted + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted); + let _ = std::fs::remove_dir_all(&src); +} + +/// The same sequence with the library on disk and a restart between the delete and the import: +/// the trashed record is read back from the catalog and restored, not refused. +#[test] +fn importing_a_deleted_photo_again_after_reopening_the_library_brings_it_back() { + let src = temp_dir("trash-reimport-reopen"); + write_png(&src.join("a.png"), 1); + let lib = src.join("library"); + let id = { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()], "albumName": "Trip"})).unwrap(); + let id = r["imported"][0].as_u64().unwrap(); + s.execute("photo.rate", &json!({"ids": [id], "rating": 2})).unwrap(); + s.execute("photo.delete", &json!({"ids": [id]})).unwrap(); + s.close_library().unwrap(); + id + }; + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "deleted state persisted"); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + assert_eq!(ids(&r, "duplicates"), 0, "{r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 2, "restored with its edits"); + let album = s.catalog.albums().find(|a| a.name == "Trip").unwrap(); + assert_eq!(album.photos, vec![lightcraft_catalog::PhotoId(id)], "still in its album"); + assert_eq!(s.visible_cloned().len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +/// A deleted photo whose file was moved meanwhile: importing the file from its new place restores +/// the photo and points it at the file (matched by content), instead of bringing back a record +/// whose file is missing. +#[test] +fn importing_a_deleted_photo_from_its_new_place_restores_and_relinks_it() { + let (mut s, src, id) = trashed_photo("trash-reimport-moved"); + std::fs::create_dir_all(src.join("moved")).unwrap(); + std::fs::rename(src.join("a.png"), src.join("moved/b.png")).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("moved/b.png").to_string_lossy()]})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 4); + assert_eq!(p.source, lightcraft_catalog::Source::File { path: src.join("moved/b.png").to_string_lossy().to_string() }); + assert_eq!(p.file_name, "b.png"); + assert_eq!(s.catalog.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +/// Only an import brings a deleted photo back. Looking at its folder in Local, and Auto Import +/// seeing its file again in the watched folder (a copy: the source is not a path the library +/// knows), leave it in Recently Deleted. +#[test] +fn browsing_and_auto_import_never_restore_a_deleted_photo() { + let (mut s, src, id) = trashed_photo("trash-no-silent-restore"); + let r = s.execute("library.browse", &json!({"path": src.to_string_lossy()})).unwrap(); + assert_eq!(r["new"], 0, "{r}"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "browsing only looks"); + assert_eq!(s.catalog.len(), 1); + // auto import in copy mode: the trashed photo's copy is in the library's folder, so the watched + // folder's file is new by path and a duplicate by content + let lib = src.join("library"); + let watched = src.join("watched"); + write_png(&watched.join("w.png"), 7); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [watched.join("w.png").to_string_lossy()], "mode": "copy"})).unwrap(); + let copied = r["imported"][0].as_u64().unwrap(); + s.execute("photo.delete", &json!({"ids": [copied]})).unwrap(); + s.execute("library.autoImport", &json!({"folder": watched.to_string_lossy(), "copy": true})).unwrap(); + for _ in 0..3 { + let r = s.execute("library.autoImportScan", &json!({})).unwrap(); + assert_eq!(r["imported"], json!([]), "{r}"); + } + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(copied)).unwrap().deleted, "still deleted"); + assert_eq!(s.catalog.len(), 1, "no second copy"); + let _ = std::fs::remove_dir_all(&src); +} + +/// `onDeleted: skip` keeps the old behaviour for callers that want it: nothing imported, the +/// duplicate reported with `existingDeleted`. #[test] fn duplicate_in_recently_deleted_is_reported_as_such() { let (mut s, src, id) = trashed_photo("trash-report"); - let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "skip"})).unwrap(); assert_eq!(ids(&r, "imported"), 0, "{r}"); assert_eq!(r["duplicates"][0]["existing"], id, "{r}"); assert_eq!(r["duplicates"][0]["existingDeleted"], true, "{r}"); - assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "default: left in the trash"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "skip: left in the trash"); let _ = std::fs::remove_dir_all(&src); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index dd52da40..5ce4d9af 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -147,7 +147,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"}, "destination": {"type": "string", "description": "Copy or move: destination folder"}, "organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, - "onDeleted": {"type": "string", "enum": ["skip", "restore", "fresh"], "description": "A file that is in Recently Deleted: skip (default) = leave it there (the duplicate is reported with existingDeleted); restore = bring the photo back with its edits (`restored` lists it); fresh = delete the trashed record and import the file as a new photo"}, + "onDeleted": {"type": "string", "enum": ["restore", "skip", "fresh"], "description": "A file that is in Recently Deleted: restore (default) = bring the photo back with its edits (`restored` lists it; relinked to the file when its own is gone); skip = leave it there (the duplicate is reported with existingDeleted); fresh = delete the trashed record and import the file as a new photo"}, "rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} }), &["paths"], diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 671da17d..4c468895 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Kanal {} zurücksetzen", "Restore {}": "{} wiederherstellen", "Restore {} Photo{}": "{} Foto{} wiederherstellen", + "Restored {} photo{} from Recently Deleted": "{} Foto{} aus „Zuletzt gelöscht“ wiederhergestellt", "Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).", "Show {hidden} hidden location{}": "Ausgeblendete Orte anzeigen: {hidden}{:.0}", @@ -180,6 +181,7 @@ "{} — show their photos": "{} — Fotos dieser Person anzeigen", "· {dups} already in the library": "· {dups} bereits in der Bibliothek", "· {n} photo{}": "· {n} Foto{}", + "· {trashed} in Recently Deleted": "· {trashed} in „Zuletzt gelöscht“", "… and {more} more": "… und {more} weitere", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", "Filtered: {label}": "Gefiltert: {label}", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 9ba1c8f1..bd18722f 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Restablecer canal {}", "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{}", + "Restored {} photo{} from Recently Deleted": "{0} foto{1} restaurada{1} desde Eliminadas recientemente", "Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).", "Show {hidden} hidden location{}": "Mostrar ubicaciones ocultas: {hidden}{:.0}", @@ -180,6 +181,7 @@ "{} — show their photos": "{}: mostrar sus fotos", "· {dups} already in the library": "· ya en la biblioteca: {dups}", "· {n} photo{}": "· {n} foto{}", + "· {trashed} in Recently Deleted": "· {trashed} en Eliminadas recientemente", "… and {more} more": "… y {more} más", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft aún no puede decodificar esta variante RAW ({}). Se está editando la vista previa JPEG incrustada de la cámara, que ya incluye el estilo de imagen de la cámara (p. ej. Monocromo) y el balance de blancos.", "Filtered: {label}": "Filtrado: {label}", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 7ccaf5a3..d288a321 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Réinitialiser le canal {}", "Restore {}": "Restaurer {}", "Restore {} Photo{}": "Restaurer {} photo{}", + "Restored {} photo{} from Recently Deleted": "{} photo{} restaurée(s) depuis « Supprimés récemment »", "Saved export preset “{}”": "Préréglage d’exportation « {} » enregistré", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Évalue la mise au point et l’exposition de {scope}, puis repère les prises similaires réalisées à quelques secondes d’intervalle (rafales).", "Show {hidden} hidden location{}": "Afficher {hidden} emplacement{} masqué(s)", @@ -180,6 +181,7 @@ "{} — show their photos": "{} — afficher leurs photos", "· {dups} already in the library": "· déjà dans la bibliothèque : {dups}", "· {n} photo{}": "· {n} photo{}", + "· {trashed} in Recently Deleted": "· {trashed} dans « Supprimés récemment »", "… and {more} more": "… et {more} de plus", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ne peut pas encore décoder cette variante RAW ({}). Vous modifiez l’aperçu JPEG intégré de l’appareil photo, qui inclut déjà le style d’image (par ex. monochrome) et la balance des blancs de l’appareil.", "Filtered: {label}": "Filtré : {label}", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index f1c06351..d8ef703e 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -61,6 +61,7 @@ "Reset {} Channel": "{}チャンネルをリセット", "Restore {}": "{} に戻す", "Restore {} Photo{}": "{}枚の写真を復元{:.0}", + "Restored {} photo{} from Recently Deleted": "最近削除した写真から{}枚を復元しました{:.0}", "Saved export preset “{}”": "書き出しプリセット「{}」を保存しました", "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", "Showing {n} of {}": "{}枚中{n}枚を表示", @@ -105,6 +106,7 @@ "{} spot(s) on this photo": "この写真に{}か所のスポット", "· {dups} already in the library": "・{dups}枚は読み込み済み", "· {n} photo{}": "・{n}枚{:.0}", + "· {trashed} in Recently Deleted": "・最近削除した写真内 {trashed} 枚", "… and {more} more": "…ほか{more}件", "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 4a9d1168..4bf52297 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -82,6 +82,7 @@ "Reset {} Channel": "Redefinir canal {}", "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{:.0}", + "Restored {} photo{} from Recently Deleted": "Restauradas {} foto{:.0} de Excluídos recentemente", "Saved export preset “{}”": "Predefinição de exportação “{}” salva", "Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto", "Showing {n} of {}": "Mostrando {n} de {}", @@ -133,6 +134,7 @@ "{} spot(s) on this photo": "{} ponto(s) nesta foto", "· {dups} already in the library": "· {dups} já na biblioteca", "· {n} photo{}": "· {n} foto{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} em Excluídos recentemente", "… and {more} more": "… e mais {more}", " ({unsure} with several look-alike files: use Locate)": " ({unsure} com vários arquivos parecidos: use Localizar)", " · {:.0}% clipped": " · {:.0}% recortado", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index ba3560ad..22b47b8f 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -114,6 +114,7 @@ "Reset {} Channel": "Сбросить канал: {}", "Restore {}": "Восстановить {}", "Restore {} Photo{}": "Восстановить фото: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "Восстановлено из «Недавно удалённых» фото: {}{:.0}", "Saved export preset “{}”": "Пресет экспорта «{}» сохранён", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).", "Show {hidden} hidden location{}": "Показать скрытые места: {hidden}{:.0}", @@ -187,6 +188,7 @@ "{} — show their photos": "{} — показать фото этого человека", "· {dups} already in the library": "· уже в библиотеке: {dups}", "· {n} photo{}": "· фото: {n}{:.0}", + "· {trashed} in Recently Deleted": "· в «Недавно удалённых»: {trashed}", "… and {more} more": "… и ещё {more}", "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", "{:.1} MB": "{:.1} MB", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index c33dd0cb..0632f000 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -109,6 +109,7 @@ "Reset {} Channel": "Скинути канал: {}", "Restore {}": "Відновити {}", "Restore {} Photo{}": "Відновити фото: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "Відновлено з нещодавно видалених фото: {}{:.0}", "Saved export preset “{}”": "Набір налаштувань експорту «{}» збережено", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оцінює фокус і експозицію ({scope}) та знаходить подібні знімки з різницею в кілька секунд (серії).", "Show {hidden} hidden location{}": "Показати приховані розташування: {hidden}{:.0}", @@ -180,6 +181,7 @@ "{} — show their photos": "{} — показати фото цієї особи", "· {dups} already in the library": "· уже в бібліотеці: {dups}", "· {n} photo{}": "· фото: {n}{:.0}", + "· {trashed} in Recently Deleted": "· у нещодавно видалених: {trashed}", "… and {more} more": "… і ще {more}", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ще не може декодувати цей варіант RAW ({}). Ви редагуєте вбудований JPEG-перегляд камери, який уже містить її стиль зображення (наприклад, монохромний) і баланс білого.", "Filtered: {label}": "Відфільтровано: {label}", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index eb429af4..34faeb16 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -61,6 +61,7 @@ "Reset {} Channel": "重置 {0} 通道", "Restore {}": "恢复为 {}", "Restore {} Photo{}": "恢复 {0} 张照片{1:.0}", + "Restored {} photo{} from Recently Deleted": "已从“最近删除”恢复 {0} 张照片{1:.0}", "Saved export preset “{}”": "已保存导出预设“{0}”", "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", "Showing {n} of {}": "显示 {0} 张中的 {n} 张", @@ -105,6 +106,7 @@ "{} spot(s) on this photo": "此照片上有 {0} 处污点", "· {dups} already in the library": "· {dups} 张已在图库中", "· {n} photo{}": "· {n} 张照片{0:.0}", + "· {trashed} in Recently Deleted": "· {trashed} 张在“最近删除”中", "… and {more} more": "…以及另外 {more} 项", "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}", "Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 0b2ec765..d2a4aa50 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -78,6 +78,7 @@ "Reset {} Channel": "重設 {} 通道", "Restore {}": "還原為 {}", "Restore {} Photo{}": "還原 {} 張照片{:.0}", + "Restored {} photo{} from Recently Deleted": "已從「最近刪除」還原 {} 張照片{:.0}", "Saved export preset “{}”": "已儲存匯出預設集「{}」", "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}", "Showing {n} of {}": "顯示 {n}/{} 張", @@ -129,6 +130,7 @@ "{} spot(s) on this photo": "此照片有 {} 個斑點", "· {dups} already in the library": "· {dups} 張已在照片庫中", "· {n} photo{}": "· {n} 張照片{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} 張在「最近刪除」中", "… and {more} more": "…以及另外 {more} 個", " ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)", "Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d0104f7d..52474ba3 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2595,9 +2595,9 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// The import review offers what to do with files that are in Recently Deleted (issue #298): - /// they are unchecked until Restore or Import as new is chosen; then confirming restores the - /// photo (with its edits) or imports the file afresh. + /// The import review offers what to do with files that are in Recently Deleted (issues #298, + /// #706): they are checked, to be restored, by default; Leave them unchecks them; confirming + /// restores the photo (with its edits) or, with Import as new, imports the file afresh. #[test] fn import_review_offers_recently_deleted_files() { for (choice, tag) in [("restore", "r"), ("fresh", "f")] { @@ -2624,9 +2624,12 @@ mod tests { h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.selected_paths().len(), 1, "only the new file: the trashed one waits for a choice"); + assert_eq!(opts.on_deleted, "restore", "the default brings a deleted photo back"); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked from the start: it will be restored"); + opts.set_on_deleted(""); + assert_eq!(opts.selected_paths().len(), 1, "Leave them: only the new file"); opts.set_on_deleted(choice); - assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked once a choice is made"); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked again once a choice is made"); // a per-cell choice survives switching between the two options, and "Leave them" unchecks let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap(); opts.checked[trashed] = false; @@ -2711,9 +2714,10 @@ mod tests { assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open"); } - /// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo - /// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once - /// restored a re-add selects it in All Photos. + /// Adding a file again (dropped on the window, no review) whose photo is in Recently Deleted + /// brings the photo back, selected in All Photos, and says so (issue #706) — it is not + /// skipped as a duplicate; the trash view's menus offer Restore and Delete Permanently, and a + /// re-add of a photo that is in the library selects it in All Photos. #[test] fn readding_a_deleted_photo_shows_it_in_recently_deleted() { let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); @@ -2740,6 +2744,7 @@ mod tests { crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); h.settle(SETTLE); let id = h.app.session.selection.active.expect("imported photo selected"); + let n0 = h.app.session.catalog.len(); h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); assert!(h.app.session.catalog.photo(id).unwrap().deleted); let photo_items = |app: &LightcraftApp| -> Vec { @@ -2749,14 +2754,22 @@ mod tests { }; assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); - h.app.ui.left_panel = false; crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); finish_import(&mut h); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); - assert_eq!(h.app.session.selection.ids, vec![id]); - assert!(h.app.ui.left_panel, "the side panel listing Recently Deleted is opened"); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "re-adding the file restores the photo"); + assert_eq!(h.app.session.catalog.len(), n0, "no second record"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id], "the restored photo is selected"); let toast = h.app.ui.toast.clone().expect("toast").0; - assert!(toast.contains("Recently Deleted") && toast.contains("Restore"), "{toast}"); + assert!(toast.contains("Restored 1 photo from Recently Deleted"), "{toast}"); + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string())); + // undo of the re-add puts it back in Recently Deleted; the trash view's menus offer Restore + h.request("engine.execute", json!({"command": "edit.undo"}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted, "one undo step"); + h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), t); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); let items = photo_items(&h.app); assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index af0a10ad..5bc7b974 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -81,9 +81,10 @@ pub struct ImportDialog { pub metadata_preset: String, /// Copy: raws are copied as DNG. pub dng: bool, - /// Per candidate: the library already has it in Recently Deleted. + /// Per candidate: the library already has it in Recently Deleted ([`ImportDialog::set_trashed`]). pub trashed: Vec, - /// What to do with those: "" (leave them), `restore` or `fresh` (the `onDeleted` param). + /// What to do with those: `restore` (the default: the photo comes back with its edits), `fresh` + /// (imported as a new photo, the old record deleted) or "" (leave them) — the `onDeleted` param. pub on_deleted: String, /// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]). #[serde(skip)] @@ -109,7 +110,22 @@ fn file_type(c: &ImportCandidate) -> String { impl ImportDialog { pub fn new(candidates: Vec) -> Self { let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); - ImportDialog { candidates, checked, ..Default::default() } + ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() } + } + /// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice + /// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by + /// mistake comes back by importing its file again (issue #706). + pub fn set_trashed(&mut self, trashed: Vec) { + self.trashed = trashed; + for i in 0..self.candidates.len() { + if !self.is_trashed(i) { + continue; + } + let on = self.importable(i); + if let Some(c) = self.checked.get_mut(i) { + *c = on; + } + } } pub fn importable(&self, i: usize) -> bool { self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty())) @@ -542,7 +558,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { } app.renderer.forget_imports(); let mut d = ImportDialog::new(out.candidates); - d.trashed = d + let trashed = d .candidates .iter() .map(|c| { @@ -550,6 +566,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) }) .collect(); + d.set_trashed(trashed); d.copy = task.copy; d.sources = task.sources; app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); @@ -754,10 +771,12 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) + } else if task.imported == 0 && task.restored > 0 { + crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) } else { crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) }; - if task.restored > 0 { + if task.restored > 0 && (task.imported > 0 || task.cancelled) { msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); } if task.params["mode"] == "move" { @@ -820,13 +839,18 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let t = Tokens::get(ui.ctx()); let n = d.candidates.len(); - let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count(); + let trashed = d.trashed.iter().filter(|t| **t).count(); + // files of photos in Recently Deleted are not "already in the library": they are counted apart + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed); let sel = d.selected_paths().len(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); if dups > 0 { ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); } + if trashed > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { d.checked.iter_mut().for_each(|c| *c = false); @@ -1048,7 +1072,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { }; let mut pick = None; egui::ComboBox::from_id_salt("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (v, label) in [("", "Leave them"), ("restore", "Restore them"), ("fresh", "Import as new")] { + for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { pick = Some(v); } diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c..c3c4d334 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -172,7 +172,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd316..d68c1355 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -104,6 +104,16 @@ runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk is touched. Agents use `folder.scanChanges` and `folder.synchronize`. +### Importing a deleted photo again +- Importing a file whose photo is in Recently Deleted brings the photo back, with its edits, rating and albums + (issue #706; before, the import skipped it as "already in the library" while the grid showed it deleted). The + import review checks such files, marks them "Will be restored" and counts them as "in Recently Deleted"; the + choice next to that (Restore them / Import as new / Leave them) now starts on Restore. Files dropped on the window + restore the photo too and say "Restored 1 photo from Recently Deleted". A deleted photo whose file was moved is + relinked to the file it is imported from. Browsing a folder in Local, Auto Import and a Lightroom catalog import + never restore anything. Agents: `library.import` → `onDeleted` defaults to `restore` (`skip` keeps the old + behaviour, `fresh` imports a clean copy and deletes the old record). + ### Folder colour labels - Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders` From 7765255ec6cd2c5af109fa56e31b92b412663c0d Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:36:32 +0200 Subject: [PATCH 2/2] Merge follow-up: whats-new entry under 2026-10-10 --- docs/whats-new.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 68ce9e02..f370f7a0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -11,6 +11,16 @@ - Quitting while a task that can be stopped is running asks first ("Quit Anyway" stops it). - Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`. +### Importing a deleted photo again +- Importing a file whose photo is in Recently Deleted brings the photo back, with its edits, rating and albums + (issue #706; before, the import skipped it as "already in the library" while the grid showed it deleted). The + import review checks such files, marks them "Will be restored" and counts them as "in Recently Deleted"; the + choice next to that (Restore them / Import as new / Leave them) now starts on Restore. Files dropped on the window + restore the photo too and say "Restored 1 photo from Recently Deleted". A deleted photo whose file was moved is + relinked to the file it is imported from. Browsing a folder in Local, Auto Import and a Lightroom catalog import + never restore anything. Agents: `library.import` → `onDeleted` defaults to `restore` (`skip` keeps the old + behaviour, `fresh` imports a clean copy and deletes the old record). + ## 2026-10-09 ### Smart album rules @@ -106,16 +116,6 @@ runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk is touched. Agents use `folder.scanChanges` and `folder.synchronize`. -### Importing a deleted photo again -- Importing a file whose photo is in Recently Deleted brings the photo back, with its edits, rating and albums - (issue #706; before, the import skipped it as "already in the library" while the grid showed it deleted). The - import review checks such files, marks them "Will be restored" and counts them as "in Recently Deleted"; the - choice next to that (Restore them / Import as new / Leave them) now starts on Restore. Files dropped on the window - restore the photo too and say "Restored 1 photo from Recently Deleted". A deleted photo whose file was moved is - relinked to the file it is imported from. Browsing a folder in Local, Auto Import and a Lightroom catalog import - never restore anything. Agents: `library.import` → `onDeleted` defaults to `restore` (`skip` keeps the old - behaviour, `fresh` imports a clean copy and deletes the old record). - ### Folder colour labels - Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders`