From 978312b5cc167331f5c3d6e14e98d1dc6a8fb7e4 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 21:32:45 +1100 Subject: [PATCH] =?UTF-8?q?Fix:=20import=20of=20metadata-less=20exports=20?= =?UTF-8?q?=E2=80=94=20file=20date=20as=20capture=20date,=20in-source=20co?= =?UTF-8?q?pies=20not=20"already=20in=20the=20library",=20Grid=20Info=20fr?= =?UTF-8?q?om=20Photo=20Grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Files with no capture date in their metadata or sidecar (e.g. exports saved without metadata) take the file's modification time, as Lightroom does, instead of all landing under "Unknown Date" (the loupe overlay showed the import date for them). The import review and its folder/rename templates use the same date. - The import review counted files with the same bytes as another file in the source as "already in the library" (8 on a brand-new, empty library); they are now shown apart as "identical copies". - View > Grid Info captions are drawn on Square Grid only; choosing one from Photo Grid now switches to Square Grid instead of seeming to do nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/files.rs | 39 ++++++++++++++++++++- crates/engine/src/import.rs | 6 ++++ crates/engine/src/media.rs | 4 +++ crates/engine/src/tests_import.rs | 3 ++ crates/engine/src/tests_import_move.rs | 3 ++ crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/headless.rs | 14 +++++++- crates/ui-egui/src/import.rs | 31 ++++++++++++++-- crates/ui-egui/src/menus.rs | 4 +++ crates/ui-egui/src/tests_masking.rs | 4 +++ docs/parity-checklist.md | 2 +- 28 files changed, 140 insertions(+), 24 deletions(-) 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 |