diff --git a/ROADMAP.md b/ROADMAP.md index aeb07567..098b8167 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -186,6 +186,18 @@ Not decoded yet — preview only (no permissively licensed description; black-bo matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. +## Lightroom Classic catalog migration + +File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust +SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords, +collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits +are preserved by default, reimport identities are persistent, and source settings/history/snapshots are +archived before catalog mutation. Missing originals remain available for relinking. + +Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived; +history/snapshots remain source data, and smart collections import current membership. The Lightroom +database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`. + ## Log - 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete. - 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed. diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 2efabebb..98e74f52 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -207,6 +207,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen // Preset files: browser pickers are asynchronous; not wired on the web yet. pick_preset_files: None, pick_tracklog: None, + pick_lightroom_catalog: None, save_preset_file: None, pick_curve_preset_files: None, save_curve_preset_file: None, diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 4bb1c6ff..f419b9aa 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -230,6 +230,13 @@ impl PrefsWriter { fn services() -> Services { Services { + pick_lightroom_catalog: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title("Import Lightroom Catalog") + .add_filter("Lightroom Classic Catalog", &["lrcat"]) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })), pick_folder: Some(Box::new(|| { rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c2d4e8a8..f59523f0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -29,6 +29,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +sqlite-core = "=0.12.0" ab_glyph = { workspace = true } lightcraft-segment = { workspace = true, optional = true } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 57632ea8..4a6b94db 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -934,6 +934,25 @@ pub fn specs() -> Vec { ok() }), // ---- import + cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None, + "{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required", + 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| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings})) + }), + cmd!( + "library.importLightroom", + "Import Lightroom Catalog", + [], + None, + "{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.", + always, + |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?; + crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false)) + } + ), cmd!( query "library.importPreview", "Review Import", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ec39222a..4ca8b5bc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,6 +25,7 @@ pub mod guard; pub mod import; mod import_move; pub mod library; +pub mod lightroom_catalog; pub mod media; pub mod memory; pub mod merge; diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs new file mode 100644 index 00000000..5ce8d577 --- /dev/null +++ b/crates/engine/src/lightroom_catalog.rs @@ -0,0 +1,877 @@ +//! Direct, read-only Lightroom Classic catalog import. SQLite pages are read in Rust; +//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place. +//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders. +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sqlite_core::{Database, Value as SqlValue}; + +type Row = BTreeMap; +const COMMAND: &str = "library.importLightroom"; +const MAX_FILE: u64 = 1 << 30; + +fn error(why: impl Into) -> crate::EngineError { + crate::EngineError::BadParams { cmd: COMMAND.into(), msg: why.into() } +} + +#[cfg(test)] +mod tests { + use super::*; + fn row(v: Value) -> Row { + serde_json::from_value(v).unwrap() + } + fn sample() -> CatalogImport { + let mut photo = CatalogPhoto { + source_id: 1, + uuid: "test-master".into(), + path: "/missing/test.ARW".into(), + image: row(json!({"id_local":1,"rating":5,"pick":1,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})), + settings: "s = { Exposure2012 = 1.25, Contrast2012 = 17, CameraProfile = \"Example\" }".into(), + xmp: String::new(), + keywords: vec!["Travel|Goa".into()], + history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], + snapshots: Vec::new(), + }; + let mut copy = photo.clone(); + copy.source_id = 2; + copy.uuid = "test-copy".into(); + copy.image = row(json!({"id_local":2,"masterImage":1,"copyName":"Mono","rating":2,"pick":-1})); + copy.settings = "s = { ConvertToGrayscale = true, Exposure2012 = -0.5 }".into(); + photo.image.insert("captureTime".into(), json!("2024-01-16T12:34:56")); + CatalogImport { + source: "test.lrcat".into(), + photos: vec![copy, photo], + collections: vec![ + row(json!({"id_local":9,"name":"Travel","creationId":"collection.group"})), + row(json!({"id_local":10,"parent":9,"name":"Keepers","creationId":"collection"})), + ], + members: vec![ + row(json!({"collection":10,"image":2,"positionInCollection":"a"})), + row(json!({"collection":10,"image":1,"positionInCollection":"b"})), + ], + collection_content: Vec::new(), + warnings: Vec::new(), + } + } + #[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["missing"].as_array().unwrap().len(), 1); + let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); + assert_eq!(master.rating, 5); + assert_eq!(master.flag, Flag::Pick); + assert_eq!(master.meta.keywords, vec!["Travel|Goa"]); + assert_eq!(master.develop.light.exposure, 1.25); + let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap(); + assert_eq!(copy.copy_of, Some(master.id)); + assert_eq!(copy.rating, 2); + assert_eq!(copy.flag, Flag::Reject); + assert_eq!(copy.develop.light.exposure, -0.5); + let album = s.catalog.albums().find(|a| a.name == "Keepers").unwrap(); + assert_eq!(album.photos, vec![copy.id, master.id]); + assert!(album.parent.is_some()); + assert!(r["unmapped"]["1"].as_array().unwrap().iter().any(|v| v.as_str() == Some("CameraProfile"))); + assert_eq!(s.undo.len(), 1); + s.undo_step().unwrap(); + assert!(s.catalog.is_empty()); + assert!(s.catalog.albums().next().is_none()); + assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact + } + #[test] + fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { + let mut s = crate::Session::new(); + let data = sample(); + apply(&mut s, data.clone(), false).unwrap(); + let id = s.catalog.photos().find(|p| p.copy_of.is_none()).unwrap().id; + s.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal edit").unwrap(); + let r = apply(&mut s, data.clone(), false).unwrap(); + assert!(r["preservedExistingEdits"].as_u64().unwrap() > 0); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.0); + let before = s.catalog.to_snapshot(); + let mut cyclic = data; + cyclic.collections[0].insert("parent".into(), json!(10)); + assert!(apply(&mut s, cyclic, false).is_err()); + assert_eq!(s.catalog.to_snapshot(), before); + assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0).is_err()); + } + #[test] + fn compressed_xmp_is_bounded_and_settings_are_data_only() { + let packet = b"example"; + let mut encoded = (packet.len() as u32).to_be_bytes().to_vec(); + encoded.extend(miniz_oxide::deflate::compress_to_vec_zlib(packet, 6)); + assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); + encoded[0] = 0xff; + assert!(xmp(Some(&json!(encoded))).is_err()); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5).unwrap(); + assert_eq!(partial["light"]["exposure"], 0.75); + } + + #[test] + fn deferred_auto_tone_sentinels_never_become_real_slider_values() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].settings = + "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); + data.photos[0].xmp = r#""#.into(); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.develop.light.exposure, 0.0); + assert_eq!(p.develop.light.contrast, 0.0); + assert_eq!(p.develop.light.shadows, 65.0); + assert_eq!(p.develop.light.blacks, -22.0); + assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); + let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0).unwrap(); + assert_eq!(partial["light"]["exposure"], -5.0); + assert_eq!(partial["light"]["contrast"], -100.0); + } + + #[test] + fn wal_checksums_cover_commits_without_materializing_historical_databases() { + let mut main = vec![0; 512]; + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + assert_eq!(wal_checksum(&[1, 0, 0, 0, 2, 0, 0, 0], false, (0, 0)), (1, 3)); + for big in [false, true] { + let mut wal = vec![0; 32]; + wal[0..4].copy_from_slice(&(if big { 0x377f0683u32 } else { 0x377f0682u32 }).to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[16..20].copy_from_slice(&123u32.to_be_bytes()); + let mut sum = wal_checksum(&wal[..24], big, (0, 0)); + wal[24..28].copy_from_slice(&sum.0.to_be_bytes()); + wal[28..32].copy_from_slice(&sum.1.to_be_bytes()); + for _ in 0..128 { + let mut frame = vec![0; 536]; + frame[0..4].copy_from_slice(&1u32.to_be_bytes()); + frame[4..8].copy_from_slice(&1u32.to_be_bytes()); + frame[8..12].copy_from_slice(&123u32.to_be_bytes()); + sum = wal_checksum(&frame[..8], big, sum); + sum = wal_checksum(&frame[24..], big, sum); + frame[16..20].copy_from_slice(&sum.0.to_be_bytes()); + frame[20..24].copy_from_slice(&sum.1.to_be_bytes()); + wal.extend(frame); + } + assert_eq!(committed_wal_len(&main, &wal).unwrap(), wal.len()); + let end = wal.len(); + wal[end - 1] ^= 1; + assert_eq!(committed_wal_len(&main, &wal).unwrap(), end - 536); + wal[24] ^= 1; + assert!(committed_wal_len(&main, &wal).is_err()); + main[17] = 0; + assert!(committed_wal_len(&main, &wal).is_err()); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + } + } + + #[test] + fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id())); + let mut s = crate::Session::new(); + s.open_library(&dir, false).unwrap(); + let mut data = sample(); + data.collections.reverse(); // Parent must be committed before its child regardless of row order. + let first = apply(&mut s, data.clone(), false).unwrap(); + s.close_library().unwrap(); + s.open_library(&dir, false).unwrap(); + let id = PhotoId(first["mapping"]["1"].as_u64().unwrap()); + 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!(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")); + s.close_library().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } + + // Construct a small SQLite fixture from public file-format records, never private media. + fn varint(mut n: u64) -> Vec { + let mut out = vec![(n & 127) as u8]; + n >>= 7; + while n > 0 { + out.push((n & 127) as u8 | 128); + n >>= 7; + } + out.reverse(); + out + } + fn record(values: &[SqlValue]) -> Vec { + let mut header = Vec::new(); + let mut body = Vec::new(); + for value in values { + let serial = match value { + SqlValue::Null => 0, + SqlValue::Integer(n) => { + body.extend(n.to_be_bytes()); + 6 + } + SqlValue::Text(s) => { + body.extend(s.as_bytes()); + 13 + 2 * s.len() as u64 + } + _ => unreachable!(), + }; + header.extend(varint(serial)); + } + let mut out = varint(1 + header.len() as u64); + out.extend(header); + out.extend(body); + out + } + fn leaf(page: &mut [u8], offset: usize, rows: &[Vec]) { + page[offset] = 13; + page[offset + 3..offset + 5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); + let mut end = page.len(); + for (i, values) in rows.iter().enumerate() { + let payload = record(values); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(i as u64 + 1)); + cell.extend(payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + page[offset + 8 + i * 2..offset + 10 + i * 2].copy_from_slice(&(end as u16).to_be_bytes()); + } + page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); + } + #[test] + fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { + use SqlValue::{Integer as I, Null as N, Text as T}; + let tables = [ + ("AgLibraryRootFolder", "id_local INTEGER PRIMARY KEY, absolutePath", vec![N, T("/catalog/".into())]), + ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), + ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), + ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", + vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], + ), + ]; + let mut bytes = vec![0u8; 5 * 2048]; + bytes[..16].copy_from_slice(b"SQLite format 3\0"); + bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); + bytes[18] = 1; + bytes[19] = 1; + bytes[21] = 64; + bytes[22] = 32; + bytes[23] = 32; + bytes[28..32].copy_from_slice(&5u32.to_be_bytes()); + 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() { + schema.push(vec![ + T("table".into()), + T((*name).into()), + T((*name).into()), + 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[..2048], 100, &schema); + let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(catalog.photos.len(), 1); + assert_eq!(catalog.photos[0].uuid, "fixture-image"); + assert_eq!(number(&catalog.photos[0].image, "rating"), 5); + assert!(catalog.photos[0].path.ends_with("test.ARW")); + std::fs::write(&path, b"bad catalog").unwrap(); + assert!(read(&path).is_err()); + std::fs::remove_file(path).unwrap(); + } +} + +fn text<'a>(r: &'a Row, k: &str) -> &'a str { + r.get(k).and_then(Value::as_str).unwrap_or("") +} +fn number(r: &Row, k: &str) -> i64 { + r.get(k).and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) as i64 +} +fn key(path: &str) -> String { + let p = path.replace('\\', "/"); + if cfg!(windows) { p.to_lowercase() } else { p } +} +fn source_path(p: &Photo) -> Option<&str> { + match &p.source { + Source::File { path } => Some(path), + _ => None, + } +} + +struct Reader { + db: Database, + tables: Vec, +} +impl Reader { + fn table(&self, name: &str, required: bool) -> Result, String> { + let Some(t) = self.tables.iter().find(|t| t.name == name) else { + return if required { Err(format!("unsupported Lightroom catalog: missing {name}")) } else { Ok(Vec::new()) }; + }; + let columns = t.column_names.as_ref().ok_or_else(|| format!("cannot read {name} columns"))?; + let rows = self.db.read_table(t.rootpage, columns.len()).map_err(|e| format!("{name}: {e:?}"))?; + if rows.len() > 1_000_000 { + return Err(format!("{name}: more than one million records")); + } + Ok(rows + .into_iter() + .map(|r| { + columns + .iter() + .cloned() + .zip(r.values.into_iter().map(|v| match v { + SqlValue::Null => Value::Null, + SqlValue::Integer(n) => json!(n), + SqlValue::Real(n) => json!(n), + SqlValue::Text(s) => json!(s), + // Opaque data is retained in the source catalog; the XMP packet is decoded below. + SqlValue::Blob(b) => json!(b), + })) + .collect() + }) + .collect()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogPhoto { + pub source_id: i64, + pub uuid: String, + pub path: String, + pub image: Row, + pub settings: String, + pub xmp: String, + pub keywords: Vec, + pub history: Vec, + pub snapshots: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogImport { + pub source: String, + pub photos: Vec, + pub collections: Vec, + pub members: Vec, + pub collection_content: Vec, + pub warnings: Vec, +} + +#[derive(Default, Serialize, Deserialize)] +struct ImportIndex { + photos: BTreeMap, + collections: BTreeMap, +} + +fn identity(data: &CatalogImport, photo: &CatalogPhoto) -> String { + if photo.uuid.is_empty() { format!("{}:{}", key(&data.source), photo.source_id) } else { photo.uuid.clone() } +} + +fn validate(data: &CatalogImport) -> Result<(), String> { + let photos: HashMap<_, _> = data.photos.iter().map(|p| (p.source_id, p)).collect(); + if photos.len() != data.photos.len() { + return Err("duplicate source photo ids".into()); + } + for photo in &data.photos { + let master = number(&photo.image, "masterImage"); + if master != 0 + && (master == photo.source_id + || !photos.contains_key(&master) + || photos.get(&master).is_some_and(|p| number(&p.image, "masterImage") != 0)) + { + return Err(format!("invalid virtual copy master for {}", photo.source_id)); + } + } + let collections: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), r)).collect(); + for id in collections.keys() { + let mut seen = HashSet::new(); + let mut at = *id; + while let Some(r) = collections.get(&at) { + if seen.len() >= 64 || !seen.insert(at) { + return Err("cyclic or overly deep collection hierarchy".into()); + } + at = number(r, "parent"); + if at == 0 { + break; + } + } + } + Ok(()) +} + +fn bytes(path: &Path) -> Result, String> { + use std::io::Read; + let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + if f.metadata().map_err(|e| e.to_string())?.len() > MAX_FILE { + return Err("catalog or WAL exceeds 1 GiB import limit".into()); + } + let mut out = Vec::new(); + f.take(MAX_FILE + 1).read_to_end(&mut out).map_err(|e| e.to_string())?; + if out.len() as u64 > MAX_FILE { + return Err("catalog grew beyond import limit".into()); + } + Ok(out) +} + +fn xmp(v: Option<&Value>) -> Result { + match v { + Some(Value::String(s)) => Ok(s.clone()), + Some(Value::Array(a)) if !a.is_empty() => { + let b: Vec = a.iter().filter_map(|n| n.as_u64().and_then(|n| u8::try_from(n).ok())).collect(); + let size = b.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_be_bytes).ok_or("truncated XMP header")?; + if size > 16 << 20 { + return Err("XMP packet exceeds 16 MiB".into()); + } + let compressed = b.get(4..).ok_or("truncated XMP")?; + let decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(compressed, 16 << 20).map_err(|_| "invalid compressed XMP")?; + if decoded.len() != size as usize { + return Err("XMP packet length mismatch".into()); + } + String::from_utf8(decoded).map_err(|e| e.to_string()) + } + _ => Ok(String::new()), + } +} + +fn be_word(bytes: &[u8], at: usize) -> Result { + let word = bytes.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or("truncated WAL")?; + Ok(u32::from_be_bytes(word)) +} + +fn wal_checksum(bytes: &[u8], big: bool, mut sum: (u32, u32)) -> (u32, u32) { + for pair in bytes.as_chunks::<8>().0 { + let first = [pair[0], pair[1], pair[2], pair[3]]; + let second = [pair[4], pair[5], pair[6], pair[7]]; + let (first, second) = + if big { (u32::from_be_bytes(first), u32::from_be_bytes(second)) } else { (u32::from_le_bytes(first), u32::from_le_bytes(second)) }; + sum.0 = sum.0.wrapping_add(first).wrapping_add(sum.1); + sum.1 = sum.1.wrapping_add(second).wrapping_add(sum.0); + } + sum +} + +// SQLite file format §4: validate the live checksum chain in constant space, then overlay +// only its last committed prefix. The forensic timeline materializes EVERY historical +// database snapshot; it must never be used to validate a large, actively used catalog. +fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result { + if wal.is_empty() { + return Ok(0); + } + let header = wal.get(..32).ok_or("truncated WAL header")?; + let magic = be_word(header, 0)?; + if !matches!(magic, 0x377f0682 | 0x377f0683) || be_word(header, 4)? != 3_007_000 { + return Err("invalid WAL header".into()); + } + let size = main.get(16..18).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes).ok_or("truncated SQLite header")?; + let size = if size == 1 { 65536 } else { u32::from(size) }; + if !(512..=65536).contains(&size) || !size.is_power_of_two() || be_word(header, 8)? != size { + return Err("WAL page size mismatch".into()); + } + let big = magic == 0x377f0683; + let mut sum = wal_checksum(&header[..24], big, (0, 0)); + if sum != (be_word(header, 24)?, be_word(header, 28)?) { + return Err("invalid WAL header checksum".into()); + } + let salt = (be_word(header, 16)?, be_word(header, 20)?); + let mut committed = 0; + let stride = size as usize + 24; + for (i, frame) in wal[32..].chunks_exact(stride).enumerate() { + if be_word(frame, 0)? == 0 || salt != (be_word(frame, 8)?, be_word(frame, 12)?) { + break; + } + sum = wal_checksum(&frame[..8], big, sum); + sum = wal_checksum(&frame[24..], big, sum); + if sum != (be_word(frame, 16)?, be_word(frame, 20)?) { + break; + } + if be_word(frame, 4)? != 0 { + committed = 32 + (i + 1) * stride; + } + } + Ok(committed) +} + +/// Inspect `.lrcat` plus committed WAL pages without modifying either source file. +pub fn read(path: &Path) -> Result { + let before = std::fs::metadata(path).map_err(|e| e.to_string())?; + let wal_path = PathBuf::from(format!("{}-wal", path.display())); + let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); + let main = bytes(path)?; + let wal = match std::fs::metadata(&wal_path) { + Ok(_) => bytes(&wal_path)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(e) => return Err(e.to_string()), + }; + let wal_len = committed_wal_len(&main, &wal)?; + let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; + let after = std::fs::metadata(path).map_err(|e| e.to_string())?; + let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); + if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } + let reader = Reader { tables: db.live_tables(), db }; + let roots: HashMap<_, _> = reader.table("AgLibraryRootFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let folders: HashMap<_, _> = reader.table("AgLibraryFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let files: HashMap<_, _> = reader.table("AgLibraryFile", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let develops: HashMap<_, _> = + reader.table("Adobe_imageDevelopSettings", false)?.into_iter().map(|r| (number(&r, "image"), text(&r, "text").to_string())).collect(); + let metadata: HashMap<_, _> = reader.table("Adobe_AdditionalMetadata", false)?.into_iter().map(|r| (number(&r, "image"), r)).collect(); + let keyword_rows: HashMap<_, _> = reader.table("AgLibraryKeyword", false)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let mut keywords: HashMap> = HashMap::new(); + for r in reader.table("AgLibraryKeywordImage", false)? { + let mut parts = Vec::new(); + let mut at = number(&r, "tag"); + let mut seen = HashSet::new(); + while at != 0 { + if parts.len() >= 64 || !seen.insert(at) { + return Err("cyclic or overly deep keyword hierarchy".into()); + } + let Some(k) = keyword_rows.get(&at) else { break }; + let name = text(k, "name"); + if !name.is_empty() { + parts.push(name.to_string()); + } + at = number(k, "parent"); + } + parts.reverse(); + if !parts.is_empty() { + keywords.entry(number(&r, "image")).or_default().push(parts.join("|")); + } + } + let group = |rows: Vec| { + let mut out: HashMap> = HashMap::new(); + for r in rows { + out.entry(number(&r, "image")).or_default().push(r); + } + out + }; + let mut history = group(reader.table("Adobe_libraryImageDevelopHistoryStep", false)?); + let mut snapshots = group(reader.table("Adobe_libraryImageDevelopSnapshot", false)?); + let mut out = CatalogImport { + source: path.to_string_lossy().into(), + photos: Vec::new(), + collections: reader.table("AgLibraryCollection", false)?, + members: reader.table("AgLibraryCollectionImage", false)?, + collection_content: reader.table("AgLibraryCollectionContent", false)?, + warnings: Vec::new(), + }; + for image in reader.table("Adobe_images", true)? { + let id = number(&image, "id_local"); + let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?; + let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?; + let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?; + let root_path = text(root, "absolutePath"); + let folder_path = text(folder, "pathFromRoot"); + let name = text(file, "idx_filename"); + if root_path.is_empty() || name.is_empty() { + return Err(format!("image {id}: incomplete original path")); + } + let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string(); + let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) { + Ok(s) => s, + Err(e) => { + out.warnings.push(format!("image {id}: {e}")); + String::new() + } + }; + out.photos.push(CatalogPhoto { + source_id: id, + uuid: text(&image, "id_global").into(), + path, + image, + settings: develops.get(&id).cloned().unwrap_or_default(), + xmp: packet, + keywords: keywords.remove(&id).unwrap_or_default(), + history: history.remove(&id).unwrap_or_default(), + snapshots: snapshots.remove(&id).unwrap_or_default(), + }); + } + Ok(out) +} + +fn mapped_settings(text: &str, raw: bool, aspect: f64) -> Result<(Value, Vec), String> { + let mut root = crate::preset_import::parse_lua(text)?; + let deferred = strip_lua_sentinels(&mut root); + let (props, values) = crate::preset_import::lua_settings_props(&root)?; + let (partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect); + if deferred > 0 { + unknown.push("Deferred Lightroom adjustments (-999999)".into()); + } + Ok((partial, unknown)) +} + +fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { + use crate::preset_import::Lua; + match value { + Lua::Num(n) if *n == -999999.0 => { + *value = Lua::Nil; + 1 + } + Lua::Table(array, fields) => { + let mut removed = array.iter_mut().map(strip_lua_sentinels).sum::(); + fields.retain_mut(|(_, value)| { + removed += strip_lua_sentinels(value); + !matches!(value, Lua::Nil) + }); + removed + } + _ => 0, + } +} + +fn strip_xmp_sentinels(value: &mut Value) { + match value { + Value::Object(fields) => fields.retain(|_, value| { + if value.as_f64() == Some(-999999.0) { + return false; + } + strip_xmp_sentinels(value); + true + }), + Value::Array(values) => { + if values.iter().any(|v| v.as_f64() == Some(-999999.0)) { + values.clear(); + } else { + for v in values { + strip_xmp_sentinels(v); + } + } + } + _ => {} + } +} + +/// Import into the current library. Existing edited records are preserved unless explicitly +/// requested; missing originals remain catalogued for later relinking. The source catalog is read-only. +pub fn import(s: &mut crate::Session, path: &Path, update_existing: bool) -> crate::Result { + let data = read(path).map_err(error)?; + apply(s, data, update_existing) +} + +fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> crate::Result { + validate(&data).map_err(error)?; + let mut warnings = data.warnings.clone(); + let archive = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Interop")); + let index_path = archive.as_ref().map(|p| p.join("lightroom-index.json")); + let mut index: ImportIndex = match index_path.as_ref().map(std::fs::read) { + Some(Ok(b)) => serde_json::from_slice(&b).map_err(|e| error(format!("invalid Lightroom import index: {e}")))?, + Some(Err(e)) if e.kind() != std::io::ErrorKind::NotFound => return Err(error(e.to_string())), + _ => ImportIndex::default(), + }; + // Preserve source data before catalog changes. A unique archive is never overwritten. + let archive_path = if let Some(dir) = archive { + std::fs::create_dir_all(&dir).map_err(|e| error(e.to_string()))?; + let bytes = serde_json::to_vec(&data).map_err(|e| error(e.to_string()))?; + let mut names = (0..10000).map(|n| dir.join(format!("lightroom-import-{n}.json"))); + Some(lightcraft_catalog::safe_file::write_new_unique(&mut names, &bytes).map_err(|e| error(e.to_string()))?) + } else { + None + }; + let existing: HashMap<_, _> = + s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect(); + let undo0 = s.undo.len(); + let paths: Vec<_> = data + .photos + .iter() + .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file()) + .map(|p| p.path.clone()) + .collect(); + let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?; + let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect(); + let mut by_path: HashMap<_, _> = + s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect(); + let mut ids = HashMap::new(); + let mut ops = Vec::new(); + let now = (s.clock)(); + let mut missing = Vec::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 mut planned: HashMap = HashMap::new(); + for src in photos { + let master = number(&src.image, "masterImage"); + let indexed = index + .photos + .get(&identity(&data, src)) + .copied() + .map(PhotoId) + .filter(|id| s.catalog.photo(*id).is_some_and(|p| source_path(p).is_some_and(|path| key(path) == key(&src.path)))); + let existing_id = indexed.or_else(|| if master == 0 { by_path.get(&key(&src.path)).copied() } else { None }); + let mut p = if let Some(id) = existing_id.and_then(|id| s.catalog.photo(id)) { + (**id).clone() + } else if master != 0 { + let master_id = ids.get(&master).copied().ok_or_else(|| error(format!("virtual copy {}: missing master", src.source_id)))?; + let mut p = planned + .get(&master_id) + .cloned() + .or_else(|| s.catalog.photo(master_id).map(|p| (**p).clone())) + .ok_or_else(|| error("virtual copy master is unavailable"))?; + p.id = s.catalog.alloc_photo_id(); + p.copy_of = Some(master_id); + p.copy_name = Some(text(&src.image, "copyName").to_string()); + p + } else if let Some(duplicate) = duplicates.get(&key(&src.path)).and_then(|id| s.catalog.photo(*id)) { + // Catalogs can contain distinct originals with identical bytes and independent edits. + let mut p = (**duplicate).clone(); + p.id = s.catalog.alloc_photo_id(); + p.source = Source::File { path: src.path.clone() }; + p.file_name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().into()).unwrap_or_else(|| src.path.clone()); + p + } 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 !Path::new(&src.path).is_file() { + missing.push(src.path.clone()); + } + let mut p = Photo::new( + id, + Source::File { path: src.path.clone() }, + &name, + text(&src.image, "fileFormat"), + number(&src.image, "fileWidth").max(0) as u32, + number(&src.image, "fileHeight").max(0) as u32, + &now, + ); + if ["DNG", "ARW", "CR2", "CR3", "NEF", "NRW", "RAF", "ORF", "RW2", "PEF"].contains(&p.format.to_uppercase().as_str()) { + p.kind = lightcraft_catalog::MediaKind::Raw; + } + p + }; + ids.insert(src.source_id, p.id); + index.photos.insert(identity(&data, src), p.id.0); + let keep_develop = existing_id.is_some() && p.edited.is_some() && !update_existing; + let previous_develop = (p.develop.clone(), p.edited.clone()); + if keep_develop { + preserved += 1; + } + if !src.xmp.is_empty() { + match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) { + Ok(mut sc) => { + if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { + strip_xmp_sentinels(partial); + } + crate::sidecar::merge_into(&mut p, &sc, &now); + } + Err(e) => warnings.push(format!("image {} XMP: {e}", src.source_id)), + } + } + p.rating = number(&src.image, "rating").clamp(0, 5) as u8; + p.flag = match number(&src.image, "pick") { + 1 => Flag::Pick, + -1 => Flag::Reject, + _ => Flag::None, + }; + p.label = s.catalog.label_from_name(text(&src.image, "colorLabels")); + if !text(&src.image, "captureTime").is_empty() { + p.captured = Some(text(&src.image, "captureTime").into()); + } + if !src.keywords.is_empty() { + p.meta.keywords = src.keywords.clone(); + } + if !src.settings.is_empty() && !keep_develop { + match mapped_settings(&src.settings, p.kind == lightcraft_catalog::MediaKind::Raw, p.width.max(1) as f64 / p.height.max(1) as f64) { + Ok((partial, unknown)) => { + p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0)); + p.edited = Some(now.clone()); + if !unknown.is_empty() { + unmapped.insert(src.source_id, unknown); + } + } + Err(e) => warnings.push(format!("image {} settings: {e}", src.source_id)), + } + } + if keep_develop { + (p.develop, p.edited) = previous_develop; + } + if s.catalog.photo(p.id).is_some() { + ops.extend([ + Op::SetRating { id: p.id, rating: p.rating }, + Op::SetFlag { id: p.id, flag: p.flag }, + Op::SetLabel { id: p.id, label: p.label }, + Op::SetMeta { id: p.id, meta: Box::new(p.meta.clone()) }, + Op::SetCaptured { id: p.id, captured: p.captured.clone() }, + ]); + 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()) }); + } + by_path.entry(key(&src.path)).or_insert(p.id); + planned.insert(p.id, p); + } + let mut albums = HashMap::new(); + for collection in &data.collections { + if number(collection, "systemOnly") != 0 { + continue; + } + let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); + let id = index + .collections + .get(&collection_key) + .copied() + .map(lightcraft_catalog::AlbumId) + .filter(|id| s.catalog.album(*id).is_some()) + .unwrap_or_else(|| s.catalog.alloc_album_id()); + index.collections.insert(collection_key, id.0); + albums.insert(number(collection, "id_local"), id); + } + let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); + let mut ordered: Vec<_> = data.collections.iter().collect(); + ordered.sort_by_key(|r| { + let mut depth = 0; + let mut at = number(r, "parent"); + while at != 0 && depth < 64 { + depth += 1; + at = parents.get(&at).copied().unwrap_or(0); + } + depth + }); + for collection in ordered { + let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; + let mut album = Album::new(id, text(collection, "name")); + album.parent = albums.get(&number(collection, "parent")).copied(); + album.folder = text(collection, "creationId").contains("group"); + if text(collection, "creationId").contains("smart") { + warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); + } + if !album.folder { + let mut members: Vec<_> = data.members.iter().filter(|r| number(r, "collection") == number(collection, "id_local")).collect(); + members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); + album.photos = members.into_iter().filter_map(|r| ids.get(&number(r, "image")).copied()).collect(); + } + if s.catalog.album(id).is_some() { + // Reimport preserves the user's album name/hierarchy, merges only new membership. + if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { + let mut photos = old.photos.clone(); + for photo in album.photos { + if !photos.contains(&photo) { + photos.push(photo); + } + } + ops.push(Op::SetAlbumPhotos { id, photos }); + } + } else { + ops.push(Op::AddAlbum { album }); + } + } + if !ops.is_empty() { + s.commit("Import Lightroom Catalog", Op::Batch { ops })?; + } + s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); + if let Some(path) = index_path { + s.persist()?; + let bytes = serde_json::to_vec(&index).map_err(|e| error(e.to_string()))?; + lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))?; + } + Ok( + 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}), + ) +} diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 3f9498cc..040d46c3 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -100,6 +100,7 @@ impl Lua { struct LuaParser<'a> { s: &'a [u8], i: usize, + depth: usize, } impl LuaParser<'_> { @@ -210,6 +211,15 @@ impl LuaParser<'_> { std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok) } fn value(&mut self) -> Result { + if self.depth >= 64 { + return self.err("Lua settings exceed nesting limit"); + } + self.depth += 1; + let result = self.value_inner(); + self.depth -= 1; + result + } + fn value_inner(&mut self) -> Result { self.skip_ws(); let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") }; match c { @@ -315,7 +325,10 @@ impl LuaParser<'_> { /// Parse a Lua table literal, optionally preceded by `name =` or `return`. pub fn parse_lua(text: &str) -> Result { - let mut p = LuaParser { s: text.as_bytes(), i: 0 }; + if text.len() > 16 << 20 { + return Err("Lua settings exceed 16 MiB".into()); + } + let mut p = LuaParser { s: text.as_bytes(), i: 0, depth: 0 }; p.skip_ws(); let save = p.i; match p.ident().as_deref() { @@ -347,6 +360,12 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option, Props, crate::crs let root = parse_lua(text)?; let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty()); let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?; + let (props, values) = lua_settings_props(settings)?; + Ok((title, props, values)) +} + +/// Data-only Lua settings shared by preset and native catalog import. Never executes Lua. +pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_masks::Values), String> { let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) }; let mut props = Props::new(); let mut values = crate::crs_masks::Values::new(); @@ -374,7 +393,7 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option, Props, crate::crs }; props.insert(key, vals); } - Ok((title, props, values)) + Ok((props, values)) } // ------------------------------------------------------------------------------- XMP in files diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7f4d6d6..1b7b99a4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,8 @@ pub struct Services { pub reveal: Option, /// Choose a folder (Settings → General → Open Library…; desktop only). pub pick_folder: Option, + /// Open a Lightroom Classic `.lrcat` catalog for read-only import. + pub pick_lightroom_catalog: Option, /// Open a web link in the browser (Help menu, About, Discord button). pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e62a382..5c841d9e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -143,6 +143,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), ("file.addFolder", "Import from Folder…", None, "File"), + ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), ("file.addFromDevice", "Import from Device", None, ""), ("file.findMissing", "Find Missing Photos…", None, "File"), ("file.backupLibrary", "Back Up Library…", None, "File"), @@ -962,6 +963,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let path = + p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f())); + match path { + Some(path) => app.run("library.importLightroom", json!({"path":path})), + None => Ok(Value::Null), + } + } "app.quit" => { app.ui.quit = true; Ok(Value::Null) diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md new file mode 100644 index 00000000..a5412da1 --- /dev/null +++ b/docs/lightroom-catalog-import.md @@ -0,0 +1,38 @@ +# Lightroom Classic catalog import + +File → Import Lightroom Catalog… opens `.lrcat` directly. Lightroom, Python, DNG conversion +and a C SQLite runtime are not required. The source database is read-only, including committed +`-wal` pages; a catalog that changes during the read is rejected. LightCraft owns subsequent edits. + +Equivalent commands: + +```text +lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path="Lightroom Catalog.lrcat" +lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat" +``` + +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 +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; recovery archives remain available. + +Before changing records, source image records, decoded XMP, verbatim develop settings, history, +snapshots and collection content are saved to `Interop/lightroom-import-N.json` in the LightCraft +library. `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 SQLite reader is Apache-2.0 `sqlite-core`, written in Rust. + +Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported +mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI +models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped +fields are reported; source settings/history/snapshots are archived rather than discarded. Smart +collections become regular albums with current membership; their original rules remain archived. +History and snapshots are retained as source data, not exposed as native LightCraft history yet. +Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings; +it is reported and archived, 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. + +The importer uses shared Rust code on macOS, Windows and Linux. If originals move between +computers or volumes, use LightCraft's missing-photo relinking tools to update their paths. diff --git a/docs/parity.md b/docs/parity.md index d5a31ea9..3aee3d8e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -716,6 +716,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 | | 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, in-process Rust SQLite reader; 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, existing edits preserved. Verbatim settings/history/snapshots/collection rules archived. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | 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; 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`) | | 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) | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | diff --git a/docs/whats-new.md b/docs/whats-new.md index ea0243f4..cad1980c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -6,6 +6,12 @@ - Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB, preserving RAW editing & full-resolution export instead of using embedded JPEG previews. +### Lightroom Classic catalogs +- File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place. + Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate; + existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported + fields are reported, and the original Lightroom database stays read-only. Rendering is approximate. + ### Presets and profiles - Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip` bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.