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 9f7d6058..30b269f2 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2605,9 +2605,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")] { @@ -2634,9 +2634,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; @@ -2721,9 +2724,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())); @@ -2750,6 +2754,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 { @@ -2759,14 +2764,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-checklist.md b/docs/parity-checklist.md index c73fdd8a..774120ac 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -106,7 +106,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 4c9a33d3..b1959872 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