Compare commits

...
Author SHA1 Message Date
skylartaylor a7657c2958 LRC-LIB-CATALOG-IMPORT: reconcile import with current main
Preserve upstream phased progress and typed cancellation alongside deduplicated original probing and offline import results. Adapt result-dialog tests to the consolidated progress helper. Preserve both sets of locale messages and current progress-document entries.

Validated the combined tree with all eight cargo xtask ci gates (3,462 passing test executions), fresh 30k import and saved-record checks, duplicate-free same-session and reopened reimports, and inspected headless screenshots. Astra integration review findings are addressed.

Generated with Codex.
2026-10-15 03:41:31 +02:00
skylartaylor f730805dc3 LRC-LIB-CATALOG-IMPORT: address Astra review and trim import work
Promote offline Local masters and virtual copies, count each promotion once, and dismiss visible import results when switching libraries. Avoid cloning catalog operations solely for cache invalidation and borrow full-strength develop patches without serializing unused settings.

Cover promotion/undo/reimport, cache rollback and undo/redo, and visible result dismissal. Bound the contact-sheet workflow fixture and poll its real worker directly, preserving PDF assertions and the 20-second deadline while reporting worker errors clearly.

Generated with Codex.
2026-10-15 01:30:25 +02:00
skylartaylor 0ec463568b LRC-LIB-CATALOG-IMPORT: report offline records and keep import results readable
Avoid repeated missing-original probes and retain staged photo copies only for virtual-copy masters. Preserve the destination and warnings in a result dialog with Show in Library, and reset every filter constraint through the existing commands.

Generated with Codex.
2026-10-13 04:53:04 +02:00
34 changed files with 543 additions and 63 deletions

No files matched your search

