diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..4d418bc46 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -176,6 +176,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { kind: MediaKind::Raw, file_size: bytes.len() as u64, captured, + file_modified: None, meta, as_shot_wb, content_hash, @@ -207,6 +208,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { kind: MediaKind::Image, file_size: bytes.len() as u64, captured, + file_modified: None, meta, as_shot_wb: None, content_hash, @@ -856,11 +858,21 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); let _permit = crate::memory::work_gate().acquire(len); let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - probe_bytes(path, &bytes) + let mut info = probe_bytes(path, &bytes)?; + info.file_modified = file_modified(path); + Ok(info) }); (loader, probe) } +/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the +/// filesystem doesn't say or the time is before 1970. +fn file_modified(path: &str) -> Option { + let t = std::fs::metadata(path).ok()?.modified().ok()?; + let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; + Some(crate::import::civil(secs)) +} + impl crate::Session { /// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos /// runner ([`crate::apple_photos::osascript`]; installed, not run). @@ -985,6 +997,31 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as + /// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. + #[test] + fn undated_files_take_the_file_date() { + let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rgb = vec![128u8; 16 * 16 * 3]; + let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let path = dir.join("export.jpg"); + std::fs::write(&path, &jpeg).unwrap(); + let path = path.to_string_lossy().to_string(); + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let want = crate::import::civil(mtime as i64); + let (_, probe) = fs_hooks(); + let info = probe(&path).unwrap(); + assert_eq!(info.captured, None, "the file itself has no date"); + assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); + let mut s = crate::Session::new().with_fs(); + let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); + let id = lightcraft_catalog::PhotoId(r.imported[0]); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); + let _ = std::fs::remove_dir_all(&dir); + } + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated /// file or one that isn't an image. diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 20d1983e1..e4abf6fc9 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -641,6 +641,8 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress Some(Ok(info)) => { (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); + // as the import will date it (a sidecar's date, when there is one, still wins there) + c.captured = c.captured.or_else(|| info.file_modified.clone()); c.preview_only = info.preview_only.clone(); if let Some(h) = &info.content_hash { match seen_hash.get(h) { @@ -974,6 +976,10 @@ impl ImportJob { if info.captured.is_none() { info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); } + // no date in the file or its sidecar: the file's own date, as Lightroom does + if info.captured.is_none() { + info.captured = info.file_modified.clone(); + } let stored = match (mode, &self.copy_root) { (ImportMode::Copy | ImportMode::Move, Some(root)) if !crate::import_move::inside(Path::new(&path), root) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..b1f3a2022 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1479,6 +1479,10 @@ pub struct ProbeInfo { pub kind: MediaKind, pub file_size: u64, pub captured: Option, + /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the + /// capture date of a file whose metadata has none, as Lightroom does (exports saved without + /// metadata would otherwise all land under "Unknown Date"). + pub file_modified: Option, pub meta: lightcraft_catalog::Meta, pub as_shot_wb: Option<(f64, f64)>, /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 185993494..e595fa9b7 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -21,6 +21,9 @@ fn write_png(path: &Path, seed: u8) { let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); + // undated files take the file's date: the tests' clock time (2026-01-14T05:58:48) + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); + std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); } fn ids(v: &Value, key: &str) -> usize { diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs index 6a19a5e5a..3e6e9208b 100644 --- a/crates/engine/src/tests_import_move.rs +++ b/crates/engine/src/tests_import_move.rs @@ -22,6 +22,9 @@ fn write_png(path: &Path, seed: u8) { let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); + // undated files take the file's date: the tests' clock time (2026-01-14T05:58:48) + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); + std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); } fn xmp(rating: u8) -> String { diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..22d707561 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "· {in_source} identical copies": "· {in_source} identische Kopien" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..769cfc79f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,6 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..24b28fbaf 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "· {in_source} identical copies": "· {in_source} copias idénticas" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..1e7ecd6d1 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..361613731 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "· {in_source} identical copies": "· {in_source} copies identiques" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..3676a692b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..af3a5560b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "· {in_source} identical copies": "・同一のコピー {in_source} 件" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..0d3e6e210 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,6 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..115d7ec8b 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "· {in_source} identical copies": "· {in_source} cópias idênticas" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..301475ef5 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..c15364d78 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "· {in_source} identical copies": "· идентичных копий: {in_source}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..02aea83d7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,6 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..a5c0e6e68 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "· {in_source} identical copies": "· ідентичних копій: {in_source}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..a50ad26e2 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,6 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..87741a934 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "· {in_source} identical copies": "· {in_source} 个相同副本" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..5fa024d57 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,6 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..0cca3a08d 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "· {in_source} identical copies": "· {in_source} 個相同副本" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..615abf87a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..5ceca0f0c 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3282,10 +3282,16 @@ mod tests { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join("IMG_01.png"), png).unwrap(); + // undated files are filed by the file's date (2026-01-14T05:58:48) + std::fs::File::options() + .write(true) + .open(src.join("IMG_01.png")) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); let dest_s = dest.to_string_lossy().to_string(); let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; let mut session = lightcraft_engine::Session::with_demo().with_fs(); - // undated files are filed by the import time session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); let mut app = LightcraftApp::new(session, services); app.ui.view = crate::state::ViewMode::PhotoGrid; @@ -3335,6 +3341,12 @@ mod tests { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); + std::fs::File::options() + .write(true) + .open(src.join(format!("IMG_0{i}.png"))) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); } let dest_s = dest.to_string_lossy().to_string(); let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..de9c1b833 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -911,19 +911,31 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) true } +/// Skipped candidates: (already in the library, identical copies within the source, in Recently +/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with +/// the same bytes as one earlier in the list (no library photo) is a copy in the source. +fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { + let trashed = d.trashed.iter().filter(|t| **t).count(); + let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); + (dups, in_source, trashed) +} + /// The dialog body: options, then the candidate grid. 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 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 (dups, in_source, trashed) = dup_counts(d); 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 in_source > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); + } if trashed > 0 { ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); } @@ -1524,6 +1536,19 @@ mod tests { ImportDialog::new(candidates) } + /// Identical copies inside the source folder are not "already in the library" (a fresh library + /// said "8 already in the library"). + #[test] + fn copies_within_the_source_are_not_counted_as_in_the_library() { + let mut d = dialog(); + assert_eq!(dup_counts(&d), (0, 1, 0), "same bytes as an earlier file, no library photo"); + d.candidates[4].duplicate = Some("content".into()); + d.candidates[4].existing = Some(7); + d.candidates[5].duplicate = Some("path".into()); + d.candidates[5].existing = Some(8); + assert_eq!(dup_counts(&d), (2, 1, 0)); + } + /// A toggle per file type: the raws only, without the JPEGs beside them (issue #344). #[test] fn file_types_check_and_uncheck_together() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..f68d6ed20 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -379,6 +379,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..b09b42763 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1122,6 +1122,10 @@ fn grid_info_cycles_caption() { exec(&mut h, "view.squareGrid", json!({})); exec(&mut h, "view.gridInfo", json!({"info": "exposure"})); h.settle(SETTLE); + // Photo Grid has no captions: picking one shows it in Square Grid + exec(&mut h, "view.photoGrid", json!({})); + exec(&mut h, "view.gridInfo", json!({"info": "date"})); + assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); } #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..5e38cf127 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -684,7 +684,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | -| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | +| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; picking one in Photo Grid switches to Square Grid); no people view | | LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |