mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Lightroom import: report skipped images as data and say so in the UI language
Review of #2230: the skipped-image warnings were English text shown as is in the result window. Skipped images are now data in the import and inspect reports (`skipped`: image id and reason: missingFile, missingFolder, missingRoot, incompletePath, masterSkipped) instead of warnings, and the result window adds one line counting them through tr_format!, translated in every UI language; the eight-warning cap is unchanged. Reader-level tests now cover a missing folder and root record and a virtual copy of a skipped master read from a catalog file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bb9fe91bf2
commit
9eec26804d
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings,"skipped":data.skipped}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
|
||||
@@ -524,6 +524,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +548,7 @@ mod tests {
|
||||
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
|
||||
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
|
||||
warnings: vec!["one warning".into()],
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,34 @@ pub struct CatalogImport {
|
||||
pub members: Vec<Row>,
|
||||
pub collection_content: Vec<Row>,
|
||||
pub warnings: Vec<String>,
|
||||
/// Catalog images left out because their records are incomplete; reported as data (not text)
|
||||
/// so each front end can say so in its own language.
|
||||
#[serde(default)]
|
||||
pub skipped: Vec<SkippedImage>,
|
||||
}
|
||||
|
||||
/// A catalog image that was not imported, and why.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SkippedImage {
|
||||
/// The image's `Adobe_images.id_local`.
|
||||
pub image: i64,
|
||||
pub reason: SkipReason,
|
||||
}
|
||||
|
||||
/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SkipReason {
|
||||
/// Its `AgLibraryFile` record is gone.
|
||||
MissingFile,
|
||||
/// Its file's `AgLibraryFolder` record is gone.
|
||||
MissingFolder,
|
||||
/// Its folder's `AgLibraryRootFolder` record is gone.
|
||||
MissingRoot,
|
||||
/// Its file name or root folder path is empty.
|
||||
IncompletePath,
|
||||
/// A virtual copy whose master was skipped.
|
||||
MasterSkipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
@@ -522,23 +550,24 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
members: optional_table("AgLibraryCollectionImage", &mut warnings)?,
|
||||
collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?,
|
||||
warnings,
|
||||
skipped: Vec::new(),
|
||||
};
|
||||
// images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves
|
||||
// these behind and ignores them); they and their virtual copies are skipped with a warning
|
||||
// these behind and ignores them); they and their virtual copies are skipped and reported
|
||||
// instead of failing the import
|
||||
let mut orphans = HashSet::new();
|
||||
for image in table("Adobe_images", true)? {
|
||||
ensure_read_active(cancel)?;
|
||||
let id = number(&image, "id_local");
|
||||
let record = files.get(&number(&image, "rootFile")).ok_or("missing file record").and_then(|file| {
|
||||
let folder = folders.get(&number(file, "folder")).ok_or("missing folder record")?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or("missing root folder record")?;
|
||||
let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| {
|
||||
let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?;
|
||||
Ok((file, folder, root))
|
||||
});
|
||||
let (file, folder, root) = match record {
|
||||
Ok(found) => found,
|
||||
Err(why) => {
|
||||
out.warnings.push(format!("image {id}: not imported; {why}"));
|
||||
Err(reason) => {
|
||||
out.skipped.push(SkippedImage { image: id, reason });
|
||||
orphans.insert(id);
|
||||
continue;
|
||||
}
|
||||
@@ -548,7 +577,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
let folder_path = text(folder, "pathFromRoot");
|
||||
let name = text(file, "idx_filename");
|
||||
if root_path.is_empty() || name.is_empty() {
|
||||
out.warnings.push(format!("image {id}: not imported; incomplete original path"));
|
||||
out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath });
|
||||
orphans.insert(id);
|
||||
continue;
|
||||
}
|
||||
@@ -584,23 +613,23 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop the virtual copies whose master was not imported, saying so in the import's warnings: a
|
||||
/// copy has no original of its own.
|
||||
/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has
|
||||
/// no original of its own.
|
||||
fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet<i64>) {
|
||||
if masters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
data.photos.retain(|p| {
|
||||
let master = number(&p.image, "masterImage");
|
||||
// 0 marks a master, never a copy
|
||||
if master == 0 || !masters.contains(&master) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
|
||||
skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped });
|
||||
false
|
||||
});
|
||||
data.warnings.extend(warnings);
|
||||
data.skipped.extend(skipped);
|
||||
}
|
||||
|
||||
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
|
||||
@@ -1054,7 +1083,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
|
||||
}
|
||||
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
|
||||
Ok(AppliedImport {
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
index: index.clone(),
|
||||
})
|
||||
}
|
||||
@@ -1098,6 +1127,7 @@ mod tests {
|
||||
],
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
|
||||
@@ -1557,6 +1587,11 @@ mod tests {
|
||||
}
|
||||
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
|
||||
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
|
||||
let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect();
|
||||
native_catalog_rows(&tables)
|
||||
}
|
||||
/// A SQLite file with one leaf page per table: `(name, columns, rows)` each.
|
||||
fn native_catalog_rows(tables: &[(&str, &str, Vec<Vec<SqlValue>>)]) -> Vec<u8> {
|
||||
use SqlValue::{Integer as I, Text as T};
|
||||
let mut bytes = vec![0u8; 5 * 2048];
|
||||
bytes[..16].copy_from_slice(b"SQLite format 3\0");
|
||||
@@ -1570,7 +1605,7 @@ mod tests {
|
||||
bytes[44..48].copy_from_slice(&4u32.to_be_bytes());
|
||||
bytes[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
let mut schema = Vec::new();
|
||||
for (i, (name, columns, row)) in tables.iter().enumerate() {
|
||||
for (i, (name, columns, rows)) in tables.iter().enumerate() {
|
||||
schema.push(vec![
|
||||
T("table".into()),
|
||||
T((*name).into()),
|
||||
@@ -1578,7 +1613,7 @@ mod tests {
|
||||
I(i as i64 + 2),
|
||||
T(format!("CREATE TABLE {name} ({columns})")),
|
||||
]);
|
||||
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
|
||||
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows);
|
||||
}
|
||||
leaf(&mut bytes[..2048], 100, &schema);
|
||||
bytes
|
||||
@@ -1617,38 +1652,73 @@ mod tests {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_without_a_file_record_are_skipped_not_fatal() {
|
||||
let mut tables = photo_tables(("/catalog/", ""));
|
||||
tables[3].2[2] = SqlValue::Integer(2);
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-orphan-catalog-{}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, native_catalog(&tables)).unwrap();
|
||||
/// Reads `tables` as a catalog file and returns what the reader made of it.
|
||||
fn read_tables(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], name: &str) -> CatalogImport {
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, native_catalog_rows(tables)).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(catalog.photos.is_empty());
|
||||
assert!(catalog.warnings.iter().any(|w| w == "image 1: not imported; missing file record"), "{:?}", catalog.warnings);
|
||||
catalog
|
||||
}
|
||||
|
||||
fn one_row(tables: Vec<(&'static str, &'static str, Vec<SqlValue>)>) -> Vec<(&'static str, &'static str, Vec<Vec<SqlValue>>)> {
|
||||
tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_with_an_empty_file_name_are_skipped_not_fatal() {
|
||||
let mut tables = photo_tables(("/catalog/", ""));
|
||||
tables[2].2[2] = SqlValue::Text(String::new());
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-nameless-catalog-{}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, native_catalog(&tables)).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(catalog.photos.is_empty());
|
||||
assert!(catalog.warnings.iter().any(|w| w == "image 1: not imported; incomplete original path"), "{:?}", catalog.warnings);
|
||||
fn images_with_incomplete_records_are_skipped_not_fatal() {
|
||||
use SqlValue::{Integer as I, Text as T};
|
||||
// (table, column, value): the record that breaks, and the reason the import gives
|
||||
let cases = [
|
||||
(3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file
|
||||
(2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder
|
||||
(1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root
|
||||
(2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty
|
||||
];
|
||||
for (table, column, value, reason) in cases {
|
||||
let mut tables = one_row(photo_tables(("/catalog/", "")));
|
||||
tables[table].2[0][column] = value;
|
||||
let catalog = read_tables(&tables, &format!("skipped-{reason:?}"));
|
||||
assert!(catalog.photos.is_empty(), "{reason:?}");
|
||||
assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]);
|
||||
assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_of_a_skipped_master_are_dropped() {
|
||||
fn a_virtual_copy_of_a_skipped_master_is_skipped_too() {
|
||||
use SqlValue::{Integer as I, Null as N, Text as T};
|
||||
let mut tables = one_row(photo_tables(("/catalog/", "")));
|
||||
tables[3] = (
|
||||
"Adobe_images",
|
||||
"id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick",
|
||||
vec![
|
||||
// the master's file record is gone, so it is skipped …
|
||||
vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)],
|
||||
// … and its virtual copy goes with it, although its own file record is there
|
||||
vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)],
|
||||
],
|
||||
);
|
||||
let catalog = read_tables(&tables, "skipped-copy");
|
||||
assert!(catalog.photos.is_empty());
|
||||
assert_eq!(
|
||||
catalog.skipped,
|
||||
[SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }]
|
||||
);
|
||||
validate(&catalog).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_images_are_in_the_import_report() {
|
||||
let mut data = sample();
|
||||
data.photos.retain(|p| p.source_id != 1);
|
||||
drop_copies_of(&mut data, &HashSet::from([1]));
|
||||
assert!(data.photos.is_empty());
|
||||
assert_eq!(data.warnings, ["image 2: not imported; its original was not imported"]);
|
||||
validate(&data).unwrap();
|
||||
data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile });
|
||||
let mut s = crate::Session::new();
|
||||
let report = apply(&mut s, data, false).unwrap();
|
||||
assert_eq!(report["photos"], 0);
|
||||
assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}]));
|
||||
assert_eq!(report["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -355,6 +355,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +400,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
},
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
|
||||
"{n} preset{}": "{n} preset{}",
|
||||
"{count} more warnings.": "{count} more warnings.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Neimportované obrázky katalogu: {skipped}. Jejich záznamy v katalogu jsou neúplné.",
|
||||
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
|
||||
"Applied {name}": "Applied {name}",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet",
|
||||
"· {in_source} identical copies": "· {in_source} identische Kopien",
|
||||
"{count} more warnings.": "{count} weitere Warnungen.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} Katalogbilder wurden nicht importiert, weil ihre Einträge im Katalog unvollständig sind.",
|
||||
"{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen.",
|
||||
"Applied {name}": "{name} angewendet",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"{n} originals opened in the other editor": "{n} originales abiertos en el otro editor",
|
||||
"· {in_source} identical copies": "· {in_source} copias idénticas",
|
||||
"{count} more warnings.": "{count} advertencias más.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "No se importaron {skipped} imágenes del catálogo porque sus registros en el catálogo están incompletos.",
|
||||
"{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos.",
|
||||
"Applied {name}": "Se aplicó {name}",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur",
|
||||
"· {in_source} identical copies": "· {in_source} copies identiques",
|
||||
"{count} more warnings.": "{count} avertissements supplémentaires.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} images du catalogue n’ont pas été importées, car leurs entrées dans le catalogue sont incomplètes.",
|
||||
"{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens.",
|
||||
"Applied {name}": "{name} appliqué",
|
||||
|
||||
@@ -250,6 +250,7 @@
|
||||
"{s} s of AV1 video": "{s} s of AV1 video",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.",
|
||||
"{count} more warnings.": "{count} more warnings.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} immagini del catalogo non sono state importate perché i loro record nel catalogo sono incompleti.",
|
||||
"· {in_source} identical copies": "· {in_source} identical copies",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
|
||||
"{n} preset{}": "{n} preset{}",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました",
|
||||
"· {in_source} identical copies": "・同一のコピー {in_source} 件",
|
||||
"{count} more warnings.": "ほかに{count}件の警告があります。",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "カタログ内のレコードが不完全なため、{skipped}件の画像を読み込めませんでした。",
|
||||
"{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。",
|
||||
"Applied {name}": "{name} を適用しました",
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
"Updated {name}": "Updated {name}",
|
||||
"new, inside {parent}": "new, inside {parent}",
|
||||
"{count} more warnings.": "{count} more warnings.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "კატალოგის {skipped} სურათი არ დაიმპორტდა, რადგან მათი ჩანაწერები კატალოგში არასრულია.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
|
||||
"{n} JPEG files": "{n} JPEG files",
|
||||
"{n} pages": "{n} pages",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
|
||||
"{n} preset{}": "{n} preset{}",
|
||||
"{count} more warnings.": "{count} more warnings.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.",
|
||||
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
|
||||
"Applied {name}": "Applied {name}",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"{n} originals opened in the other editor": "{n} originais abertos no outro editor",
|
||||
"· {in_source} identical copies": "· {in_source} cópias idênticas",
|
||||
"{count} more warnings.": "Mais {count} avisos.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} imagens do catálogo não foram importadas porque seus registros no catálogo estão incompletos.",
|
||||
"{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente.",
|
||||
"Applied {name}": "{name} aplicado",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}",
|
||||
"· {in_source} identical copies": "· идентичных копий: {in_source}",
|
||||
"{count} more warnings.": "Других предупреждений: {count}.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не импортированы изображения каталога: {skipped}. Их записи в каталоге неполные.",
|
||||
"{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки.",
|
||||
"Applied {name}": "Применено: {name}",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}",
|
||||
"· {in_source} identical copies": "· ідентичних копій: {in_source}",
|
||||
"{count} more warnings.": "Інших попереджень: {count}.",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не імпортовано зображення каталогу: {skipped}. Їхні записи в каталозі неповні.",
|
||||
"{photos} catalog records processed; {collections} collections.": "Оброблено записів каталогу: {photos}; колекцій: {collections}.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не знайдено оригінали: {missing}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання.",
|
||||
"Applied {name}": "Застосовано: {name}",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件",
|
||||
"· {in_source} identical copies": "· {in_source} 个相同副本",
|
||||
"{count} more warnings.": "另有 {count} 条警告。",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 张图像未导入,因为它们在目录中的记录不完整。",
|
||||
"{photos} catalog records processed; {collections} collections.": "已处理 {photos} 条目录记录;{collections} 个收藏集。",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。",
|
||||
"Applied {name}": "已应用 {name}",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案",
|
||||
"· {in_source} identical copies": "· {in_source} 個相同副本",
|
||||
"{count} more warnings.": "另有 {count} 則警告。",
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 張影像未匯入,因為它們在目錄中的記錄不完整。",
|
||||
"{photos} catalog records processed; {collections} collections.": "已處理 {photos} 筆目錄記錄;{collections} 個收藏集。",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。",
|
||||
"Applied {name}": "已套用 {name}",
|
||||
|
||||
@@ -235,6 +235,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) ->
|
||||
"collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(),
|
||||
"missing": data.photos.iter().filter(|p| !p.original_is_file()).map(|p| &p.path).collect::<Vec<_>>(),
|
||||
"warnings": data.warnings,
|
||||
"skipped": data.skipped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,6 +320,15 @@ fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog {
|
||||
missing = missing
|
||||
));
|
||||
}
|
||||
// images whose catalog records are incomplete come as data (`skipped`), said here in the UI
|
||||
// language; their ids are in the report for the CLI and agents
|
||||
let skipped = report["skipped"].as_array().map_or(0, Vec::len);
|
||||
if skipped > 0 {
|
||||
details.push(crate::i18n::tr_format!(
|
||||
"{skipped} catalog images were not imported because their records in the catalog are incomplete.",
|
||||
skipped = skipped
|
||||
));
|
||||
}
|
||||
if let Some(warning) = value.get("indexWarning").and_then(Value::as_str) {
|
||||
details.push(warning.to_string());
|
||||
}
|
||||
@@ -669,6 +679,40 @@ mod tests {
|
||||
assert!(h.app.lightroom_result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_images_are_one_line_in_the_ui_language() {
|
||||
let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [
|
||||
{"image": 7, "reason": "missingFile"},
|
||||
{"image": 8, "reason": "missingRoot"},
|
||||
{"image": 9, "reason": "masterSkipped"},
|
||||
]}});
|
||||
let details = |locale| {
|
||||
crate::i18n::set_language(locale);
|
||||
let dialog = result_dialog(&value, "done");
|
||||
crate::i18n::set_language(crate::i18n::Locale::En);
|
||||
let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") };
|
||||
details
|
||||
};
|
||||
let en = details(crate::i18n::Locale::En);
|
||||
assert_eq!(en.len(), 4, "{en:?}");
|
||||
assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete.");
|
||||
assert_eq!(&en[2..], ["w1", "w2"]);
|
||||
let es = details(crate::i18n::Locale::Es);
|
||||
assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_images_do_not_take_the_warning_slots() {
|
||||
let warnings: Vec<_> = (0..10).map(|i| format!("w{i}")).collect();
|
||||
let skipped: Vec<_> = (0..500).map(|i| json!({"image": i, "reason": "missingFile"})).collect();
|
||||
let value = json!({"report": {"warnings": warnings, "skipped": skipped}});
|
||||
let crate::state::Dialog::LightroomResult { details, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") };
|
||||
// one line for the skipped images, then the usual eight warnings and the rest counted
|
||||
assert_eq!(details.len(), 10, "{details:?}");
|
||||
assert!(details[0].starts_with("500 catalog images were not imported"));
|
||||
assert_eq!(details[9], "2 more warnings.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_report_keeps_empty_catalog_shape() {
|
||||
let report = inspect_report(CatalogImport {
|
||||
@@ -678,6 +722,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: vec!["warning".into()],
|
||||
skipped: Vec::new(),
|
||||
});
|
||||
assert_eq!(report["photos"], 0);
|
||||
assert_eq!(report["collections"], 0);
|
||||
|
||||
@@ -30,7 +30,9 @@ again. Previously browsed Local records join All Photos even when their original
|
||||
their promotion is counted once and undone together with the catalog import.
|
||||
An image whose file, folder or root folder record is gone from the catalog, or whose original path
|
||||
is empty (Lightroom leaves these behind and ignores them), is skipped together with its virtual
|
||||
copies instead of failing the whole import, with a warning for each.
|
||||
copies instead of failing the whole import. The result lists them as data (`skipped`: each
|
||||
image id with its reason, `missingFile`, `missingFolder`, `missingRoot`, `incompletePath` or
|
||||
`masterSkipped`), also in `library.inspectLightroom`, and the desktop result window says how many in the UI language.
|
||||
Virtual copies count as added records too; reimport reports zero additions when all
|
||||
records already exist. Existing LightCraft
|
||||
edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import
|
||||
|
||||
@@ -698,7 +698,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| 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; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies, each with a warning, instead of failing the whole import (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) |
|
||||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) |
|
||||
| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
|
||||
Reference in New Issue
Block a user