+2 -2
View File
@@ -10,6 +10,7 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::borrow::Cow;
use crate::DevelopSettings;
@@ -185,8 +186,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
/// through the control specs.
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let base = s.to_json();
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
for c in crate::controls::CONTROLS {
+9 -3
View File
@@ -506,16 +506,22 @@ impl Session {
}
fn apply_catalog(&mut self, op: Op) -> Result<Op> {
let inv = self.catalog.apply(op.clone())?;
// Retain only cache IDs while the catalog consumes the operation. Cloning an import
// batch here also cloned every photo, although commit already keeps the journal copy.
let mut invalidated = Vec::new();
let mut pending = vec![&op];
while let Some(op) = pending.pop() {
match op {
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id),
Op::AddPhoto { photo } => self.media.forget(photo.id),
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id),
Op::AddPhoto { photo } => invalidated.push(photo.id),
Op::Batch { ops } => pending.extend(ops),
_ => {}
}
}
let inv = self.catalog.apply(op)?;
for id in invalidated {
self.media.forget(id);
}
Ok(inv)
}
+137 -7
View File
@@ -698,14 +698,22 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
let mut ops = Vec::new();
let now = (s.clock)();
let mut missing = Vec::new();
// Normal import counts probed originals; catalog import also adds offline records,
// distinct originals with identical bytes, and virtual copies.
let mut imported = files.imported.len();
let mut promoted = HashSet::new();
let mut unmapped = BTreeMap::new();
let mut preserved = 0;
// Masters precede their virtual copies.
let mut photos: Vec<_> = data.photos.iter().collect();
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
let mut planned: HashMap<PhotoId, Photo> = HashMap::new();
for src in photos {
let master = number(&src.image, "masterImage");
if master == 0 && missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
let indexed = index
.photos
.get(&identity(&data, src))
@@ -736,9 +744,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
} else {
let id = s.catalog.alloc_photo_id();
let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone());
if missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
let mut p = Photo::new(
id,
Source::File { path: src.path.clone() },
@@ -807,7 +812,21 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
if keep_develop {
(p.develop, p.edited) = previous_develop;
}
// Missing files bypass normal-file preparation, including its Local promotion.
// Import still joins the library, and a virtual copy must inherit that membership.
let was_local = p.local;
p.local = false;
let photo_id = p.id;
// Only virtual-copy masters need a second owned photo while operations are staged.
// Keeping every photo here doubled the new-record working set on large catalogs.
if copy_masters.contains(&src.source_id) {
planned.insert(photo_id, p.clone());
}
if s.catalog.photo(p.id).is_some() {
if was_local && promoted.insert(p.id) {
imported += 1;
ops.push(Op::SetLocal { id: p.id, local: false });
}
ops.extend([
Op::SetRating { id: p.id, rating: p.rating },
Op::SetFlag { id: p.id, flag: p.flag },
@@ -817,10 +836,10 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
]);
ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() });
} else {
ops.push(Op::AddPhoto { photo: Box::new(p.clone()) });
imported += 1;
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
by_path.entry(key(&src.path)).or_insert(p.id);
planned.insert(p.id, p);
by_path.entry(key(&src.path)).or_insert(photo_id);
}
let mut albums = HashMap::new();
for collection in &data.collections {
@@ -893,7 +912,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
}
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
Ok(AppliedImport {
report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"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,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
index: index.clone(),
})
}
@@ -938,12 +957,62 @@ mod tests {
warnings: Vec::new(),
}
}
#[test]
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
let mut s = crate::Session::new();
let data = sample();
let master_id = s.catalog.alloc_photo_id();
let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before");
master.local = true;
s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap();
s.undo.clear();
let before = s.catalog.photo(master_id).unwrap().clone();
let mut index = ImportIndex::default();
let missing_sources = [key("/missing/test.ARW")].into_iter().collect();
let mut import = |s: &mut crate::Session| {
let undo0 = s.undo.len();
apply_prepared(
s,
data.clone(),
ApplyPrepared {
update_existing: false,
files: Default::default(),
index: &mut index,
archive_path: None,
missing_sources: &missing_sources,
undo0,
},
)
.unwrap()
.report
};
let first = import(&mut s);
assert_eq!(first["mapping"]["1"], master_id.0);
assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos");
assert_eq!(first["missing"], json!(["/missing/test.ARW"]));
assert_eq!(s.catalog.len(), 2);
assert!(s.catalog.photos().all(|photo| !photo.local));
assert_eq!(s.undo.len(), 1);
let second = import(&mut s);
assert_eq!(second["imported"], 0);
assert_eq!(second["mapping"], first["mapping"]);
s.undo_step().unwrap();
s.undo_step().unwrap();
assert_eq!(s.catalog.len(), 1);
assert_eq!(s.catalog.photo(master_id), Some(&before));
assert!(s.catalog.albums().next().is_none());
let third = import(&mut s);
assert_eq!(third["imported"], 2);
assert!(s.catalog.photos().all(|photo| !photo.local));
}
#[test]
fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() {
let mut s = crate::Session::new();
let data = sample();
let r = apply(&mut s, data.clone(), false).unwrap();
assert_eq!(r["photos"], 2);
assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added");
assert_eq!(r["missing"].as_array().unwrap().len(), 1);
let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap();
assert_eq!(master.rating, 5);
@@ -1241,6 +1310,10 @@ mod tests {
s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap();
let second = apply(&mut s, data, true).unwrap();
assert_eq!(first["mapping"], second["mapping"]);
assert_eq!(first["imported"], 2);
assert_eq!(second["imported"], 0, "reimport updates records instead of adding them");
assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too");
assert_eq!(second["library"], dir.to_string_lossy().as_ref());
assert_eq!(s.catalog.len(), 2);
assert_eq!(s.catalog.albums().count(), 2);
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56"));
@@ -1359,6 +1432,63 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
#[test]
fn preparation_skips_missing_original_probes_but_keeps_catalog_records() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("source.lrcat");
let root = dir.join("offline");
let tables = photo_tables((root.to_str().unwrap(), ""));
std::fs::write(&source, native_catalog(&tables)).unwrap();
let mut s = crate::Session::new();
let probes = Arc::new(AtomicUsize::new(0));
let calls = probes.clone();
s.media.file_probe = Some(Arc::new(move |_| {
calls.fetch_add(1, Ordering::Relaxed);
Err("a known-missing original must not be probed".into())
}));
let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap();
let prepared = job.prepare(&AtomicBool::new(false)).unwrap();
assert_eq!(probes.load(Ordering::Relaxed), 0);
let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap();
assert_eq!(result.report["imported"], 1);
assert_eq!(result.report["failed"], json!([]));
assert_eq!(result.report["missing"].as_array().unwrap().len(), 1);
let photo = s.catalog.photos().next().unwrap();
assert_eq!(photo.rating, 5);
assert_eq!((photo.width, photo.height), (20, 10));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.catalog.is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn probed_original_is_counted_once_and_reimport_adds_nothing() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id()));
std::fs::create_dir_all(dir.join("Photos")).unwrap();
let source = dir.join("source.lrcat");
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
let mut s = crate::Session::new();
let probes = Arc::new(AtomicUsize::new(0));
let calls = probes.clone();
s.media.file_probe = Some(Arc::new(move |_| {
calls.fetch_add(1, Ordering::Relaxed);
Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() })
}));
for expected in [1, 0] {
let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap();
let prepared = job.prepare(&AtomicBool::new(false)).unwrap();
let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap();
assert_eq!(completion.report["imported"], expected);
assert_eq!(completion.report["missing"], json!([]));
assert_eq!(completion.report["failed"], json!([]));
assert_eq!(s.catalog.len(), 1);
}
assert_eq!(probes.load(Ordering::Relaxed), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_root_missing_here_is_found_relative_to_the_catalog() {
let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id()));
+30 -15
View File
@@ -247,23 +247,14 @@ impl LightroomJob {
if cancel.load(Ordering::Relaxed) {
return Err(cancelled());
}
let paths: Vec<String> = data
.photos
.iter()
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
.map(|photo| photo.path.clone())
.collect();
let missing_sources = data
.photos
.iter()
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
.filter(|photo| !Path::new(&photo.path).is_file())
.map(|photo| normalize(&photo.path))
.collect();
// the catalog step's last unit (validation, and looking for each original), then the photos
let (paths, missing_sources) = original_paths(&data, cancel)?;
// Catalog entries name files, never folders. Missing originals already have enough
// catalog metadata for placeholders; probing them again only adds failed I/O and errors.
// Complete the catalog step before the separate read/add photo progress starts.
self.progress.catalog_done.fetch_add(1, Ordering::Relaxed);
self.progress.photos.store(true, Ordering::Relaxed);
let normal = self.import.prepare(&paths, cancel);
self.import.total.fetch_add(paths.len(), Ordering::Relaxed);
let normal = self.import.prepare_files(paths, cancel);
if cancel.load(Ordering::Relaxed) {
normal.rollback();
return Err(cancelled());
@@ -323,6 +314,30 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom)
Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } })
}
fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(Vec<String>, HashSet<String>)> {
let mut paths = Vec::new();
let mut missing = HashSet::new();
let mut seen = HashSet::new();
for photo in &data.photos {
if cancel.load(Ordering::Relaxed) {
return Err(cancelled());
}
if lightroom_catalog::number_for_job(&photo.image, "masterImage") != 0 {
continue;
}
let key = normalize(&photo.path);
if !seen.insert(key.clone()) {
continue;
}
if Path::new(&photo.path).is_file() {
paths.push(photo.path.clone());
} else {
missing.insert(key);
}
}
Ok((paths, missing))
}
fn normalize(path: &str) -> String {
let path = path.replace('\\', "/");
if cfg!(windows) { path.to_lowercase() } else { path }
+33
View File
@@ -6,6 +6,39 @@ fn demo() -> Session {
Session::with_demo()
}
#[test]
fn nested_catalog_operations_invalidate_sources_after_success_and_on_undo_redo() {
use crate::media::SourceLevel;
use lightcraft_catalog::{Op, PhotoId};
let mut s = demo();
let id = s.active().unwrap();
let image = std::sync::Arc::new(lightcraft_raster::Rgb32f::new(1, 1));
let cache = |s: &mut Session| {
for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] {
s.media.insert(id, level, image.clone());
}
};
cache(&mut s);
// An invalid operation must not throw away usable sources.
assert!(s.apply_catalog(Op::Batch { ops: vec![Op::RemovePhoto { id }, Op::RemovePhoto { id: PhotoId(u64::MAX) }] }).is_err());
assert!(s.catalog.photo(id).is_some(), "the failed batch restores the removed photo");
for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] {
assert!(s.media.has_source(id, level), "the failed batch keeps usable sources");
}
s.commit("Remove", Op::Batch { ops: vec![Op::Batch { ops: vec![Op::RemovePhoto { id }] }] }).unwrap();
for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] {
assert!(!s.media.has_source(id, level));
}
cache(&mut s);
s.undo_step().unwrap();
assert!(s.catalog.photo(id).is_some());
assert!(!s.media.has_source(id, SourceLevel::Full), "undo AddPhoto must invalidate the source too");
cache(&mut s);
s.redo_step().unwrap();
assert!(s.catalog.photo(id).is_none());
assert!(!s.media.has_source(id, SourceLevel::Full));
}
fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt",
"{name} opened in the other editor": "{name} im anderen Editor geöffnet",
"{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet",
"· {in_source} identical copies": "· {in_source} identische Kopien"
"· {in_source} identical copies": "· {in_source} identische Kopien",
"{count} more warnings.": "{count} weitere Warnungen.",
"{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."
}
+6 -1
View File
@@ -2118,5 +2118,10 @@
"Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.",
"On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.",
"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."
"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.",
"Lightroom Catalog Import": "Lightroom-Katalogimport",
"Destination library": "Zielbibliothek",
"Show in Library": "In Bibliothek anzeigen",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom-Kataloge werden in die geöffnete Bibliothek importiert; es wird kein eigener Katalogordner erstellt.",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Diese Fotos befinden sich nur im Arbeitsspeicher. Öffne eine Bibliothek auf dem Datenträger und importiere den Katalog erneut, um sie zu speichern."
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original",
"{name} opened in the other editor": "{name} abierto en el otro editor",
"{n} originals opened in the other editor": "{n} originales abiertos en el otro editor",
"· {in_source} identical copies": "· {in_source} copias idénticas"
"· {in_source} identical copies": "· {in_source} copias idénticas",
"{count} more warnings.": "{count} advertencias más.",
"{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."
}
+6 -1
View File
@@ -1995,5 +1995,10 @@
"Adding to Apple Photos": "Añadiendo a Fotos de Apple",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.",
"On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.",
"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."
"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.",
"Lightroom Catalog Import": "Importación de catálogo de Lightroom",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos solo están en memoria. Abre una biblioteca en disco e importa el catálogo de nuevo para guardarlas.",
"Show in Library": "Mostrar en la biblioteca",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Los catálogos de Lightroom se importan a la biblioteca abierta; no se crea una carpeta de catálogo separada.",
"Destination library": "Biblioteca de destino"
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original",
"{name} opened in the other editor": "{name} ouvert dans l’autre éditeur",
"{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur",
"· {in_source} identical copies": "· {in_source} copies identiques"
"· {in_source} identical copies": "· {in_source} copies identiques",
"{count} more warnings.": "{count} avertissements supplémentaires.",
"{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."
}
+6 -1
View File
@@ -1957,5 +1957,10 @@
"Adding to Apple Photos": "Ajout à Apple Photos",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.",
"On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.",
"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é."
"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é.",
"Lightroom Catalog Import": "Importation de catalogue Lightroom",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ces photos sont uniquement en mémoire. Ouvrez une bibliothèque sur disque et importez à nouveau le catalogue pour les enregistrer.",
"Show in Library": "Afficher dans la bibliothèque",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Les catalogues Lightroom sont importés dans la bibliothèque ouverte ; aucun dossier de catalogue distinct n’est créé.",
"Destination library": "Bibliothèque de destination"
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています",
"{name} opened in the other editor": "{name}を別のエディタで開きました",
"{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました",
"· {in_source} identical copies": "・同一のコピー {in_source} 件"
"· {in_source} identical copies": "・同一のコピー {in_source} 件",
"{count} more warnings.": "ほかに{count}件の警告があります。",
"{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}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。"
}
+6 -1
View File
@@ -1961,5 +1961,10 @@
"Adding to Apple Photos": "Appleの写真に追加中",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。",
"On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。",
"Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。"
"Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。",
"Lightroom Catalog Import": "Lightroomカタログの読み込み",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "これらの写真はメモリ内にのみあります。ディスク上のライブラリを開き、カタログを再度読み込んで保存してください。",
"Show in Library": "ライブラリに表示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroomカタログは開いているライブラリに読み込まれます。別のカタログフォルダーは作成されません。",
"Destination library": "読み込み先ライブラリ"
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original",
"{name} opened in the other editor": "{name} aberto no outro editor",
"{n} originals opened in the other editor": "{n} originais abertos no outro editor",
"· {in_source} identical copies": "· {in_source} cópias idênticas"
"· {in_source} identical copies": "· {in_source} cópias idênticas",
"{count} more warnings.": "Mais {count} avisos.",
"{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."
}
+6 -1
View File
@@ -1998,5 +1998,10 @@
"Recognition models": "Modelos de reconhecimento",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.",
"On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.",
"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."
"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.",
"Lightroom Catalog Import": "Importação de catálogo do Lightroom",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos estão apenas na memória. Abra uma biblioteca no disco e importe o catálogo novamente para salvá-las.",
"Show in Library": "Mostrar na biblioteca",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Os catálogos do Lightroom são importados para a biblioteca aberta; nenhuma pasta de catálogo separada é criada.",
"Destination library": "Biblioteca de destino"
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом",
"{name} opened in the other editor": "{name} открыт в другом редакторе",
"{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}",
"· {in_source} identical copies": "· идентичных копий: {in_source}"
"· {in_source} identical copies": "· идентичных копий: {in_source}",
"{count} more warnings.": "Других предупреждений: {count}.",
"{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}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки."
}
+6 -1
View File
@@ -2151,5 +2151,10 @@
"Adding to Apple Photos": "Добавление в Apple Фото",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.",
"On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.",
"Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый."
"Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.",
"Lightroom Catalog Import": "Импорт каталога Lightroom",
"Destination library": "Библиотека назначения",
"Show in Library": "Показать в библиотеке",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom импортируются в открытую библиотеку; отдельная папка каталога не создаётся.",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Эти фотографии находятся только в памяти. Откройте библиотеку на диске и импортируйте каталог повторно, чтобы сохранить их."
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом",
"{name} opened in the other editor": "{name} відкрито в іншому редакторі",
"{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}",
"· {in_source} identical copies": "· ідентичних копій: {in_source}"
"· {in_source} identical copies": "· ідентичних копій: {in_source}",
"{count} more warnings.": "Інших попереджень: {count}.",
"{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}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання."
}
+6 -1
View File
@@ -2151,5 +2151,10 @@
"Adding to Apple Photos": "Додавання до Apple Фото",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.",
"On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.",
"Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший."
"Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.",
"Lightroom Catalog Import": "Імпорт каталогу Lightroom",
"Destination library": "Бібліотека призначення",
"Show in Library": "Показати в бібліотеці",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom імпортуються у відкриту бібліотеку; окрема папка каталогу не створюється.",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ці фотографії є лише в пам’яті. Відкрийте бібліотеку на диску та імпортуйте каталог повторно, щоб зберегти їх."
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起",
"{name} opened in the other editor": "已在其他编辑器中打开 {name}",
"{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件",
"· {in_source} identical copies": "· {in_source} 个相同副本"
"· {in_source} identical copies": "· {in_source} 个相同副本",
"{count} more warnings.": "另有 {count} 条警告。",
"{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} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。"
}
+6 -1
View File
@@ -1997,5 +1997,10 @@
"Adding to Apple Photos": "正在添加到 Apple 照片",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。",
"On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。",
"Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。"
"Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。",
"Lightroom Catalog Import": "Lightroom 目录导入",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "这些照片仅保存在内存中。请打开磁盘上的图库并再次导入目录以保存照片。",
"Show in Library": "在图库中显示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目录会导入到当前打开的图库中,不会创建单独的目录文件夹。",
"Destination library": "目标图库"
}
+4 -1
View File
@@ -247,5 +247,8 @@
"{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起",
"{name} opened in the other editor": "已在其他編輯器中開啟 {name}",
"{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案",
"· {in_source} identical copies": "· {in_source} 個相同副本"
"· {in_source} identical copies": "· {in_source} 個相同副本",
"{count} more warnings.": "另有 {count} 則警告。",
"{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} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。"
}
+6 -1
View File
@@ -1959,5 +1959,10 @@
"Adding to Apple Photos": "正在加入 Apple 照片",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。",
"On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。",
"Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。"
"Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。",
"Lightroom Catalog Import": "Lightroom 目錄匯入",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的圖庫並再次匯入目錄以儲存相片。",
"Show in Library": "在圖庫中顯示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的圖庫,不會建立獨立的目錄資料夾。",
"Destination library": "目標圖庫"
}
+23 -9
View File
@@ -263,25 +263,39 @@ mod contact_sheet_tests {
})),
..Default::default()
};
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
// Exercise the menu/picker/background PDF workflow with a small procedural original.
// A full-size demo makes this test's deadline depend on unrelated concurrent renders.
let demo = lightcraft_engine::Session::with_demo();
let id = demo.active().unwrap();
let mut photo = (**demo.catalog.photo(id).unwrap()).clone();
photo.width = 96;
photo.height = 64;
let mut session = lightcraft_engine::Session::new();
session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(photo) }).unwrap();
session.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
let mut app = LightcraftApp::new(session, services);
app.run("dialog.contactSheet", json!({})).unwrap();
let Some(Dialog::ContactSheet { options }) = app.ui.dialog.as_mut() else { panic!("no contact sheet settings") };
options.paper = "letter".into();
options.landscape = true;
let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0);
let dialog = h.app.ui.dialog.take().unwrap();
crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap();
assert_eq!(h.app.pending_picks.len(), 1);
let dialog = app.ui.dialog.take().unwrap();
crate::panels::dialogs::confirm_dialog(&mut app, &dialog).unwrap();
assert_eq!(app.pending_picks.len(), 1);
let ctx = egui::Context::default();
crate::pick::poll(&mut app, &ctx);
assert!(app.pending_picks.is_empty());
assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast);
let deadline = Instant::now() + Duration::from_secs(20);
loop {
// The activity stack lays out text, which egui only allows inside a frame.
h.step();
if h.app.last_export_result.is_some() {
// Poll the real worker without starting unrelated gallery renders each frame.
poll(&mut app, &ctx);
if app.export.is_none() {
break;
}
assert!(Instant::now() < deadline, "export did not finish");
std::thread::sleep(Duration::from_millis(10));
}
let result = app.last_export_result.as_ref().unwrap_or_else(|| panic!("export failed: {:?}", app.ui.toast));
let req = asked.lock().unwrap();
assert_eq!(req[0].kind, PickKind::Save);
assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]);
@@ -289,7 +303,7 @@ mod contact_sheet_tests {
let b = bytes.lock().unwrap();
assert!(b.starts_with(b"%PDF-1.4"));
assert!(String::from_utf8_lossy(&b).contains("/MediaBox [0 0 792.00 612.00]"));
assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1);
assert_eq!(result["files"][0]["photos"], 1);
}
#[test]
+3
View File
@@ -383,6 +383,8 @@ pub struct LightcraftApp {
pub lightroom: Option<lightroom_import::LightroomTask>,
/// Last terminal Lightroom result, exposed by the command's status/wait response.
pub lightroom_last: Option<Value>,
/// A completed import result waiting for another modal dialog to close.
pub(crate) lightroom_result: Option<state::Dialog>,
/// The library generation the window title and the recent list were last updated for.
pub shown_library: Option<u64>,
/// A background export in progress.
@@ -476,6 +478,7 @@ impl LightcraftApp {
sync_run: None,
lightroom: None,
lightroom_last: None,
lightroom_result: None,
shown_library: None,
export: None,
tasks: Default::default(),
+116
View File
@@ -243,6 +243,9 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask,
let kind = task.kind;
if let Some(o) = value.as_object_mut() {
o.entry("path").or_insert_with(|| json!(task.path));
if let Some(library) = app.session.library.as_ref().filter(|l| l.on_disk) {
o.entry("library").or_insert_with(|| json!(library.dir.to_string_lossy()));
}
}
let message = if let Some(e) = value.get("error").and_then(Value::as_str) {
let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}"));
@@ -280,11 +283,61 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask,
crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed)
}
};
if matches!(kind, Kind::Import) && value.get("cancelled") != Some(&Value::Bool(true)) {
app.lightroom_result = Some(result_dialog(&value, &message));
show_result(app);
}
app.lightroom_last = Some(value);
app.lightroom = None;
app.toast_for(ctx, message, 6.0);
}
fn show_result(app: &mut LightcraftApp) {
if app.ui.dialog.is_none() {
app.ui.dialog = app.lightroom_result.take();
}
}
fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog {
let report = value.get("report").unwrap_or(value);
let mut details = Vec::new();
if let Some(photos) = report["photos"].as_u64() {
details.push(crate::i18n::tr_format!(
"{photos} catalog records processed; {collections} collections.",
photos = photos,
collections = report["collections"].as_u64().unwrap_or(0)
));
}
if let Some(failures) = report["failed"].as_array() {
details
.extend(failures.iter().take(4).map(|failure| format!("{}: {}", failure[0].as_str().unwrap_or(""), failure[1].as_str().unwrap_or(""))));
}
let missing = report["missing"].as_array().map_or(0, Vec::len);
if missing > 0 {
details.push(crate::i18n::tr_format!(
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
missing = missing
));
}
if let Some(warning) = value.get("indexWarning").and_then(Value::as_str) {
details.push(warning.to_string());
}
if let Some(warnings) = report["warnings"].as_array() {
// Results can contain one warning per image; keep the dialog bounded.
details.extend(warnings.iter().take(8).filter_map(Value::as_str).map(str::to_string));
if warnings.len() > 8 {
details.push(crate::i18n::tr_format!("{count} more warnings.", count = warnings.len() - 8));
}
}
let first = value.get("error").is_none().then(|| report["mapping"].as_object()?.values().filter_map(Value::as_u64).min()).flatten();
crate::state::Dialog::LightroomResult {
summary: summary.to_string(),
library: report["library"].as_str().or_else(|| value["library"].as_str()).map(str::to_string),
details,
first,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result<Value, String> {
busy(app)?;
@@ -348,6 +401,7 @@ pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context
/// Poll owner-thread work, commit a prepared import, and finish its archive index.
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
show_result(app);
let Some(mut task) = app.lightroom.take() else { return };
let message = match task.rx.try_recv() {
Ok(m) => m,
@@ -553,6 +607,68 @@ mod tests {
}
}
#[test]
fn lightroom_result_stays_readable_and_shows_photos_hidden_by_a_filter() {
let mut h = demo();
let id = h.app.session.catalog.photos().next().unwrap().id.0;
h.app.session.source = lightcraft_engine::LibrarySource::Picks;
h.app.session.filter.text = "no-photo-has-this-name".into();
h.app.session.filter.only = vec![lightcraft_catalog::PhotoId(u64::MAX)];
h.app.session.filter.labels = vec![lightcraft_catalog::ColorLabel::Red];
h.app.session.filter.rule_set = Some(lightcraft_catalog::RuleSet {
mode: lightcraft_catalog::rules::Match::All,
rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }],
});
let (task, _tx, _, _) = fake_import(&h);
let value = json!({"photos": 1, "imported": 1, "collections": 0, "missing": ["D:/Photos/a.NEF"], "mapping": {"1": id}, "library": "C:/Pictures/LightCraft Library"});
terminal(&mut h.app, &h.view.ctx, &task, value);
assert!(h.app.ui.toast.as_ref().unwrap().0.contains("1 photos added"));
h.step();
h.step();
let text = h.painted_text().join(" ");
assert!(text.contains("Destination library"), "{text}");
assert!(text.contains("C:/Pictures/LightCraft Library"), "{text}");
assert!(text.contains("1 originals are missing"), "{text}");
assert!(text.contains("Show in Library"), "{text}");
// The activity and toast can go away; the modal result remains until dismissed.
h.app.ui.toast = None;
h.step();
assert!(h.app.ui.dialog.is_some());
let dialog = h.app.ui.dialog.clone().unwrap();
crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap();
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All);
assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default());
assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(id));
assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid);
assert!(!h.app.session.visible().is_empty());
}
#[test]
fn lightroom_failure_stays_readable_without_offering_a_success_action() {
let mut h = demo();
let (task, _tx, _, _) = fake_import(&h);
terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"}));
h.step();
h.step();
assert!(h.painted_text().join(" ").contains("exceeds 1 GiB import limit"));
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { first: None, .. })));
assert!(!h.app.widgets.iter().any(|(id, _)| id == "button:dialogCancel"));
}
#[test]
fn lightroom_result_waits_for_an_existing_dialog() {
let mut h = demo();
h.app.ui.dialog = Some(crate::state::Dialog::About);
let (task, _tx, _, _) = fake_import(&h);
terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog could not be read"}));
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::About)));
assert!(h.app.lightroom_result.is_some());
h.app.ui.dialog = None;
tick(&mut h.app, &h.view.ctx);
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { .. })));
assert!(h.app.lightroom_result.is_none());
}
#[test]
fn inspect_report_keeps_empty_catalog_shape() {
let report = inspect_report(CatalogImport {
+32
View File
@@ -226,6 +226,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::RenameAlbum { .. } => "Rename Album",
Dialog::Rename { .. } => "Rename Photos",
Dialog::Import { .. } => "Import Photos",
Dialog::LightroomResult { .. } => "Lightroom Catalog Import",
Dialog::LabelNames { .. } => "Edit Color Label Names",
Dialog::CaptureTime { .. } => "Edit Capture Time",
Dialog::RenameKeyword { .. } => "Rename Keyword",
@@ -345,6 +346,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted),
Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted),
Dialog::LightroomResult { summary, library, details, .. } => {
ui.label(summary.as_str());
ui.add_space(8.0);
if let Some(library) = library {
ui.label(crate::i18n::tr("Destination library"));
ui.add(egui::Label::new(library.as_str()).wrap().selectable(true));
ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created."));
} else {
ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them."));
}
for detail in details.iter() {
ui.add(egui::Label::new(detail).wrap().selectable(true));
}
}
Dialog::SystemInfo { rows } => {
egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| {
for (k, v) in rows.iter() {
@@ -1199,6 +1214,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
// only shows something (one Close, no Cancel / OK)
let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported");
let informational = unusable_model
|| matches!(dlg, Dialog::LightroomResult { first: None, .. })
|| matches!(
dlg,
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew
@@ -1210,6 +1226,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let cancel = match &dlg {
Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close",
Dialog::SamModel { .. } => "Not Now",
Dialog::LightroomResult { .. } => "Close",
_ => "Cancel",
};
if !informational {
@@ -1227,6 +1244,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
add_label.as_str()
}
Dialog::LightroomResult { first: Some(_), .. } => "Show in Library",
Dialog::ContactSheet { .. } => "Choose PDF…",
Dialog::Merge { .. } => "Merge",
Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download",
@@ -1552,6 +1570,20 @@ pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option<u
pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_json::Value, String> {
match dlg {
Dialog::LightroomResult { first, .. } => {
if let Some(first) = first {
app.run("library.source", json!({"kind": "all"}))?;
let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?;
// The filter command merges a patch; these defaults are omitted by serde.
filter["only"] = json!([]);
filter["labels"] = json!([]);
filter["ruleSet"] = serde_json::Value::Null;
app.run("library.filter", filter)?;
app.run("library.select", json!({"ids": [first]}))?;
app.ui.view = crate::state::ViewMode::PhotoGrid;
}
Ok(serde_json::Value::Null)
}
Dialog::SamModel { then, .. } => {
if app.session.segmenter.installed() {
// installed: start what the user was doing
+4
View File
@@ -956,6 +956,10 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String>
app.session.close_library().map_err(|e| e.to_string())?;
let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}"));
app.lightroom_last = None;
app.lightroom_result = None;
if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) {
app.ui.dialog = None;
}
app.renderer.forget_all();
app.ui.compare = None;
if let Err(err) = opened {
+7
View File
@@ -783,6 +783,13 @@ pub struct NameEdit {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Dialog {
/// A Lightroom import result stays readable after its activity row disappears.
LightroomResult {
summary: String,
library: Option<String>,
details: Vec<String>,
first: Option<u64>,
},
ContactSheet {
options: lightcraft_engine::contact_sheet::Options,
},
+4 -2
View File
@@ -88,8 +88,10 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() {
// library open now. (This fake catalog then fails to read: only its path matters here.)
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true);
h.step();
assert!(h.app.ui.dialog.is_none());
h.step_until(T, |h| h.app.lightroom.is_none());
// A fast read failure can already have replaced the offer with its lasting result.
assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. })));
assert!(h.step_until(T, |h| h.app.lightroom.is_none()));
assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomResult { summary, first: None, .. }) if summary.contains("failed")));
let last = h.app.lightroom_last.clone().unwrap_or_default();
assert_eq!(last["path"], json!(lrcat), "{last}");
assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone()));
@@ -55,6 +55,26 @@ fn app_in(dir: &std::path::Path) -> LightcraftApp {
LightcraftApp::new(session, Services::default())
}
#[test]
fn switching_libraries_dismisses_the_previous_lightroom_result() {
let dir = temp("switch-lightroom-result");
let mut app = app_in(&dir);
let result = || crate::state::Dialog::LightroomResult {
summary: "Imported into first".into(),
library: Some(dir.join("first").to_string_lossy().into_owned()),
details: Vec::new(),
first: Some(1),
};
app.ui.dialog = Some(result());
app.lightroom_result = Some(result());
app.lightroom_last = Some(json!({"imported": 1}));
app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Second"})).unwrap();
assert!(app.ui.dialog.is_none(), "the old destination and photo ID must not act on the new library");
assert!(app.lightroom_result.is_none());
assert!(app.lightroom_last.is_none());
let _ = std::fs::remove_dir_all(dir);
}
fn recent_submenu(app: &LightcraftApp) -> Vec<crate::menubar::MenuNode> {
use crate::menubar::MenuNode;
let bar = crate::menubar::menu_bar(app);
+5 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** patch (gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -27,7 +27,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
| 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) |
| 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) |
| 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) |
@@ -91,3 +91,6 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues |
| 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps |
| 2026-10-13 | minor | Gap 8 narrows: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (`docs/nikon-lens-corrections.md`) |
| 2026-10-12 | minor | Catalog migration reports offline records as additions, keeps the destination/result readable, skips missing-original probes and stages only masters needed for virtual copies. Approximate rendering and database/table limits remain. |
| 2026-10-13 | minor | Catalog import review: offline Local records join All Photos, redundant operation/settings copies removed, and result invalidation across library switches covered. Approximate rendering and database/table limits remain. |
| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. |
+23 -3
View File
@@ -15,9 +15,21 @@ lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path=
lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat"
```
Import adds records to the currently open LightCraft library; it does not create a separate
folder named after the Lightroom catalog. The desktop result stays open until dismissed and
shows the destination library, the number of records added, missing originals and warnings.
**Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay
readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result
is also available through `library.importLightroom {"status":true}` during that session.
Original files are referenced in place. Ratings, picks/rejects, color labels, XMP metadata,
hierarchical keywords, regular collections/collection sets, virtual copies and mapped develop
settings are imported. Missing originals remain catalogued for relinking. Existing LightCraft
settings are imported. Missing originals remain catalogued for relinking, count as added records and are reported
separately from failures reading files that are present. Known-missing originals are not probed
again. Previously browsed Local records join All Photos even when their originals are offline;
their promotion is counted once and undone together with the catalog import.
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
identity prevents reimport from duplicating virtual copies and collections. One undo step reverses
catalog changes; any saved recovery archives remain available.
@@ -32,8 +44,10 @@ retained. Oldest managed archives are retired; legacy `.json` archives remain un
`Interop/lightroom-index.json` records imported identities. Source catalogs and originals are
never overwritten.
Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per
table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format
Each native `.lrcat` database and WAL file is independently limited to 1 GiB
(1,073,741,824 bytes). This bounds the current whole-file reader; referenced photo sizes and
photo counts are not part of that limit. XMP packets are capped at 16 MiB and each table at one million rows.
The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format
specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB.
Optional history, keyword and collection tables that exceed limits are skipped with warnings;
required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed
@@ -61,6 +75,12 @@ Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog
it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone
is not evaluated by the importer; LightCraft's Auto control remains available after migration.
New photo records are moved into staged operations; only masters needed for virtual copies
retain a second owned record while the import commits.
Applying catalog operations collects affected source-cache IDs before consuming the operations,
avoiding another full operation copy. Full-strength develop patches reuse their JSON without
serializing an unused copy of the current settings.
Native desktop inspection/import runs on a cancellable background worker with progress. Only
prepared catalog operations commit on the owner thread; archive index writing stays on a worker.
Late results from a closed or switched library are rejected, including reopening the same path.
+5 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -692,7 +692,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 a catalog whose tables are all still in its WAL: the main file's schema format 0 is an empty schema), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; 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). 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). 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 |
| 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) |
@@ -823,3 +823,6 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. |
| 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` |
| 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) |
| 2026-10-12 | minor | LRC-LIB-CATALOG-IMPORT: lasting destination/result, offline records and virtual copies count as added; redundant missing-file probes and staged photo copies removed. Status and summary counts unchanged. |
| 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. |
| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. |