Merge PR #726
This commit is contained in:
commit
ee2cd6f4bb
|
|
@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
|||
}
|
||||
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<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
"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)?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
|||
"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"],
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}枚",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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} 张",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
/// 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<ImportCandidate>) -> 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<bool>) {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue