From 4a427894ed21daefe345a6ed6c2a01fb0ca5ee23 Mon Sep 17 00:00:00 2001 From: Orthic Labs <102761952+adrdsouza@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:44:09 +0530 Subject: [PATCH 01/25] LRC-LIB-CATALOG-IMPORT: import Lightroom Classic catalogs Co-authored-by: Codex --- ROADMAP.md | 12 + apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/src/main.rs | 7 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/library.rs | 19 + crates/engine/src/lib.rs | 1 + crates/engine/src/lightroom_catalog.rs | 877 +++++++++++++++++++++++++ crates/engine/src/preset_import.rs | 23 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 9 + docs/lightroom-catalog-import.md | 38 ++ docs/parity.md | 1 + docs/whats-new.md | 6 + 13 files changed, 995 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/lightroom_catalog.rs create mode 100644 docs/lightroom-catalog-import.md 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. From 866d02e577cb28b3f3b86da52a4174098348ec14 Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Thu, 8 Oct 2026 12:41:14 +0530 Subject: [PATCH 02/25] LRC-LIB-CATALOG-IMPORT: address SQLite, worker & archive review Co-authored-by: Codex --- crates/engine/Cargo.toml | 1 - crates/engine/src/import.rs | 72 ++ crates/engine/src/lib.rs | 6 + crates/engine/src/library.rs | 7 +- crates/engine/src/lightroom_archive.rs | 654 +++++++++++++++ crates/engine/src/lightroom_catalog.rs | 260 ++++-- crates/engine/src/lightroom_job.rs | 386 +++++++++ crates/engine/src/lightroom_sqlite.rs | 1071 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 21 +- crates/ui-egui/src/lightroom_import.rs | 456 ++++++++++ crates/ui-egui/src/menus.rs | 12 +- crates/ui-egui/src/panels/settings.rs | 4 + docs/lightroom-catalog-import.md | 21 +- docs/parity.md | 2 +- 14 files changed, 2903 insertions(+), 70 deletions(-) create mode 100644 crates/engine/src/lightroom_archive.rs create mode 100644 crates/engine/src/lightroom_job.rs create mode 100644 crates/engine/src/lightroom_sqlite.rs create mode 100644 crates/ui-egui/src/lightroom_import.rs diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f59523f0..c2d4e8a8 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -29,7 +29,6 @@ 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/import.rs b/crates/engine/src/import.rs index 4895eee7..1e94226e 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -610,6 +610,78 @@ impl Prepared { pub fn is_empty(&self) -> bool { self.items.is_empty() } + + /// Recheck Add-only entries against the owner catalog immediately before commit. + /// Preparation may have run while another import added the same path or content. + pub fn revalidate_add(&mut self, s: &Session) { + let mut paths = HashMap::new(); + let mut hashes = HashMap::new(); + for photo in s.catalog.photos() { + if let Source::File { path } = &photo.source { + paths.insert(normalize_import_path(path), photo.id); + } + if let Some(hash) = &photo.content_hash { + hashes.insert(hash.clone(), photo.id); + } + } + let mut items = Vec::with_capacity(self.items.len()); + for item in std::mem::take(&mut self.items) { + let PreparedItem::Ready(ready) = item else { + items.push(item); + continue; + }; + let ReadyFile { path, stored, info, sidecar, placed } = *ready; + let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied()); + let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied()); + if let Some(existing) = existing_path.or(existing_hash) { + if let Some(placed) = &placed { + crate::import_move::rollback(placed); + } + items.push(PreparedItem::Duplicate { + path, + existing: Some(existing), + reason: if existing_path.is_some() { "path" } else { "content" }, + hash: info.content_hash, + }); + } else { + items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); + } + } + self.items = items; + } +} + +fn normalize_import_path(path: &str) -> String { + let path = path.replace('\\', "/"); + if cfg!(windows) { path.to_lowercase() } else { path } +} + +#[cfg(test)] +mod prepared_tests { + use super::*; + + #[test] + fn revalidate_add_only_converts_matching_ready_item() { + let mut s = Session::new(); + let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); + let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); + let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); + s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); + let ready = |path: &Path| ReadyFile { + path: path.to_string_lossy().into(), + stored: path.to_string_lossy().into(), + info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, + sidecar: None, + placed: None, + }; + let mut prepared = Prepared { + scanned: 2, + items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], + }; + prepared.revalidate_add(&s); + assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); + assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); + } } impl ImportJob { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ca8b5bc..122bde8f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,7 +25,10 @@ pub mod guard; pub mod import; mod import_move; pub mod library; +mod lightroom_archive; pub mod lightroom_catalog; +pub mod lightroom_job; +mod lightroom_sqlite; pub mod media; pub mod memory; pub mod merge; @@ -162,6 +165,8 @@ pub struct Session { pub active_spot: Option, /// The persistent library this session writes to (`None` = in-memory only). pub library: Option, + /// Identity of current library opening; refreshed only after a successful open. + pub(crate) library_identity: Arc<()>, /// XMP sidecar preferences (persisted with the library). pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. @@ -249,6 +254,7 @@ impl Session { segmenter: segment::Segmenter::default(), active_spot: None, library: None, + library_identity: Arc::new(()), xmp: sidecar::XmpPrefs::default(), last_export: None, export_presets: Vec::new(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 6a63e378..a3f5be33 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -399,6 +399,7 @@ impl Session { lib.forgot_local = Some(plan); } } + self.library_identity = std::sync::Arc::new(()); Ok(()) } @@ -491,12 +492,16 @@ impl Session { lib.retry_at = None; lib.last_error = None; } - match (persisted, snapshot) { + let result = match (persisted, snapshot) { (_, Ok(())) => Ok(()), // neither the log nor the snapshot took the queued ops (Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))), (_, Err(s)) => Err(s.into()), + }; + if result.is_ok() { + self.library_identity = std::sync::Arc::new(()); } + result } fn view_json(&self) -> Vec { diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs new file mode 100644 index 00000000..ecb53e25 --- /dev/null +++ b/crates/engine/src/lightroom_archive.rs @@ -0,0 +1,654 @@ +//! Bounded, deduplicated persistence for Lightroom import source data. +//! +//! Archives use a deterministic JSON representation wrapped in zlib. The JSON +//! writer streams through a bounded compressor, so an import cannot first build +//! an unbounded archive buffer. Blob columns are omitted by the catalog reader; +//! decoded XMP, +//! settings, history, snapshots, identities, paths, collections & membership +//! remain available for recovery. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use miniz_oxide::deflate::{ + core::{CompressorOxide, create_comp_flags_from_zip_params}, + stream::deflate, +}; +use miniz_oxide::{MZFlush, MZStatus}; +use serde::ser::{Serialize, SerializeMap, SerializeSeq, SerializeStruct, Serializer}; +use serde_json::Value; + +use crate::lightroom_catalog::{CatalogImport, CatalogPhoto}; + +/// Maximum size of one complete archive on disk, including format header. +pub const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024; +/// Maximum total size retained in managed archives. +pub const MAX_TOTAL_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024; +/// Maximum number of managed archives retained. +pub const MAX_MANAGED_ARCHIVES: usize = 8; + +const FORMAT_VERSION: u8 = 1; +const HEADER: &[u8] = b"LC-LRARCH\0\x01Z"; +const NAME_PREFIX: &str = "lightroom-import-"; +const NAME_SUFFIX: &str = ".lca"; +const MAX_SCAN: usize = 1024; +const IO_BUFFER: usize = 16 * 1024; + +/// Store compact source data below `directory` and return its managed archive. +/// +/// The operation is safe to call before catalog mutation. It rejects a +/// symlink directory, writes only `lightroom-import-.lca`, compares the +/// complete bounded byte stream with recent managed archives, and retains at +/// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`]. Legacy +/// `lightroom-import-*.json` files are deliberately ignored and preserved for +/// an explicit caller warning. +pub fn store(directory: &Path, data: &CatalogImport) -> Result { + prepare_directory(directory)?; + let mut managed = managed_files(directory)?; + managed.sort_by_key(|entry| entry.index); + + let temporary = temporary_path(directory)?; + write_archive(&temporary, data).map_err(|error| { + let _ = remove_managed_file(&temporary); + error + })?; + + let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES); + for entry in recent { + let same = match same_bounded(&temporary, &entry.path) { + Ok(same) => same, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error.to_string()); + } + }; + if same { + remove_managed_file(&temporary).map_err(|e| e.to_string())?; + let matched = entry.path.clone(); + let mut all = managed.clone(); + prune_existing(directory, &mut all, Some(&matched))?; + return Ok(matched); + } + } + + let index = managed.last().map_or(0, |entry| entry.index.saturating_add(1)); + if managed.last().is_some_and(|entry| entry.index == u64::MAX) { + let _ = remove_managed_file(&temporary); + return Err("managed Lightroom archive index is exhausted".into()); + } + let destination = match next_destination(directory, index) { + Ok(path) => path, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error); + } + }; + let size = match fs::metadata(&temporary) { + Ok(meta) => meta.len(), + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error.to_string()); + } + }; + let new_entry = Managed { index, path: destination.clone(), size }; + let mut after = managed; + after.push(new_entry); + after.sort_by_key(|entry| entry.index); + let retirees = match retirement_set(&after) { + Ok(retirees) => retirees, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error); + } + }; + let quarantined = match quarantine(&retirees) { + Ok(quarantined) => quarantined, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error); + } + }; + + if let Err(error) = publish_new(&temporary, &destination) { + rollback_quarantine(&quarantined); + let _ = remove_managed_file(&temporary); + return Err(error.to_string()); + } + if let Err(error) = remove_managed_file(&temporary) { + return Err(format!("archive published but temporary cleanup failed: {error}")); + } + for (old, held) in quarantined { + if let Err(error) = remove_managed_file(&held) { + return Err(format!("archive published but could not retire {}: {error}", old.display())); + } + } + Ok(destination) +} + +#[derive(Clone, Debug)] +struct Managed { + index: u64, + path: PathBuf, + size: u64, +} + +fn prepare_directory(directory: &Path) -> Result<(), String> { + match fs::symlink_metadata(directory) { + Ok(meta) => { + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(format!("archive directory is not a real directory: {}", directory.display())); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(directory).map_err(|e| e.to_string())?; + let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?; + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(format!("archive directory is not a real directory: {}", directory.display())); + } + } + Err(error) => return Err(error.to_string()), + } + Ok(()) +} + +fn managed_files(directory: &Path) -> Result, String> { + let mut files = Vec::new(); + for (seen, result) in fs::read_dir(directory).map_err(|e| e.to_string())?.enumerate() { + if seen >= MAX_SCAN { + return Err(format!("too many entries in archive directory (limit {MAX_SCAN})")); + } + let entry = result.map_err(|e| e.to_string())?; + let Some(index) = parse_managed_name(&entry.file_name()) else { continue }; + let meta = fs::symlink_metadata(entry.path()).map_err(|e| e.to_string())?; + if meta.file_type().is_symlink() || !meta.is_file() { + continue; + } + files.push(Managed { index, path: entry.path(), size: meta.len() }); + } + Ok(files) +} + +fn parse_managed_name(name: &std::ffi::OsStr) -> Option { + let name = name.to_str()?; + let digits = name.strip_prefix(NAME_PREFIX)?.strip_suffix(NAME_SUFFIX)?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +fn temporary_path(directory: &Path) -> Result { + let pid = std::process::id(); + for slot in 0..MAX_SCAN { + let path = directory.join(format!(".lightroom-import-{pid}-{slot}.tmp")); + match fs::symlink_metadata(&path) { + Ok(_) => continue, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path), + Err(error) => return Err(error.to_string()), + } + } + Err("no safe temporary archive name available".into()) +} + +fn next_destination(directory: &Path, mut index: u64) -> Result { + for _ in 0..MAX_SCAN { + let path = directory.join(format!("{NAME_PREFIX}{index}{NAME_SUFFIX}")); + match fs::symlink_metadata(&path) { + Ok(_) => index = index.checked_add(1).ok_or("managed archive index is exhausted")?, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path), + Err(error) => return Err(error.to_string()), + } + } + Err("no safe managed archive name available".into()) +} + +fn write_archive(path: &Path, data: &CatalogImport) -> Result<(), String> { + lightcraft_catalog::safe_file::write_atomic_with(path, &mut |raw| { + let mut bounded = BoundedWriter { inner: raw, total: 0 }; + bounded.write_all(HEADER)?; + let mut compressor = ZlibWriter::new(&mut bounded); + serde_json::to_writer(&mut compressor, &ArchiveRef(data)).map_err(json_io)?; + compressor.finish()?; + Ok(()) + }) + .map(|_| ()) + .map_err(|error| format!("could not write Lightroom archive: {error}")) +} + +fn json_io(error: serde_json::Error) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error.to_string()) +} + +struct BoundedWriter<'a> { + inner: &'a mut dyn Write, + total: u64, +} + +impl Write for BoundedWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let next = self.total.checked_add(bytes.len() as u64).ok_or_else(limit_error)?; + if next > MAX_ARCHIVE_BYTES { + return Err(limit_error()); + } + self.inner.write_all(bytes)?; + self.total = next; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +fn limit_error() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "Lightroom archive exceeds 32 MiB") +} + +struct ZlibWriter<'a, 'b> { + sink: &'a mut BoundedWriter<'b>, + compressor: CompressorOxide, + plain: u64, +} + +impl<'a, 'b> ZlibWriter<'a, 'b> { + fn new(sink: &'a mut BoundedWriter<'b>) -> ZlibWriter<'a, 'b> { + let flags = create_comp_flags_from_zip_params(6, 15, 0); + ZlibWriter { sink, compressor: CompressorOxide::new(flags), plain: 0 } + } + + fn run(&mut self, input: &[u8], flush: MZFlush) -> io::Result<()> { + let mut rest = input; + loop { + let mut output = [0u8; IO_BUFFER]; + let result = deflate(&mut self.compressor, rest, &mut output, flush); + self.sink.write_all(&output[..result.bytes_written])?; + match result.status { + Ok(MZStatus::StreamEnd) => return Ok(()), + Ok(MZStatus::Ok) => { + let consumed = result.bytes_consumed; + if consumed > rest.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid compressor progress")); + } + rest = &rest[consumed..]; + if rest.is_empty() && flush == MZFlush::None { + return Ok(()); + } + if consumed == 0 && result.bytes_written == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "compressor made no progress")); + } + } + Ok(status) => { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported compressor status: {status:?}"))); + } + Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("compression failed: {error:?}"))), + } + } + } + + fn finish(&mut self) -> io::Result<()> { + self.run(&[], MZFlush::Finish) + } +} + +impl Write for ZlibWriter<'_, '_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.is_empty() { + return Ok(0); + } + let next = self.plain.checked_add(bytes.len() as u64).ok_or_else(limit_error)?; + if next > MAX_ARCHIVE_BYTES { + return Err(limit_error()); + } + self.run(bytes, MZFlush::None)?; + self.plain = next; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn same_bounded(left: &Path, right: &Path) -> io::Result { + let left_meta = fs::symlink_metadata(left)?; + let right_meta = fs::symlink_metadata(right)?; + if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() || !left_meta.is_file() || !right_meta.is_file() { + return Ok(false); + } + let left_len = left_meta.len(); + let right_len = right_meta.len(); + if left_len != right_len || left_len > MAX_ARCHIVE_BYTES { + return Ok(false); + } + let mut left = File::open(left)?; + let mut right = File::open(right)?; + let mut left_buf = [0u8; IO_BUFFER]; + let mut right_buf = [0u8; IO_BUFFER]; + loop { + let left_n = left.read(&mut left_buf)?; + let right_n = right.read(&mut right_buf)?; + if left_n != right_n { + return Ok(false); + } + if left_n == 0 { + return Ok(true); + } + if left_buf[..left_n] != right_buf[..right_n] { + return Ok(false); + } + } +} + +fn retirement_set(entries: &[Managed]) -> Result, String> { + retirement_set_protected(entries, None) +} + +fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Result, String> { + let mut ordered = entries.to_vec(); + ordered.sort_by_key(|entry| entry.index); + let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?; + let mut retirees = Vec::new(); + while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES { + let Some(position) = ordered.iter().position(|entry| protected.map_or(true, |path| entry.path != path)) else { + return Err("cannot satisfy archive retention limit".into()); + }; + let oldest = ordered.remove(position); + total = total.saturating_sub(oldest.size); + retirees.push(oldest); + } + Ok(retirees) +} + +fn prune_existing(directory: &Path, entries: &mut [Managed], protected: Option<&Path>) -> Result<(), String> { + let retirees = retirement_set_protected(entries, protected)?; + let quarantined = quarantine(&retirees)?; + for (old, held) in quarantined { + if let Err(error) = remove_managed_file(&held) { + return Err(format!("could not retire {} from {}: {error}", old.display(), directory.display())); + } + } + Ok(()) +} + +fn quarantine(entries: &[Managed]) -> Result, String> { + let mut moved = Vec::new(); + let pid = std::process::id(); + for (slot, entry) in entries.iter().enumerate() { + let held = entry.path.with_file_name(format!(".lightroom-retire-{pid}-{slot}.tmp")); + match fs::symlink_metadata(&held) { + Ok(_) => { + rollback_quarantine(&moved); + return Err(format!("retention temporary path already exists: {}", held.display())); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + rollback_quarantine(&moved); + return Err(error.to_string()); + } + } + let meta = fs::symlink_metadata(&entry.path).map_err(|error| { + rollback_quarantine(&moved); + error.to_string() + })?; + if meta.file_type().is_symlink() || !meta.is_file() { + rollback_quarantine(&moved); + return Err(format!("managed archive changed into non-file: {}", entry.path.display())); + } + if let Err(error) = fs::rename(&entry.path, &held) { + rollback_quarantine(&moved); + return Err(error.to_string()); + } + moved.push((entry.path.clone(), held)); + } + Ok(moved) +} + +fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) { + for (original, held) in moved.iter().rev() { + let _ = fs::rename(held, original); + } +} + +fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> { + match fs::hard_link(temporary, destination) { + Ok(()) => Ok(()), + Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))), + } +} + +fn remove_managed_file(path: &Path) -> io::Result<()> { + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() || !meta.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "refusing to remove non-regular archive path")); + } + fs::remove_file(path) +} + +struct ArchiveRef<'a>(&'a CatalogImport); + +impl Serialize for ArchiveRef<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_struct("CatalogImportArchive", 7)?; + out.serialize_field("version", &FORMAT_VERSION)?; + out.serialize_field("source", &self.0.source)?; + out.serialize_field("photos", &PhotoSeq(&self.0.photos))?; + out.serialize_field("collections", &RowSeq(&self.0.collections))?; + out.serialize_field("members", &RowSeq(&self.0.members))?; + out.serialize_field("collection_content", &RowSeq(&self.0.collection_content))?; + out.serialize_field("warnings", &self.0.warnings)?; + out.end() + } +} + +struct PhotoSeq<'a>(&'a [CatalogPhoto]); +impl Serialize for PhotoSeq<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_seq(Some(self.0.len()))?; + for photo in self.0 { + out.serialize_element(&PhotoRef(photo))?; + } + out.end() + } +} + +struct PhotoRef<'a>(&'a CatalogPhoto); +impl Serialize for PhotoRef<'_> { + fn serialize(&self, serializer: S) -> Result { + let p = self.0; + let mut out = serializer.serialize_struct("CatalogPhotoArchive", 8)?; + out.serialize_field("source_id", &p.source_id)?; + out.serialize_field("uuid", &p.uuid)?; + out.serialize_field("path", &p.path)?; + out.serialize_field("image", &RowRef(&p.image))?; + out.serialize_field("settings", &p.settings)?; + out.serialize_field("xmp", &p.xmp)?; + out.serialize_field("keywords", &p.keywords)?; + out.serialize_field("history", &RowSeq(&p.history))?; + out.serialize_field("snapshots", &RowSeq(&p.snapshots))?; + out.end() + } +} + +struct RowSeq<'a>(&'a [BTreeMap]); +impl Serialize for RowSeq<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_seq(Some(self.0.len()))?; + for row in self.0 { + out.serialize_element(&RowRef(row))?; + } + out.end() + } +} + +struct RowRef<'a>(&'a BTreeMap); +impl Serialize for RowRef<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_map(None)?; + for (key, value) in self.0 { + out.serialize_entry(key, value)?; + } + out.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lightroom_catalog::{CatalogImport, CatalogPhoto}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT: AtomicU64 = AtomicU64::new(0); + + fn tempdir() -> PathBuf { + let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + fs::create_dir_all(&path).unwrap(); + path + } + + fn data(source: String) -> CatalogImport { + CatalogImport { + source, + photos: Vec::new(), + collections: Vec::new(), + members: Vec::new(), + collection_content: Vec::new(), + warnings: Vec::new(), + } + } + + fn rich_data() -> CatalogImport { + let photo = CatalogPhoto { + source_id: 7, + uuid: "uuid-7".into(), + path: "/photos/one.raw".into(), + image: BTreeMap::from([("rating".into(), serde_json::json!(5)), ("opaque_blob".into(), serde_json::json!([1, 2, 3, 4]))]), + settings: "s = { Exposure2012 = 0.75 }".into(), + xmp: "decoded".into(), + keywords: vec!["Trips|Goa".into()], + history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])], + snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])], + }; + CatalogImport { + source: "source.lrcat".into(), + photos: vec![photo], + collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])], + members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])], + collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])], + warnings: vec!["one warning".into()], + } + } + + fn managed_count(path: &Path) -> usize { + fs::read_dir(path).unwrap().filter_map(Result::ok).filter(|entry| parse_managed_name(&entry.file_name()).is_some()).count() + } + + #[test] + fn deduplicates_identical_deterministic_archives() { + let path = tempdir(); + let input = data("catalog.lrcat".into()); + let first = store(&path, &input).unwrap(); + let second = store(&path, &input).unwrap(); + assert_eq!(first, second); + assert_eq!(managed_count(&path), 1); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn dedup_returns_archive_that_retention_keeps() { + let path = tempdir(); + let input = data("catalog.lrcat".into()); + let first = store(&path, &input).unwrap(); + for index in 1..MAX_MANAGED_ARCHIVES { + let filler = path.join(format!("lightroom-import-{index}.lca")); + File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap(); + } + let reused = store(&path, &input).unwrap(); + assert_eq!(reused, first); + assert!(reused.is_file()); + assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn caps_managed_archive_count_and_preserves_unrelated_files() { + let path = tempdir(); + let human = path.join("notes.txt"); + let legacy = path.join("lightroom-import-0.json"); + fs::write(&human, b"keep").unwrap(); + fs::write(&legacy, b"legacy").unwrap(); + for index in 0..(MAX_MANAGED_ARCHIVES + 3) { + let input = data(format!("catalog-{index}.lrcat")); + store(&path, &input).unwrap(); + } + assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES); + assert_eq!(fs::read(&human).unwrap(), b"keep"); + assert_eq!(fs::read(&legacy).unwrap(), b"legacy"); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn rejects_oversize_before_creating_archive() { + let path = tempdir(); + let input = data("x".repeat((MAX_ARCHIVE_BYTES as usize) + 1)); + assert!(store(&path, &input).is_err()); + assert_eq!(managed_count(&path), 0); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() { + let path = tempdir(); + let archive = store(&path, &rich_data()).unwrap(); + let bytes = fs::read(archive).unwrap(); + assert!(bytes.starts_with(HEADER)); + let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap(); + let decoded: Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(decoded["version"], serde_json::json!(FORMAT_VERSION)); + assert_eq!(decoded["source"], serde_json::json!("source.lrcat")); + assert_eq!(decoded["photos"].as_array().unwrap().len(), 1); + assert_eq!(decoded["photos"][0]["source_id"], serde_json::json!(7)); + assert_eq!(decoded["photos"][0]["uuid"], serde_json::json!("uuid-7")); + assert_eq!(decoded["photos"][0]["path"], serde_json::json!("/photos/one.raw")); + assert_eq!(decoded["photos"][0]["settings"], serde_json::json!("s = { Exposure2012 = 0.75 }")); + assert_eq!(decoded["photos"][0]["xmp"], serde_json::json!("decoded")); + assert_eq!(decoded["photos"][0]["keywords"], serde_json::json!(["Trips|Goa"])); + assert_eq!(decoded["photos"][0]["image"]["rating"], serde_json::json!(5)); + assert_eq!(decoded["photos"][0]["image"]["opaque_blob"], serde_json::json!([1, 2, 3, 4])); + assert_eq!(decoded["photos"][0]["history"].as_array().unwrap().len(), 1); + assert_eq!(decoded["photos"][0]["history"][0]["curve"], serde_json::json!([0, 128, 255])); + assert_eq!(decoded["photos"][0]["snapshots"].as_array().unwrap().len(), 1); + assert_eq!(decoded["collections"].as_array().unwrap().len(), 1); + assert_eq!(decoded["collections"][0]["name"], serde_json::json!("Keepers")); + assert_eq!(decoded["members"].as_array().unwrap().len(), 1); + assert_eq!(decoded["members"][0]["image"], serde_json::json!(7)); + assert_eq!(decoded["collection_content"].as_array().unwrap().len(), 1); + assert_eq!(decoded["warnings"], serde_json::json!(["one warning"])); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn retention_enforces_aggregate_byte_cap_with_small_synthetic_entries() { + let mut entries: Vec<_> = (0..MAX_MANAGED_ARCHIVES as u64) + .map(|index| Managed { index, path: PathBuf::from(format!("lightroom-import-{index}.lca")), size: 20 * 1024 * 1024 }) + .collect(); + entries.push(Managed { index: 8, path: PathBuf::from("lightroom-import-8.lca"), size: 20 * 1024 * 1024 }); + let retirees = retirement_set(&entries).unwrap(); + assert_eq!(retirees.len(), 3); + assert_eq!(retirees.iter().map(|entry| entry.index).collect::>(), vec![0, 1, 2]); + } + + #[test] + fn retention_failure_preserves_old_archive() { + let path = tempdir(); + let old = path.join("lightroom-import-0.lca"); + let held = path.join(format!(".lightroom-retire-{}-0.tmp", std::process::id())); + fs::write(&old, b"old archive").unwrap(); + fs::write(&held, b"occupied").unwrap(); + let entry = Managed { index: 0, path: old.clone(), size: 11 }; + assert!(quarantine(&[entry]).is_err()); + assert_eq!(fs::read(&old).unwrap(), b"old archive"); + fs::remove_dir_all(path).unwrap(); + } +} diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 5ce8d577..47f22247 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -3,12 +3,15 @@ //! 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 std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue}; 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"; @@ -296,6 +299,10 @@ fn text<'a>(r: &'a Row, k: &str) -> &'a str { 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 } + +pub(crate) fn number_for_job(r: &Row, k: &str) -> i64 { + number(r, k) +} fn key(path: &str) -> String { let p = path.replace('\\', "/"); if cfg!(windows) { p.to_lowercase() } else { p } @@ -309,7 +316,7 @@ fn source_path(p: &Photo) -> Option<&str> { struct Reader { db: Database, - tables: Vec, + tables: Vec, } impl Reader { fn table(&self, name: &str, required: bool) -> Result, String> { @@ -328,17 +335,64 @@ impl Reader { .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), + SqlValue::Null => Some(Value::Null), + SqlValue::Integer(n) => Some(json!(n)), + SqlValue::Real(n) => Some(json!(n)), + SqlValue::Text(s) => Some(json!(s)), + // Opaque Lightroom payloads never become JSON byte arrays. + SqlValue::Blob(_) => None, })) + .filter_map(|(column, value)| value.map(|value| (column, value))) .collect() }) .collect()) } + + fn xmp_table(&self) -> Result<(Vec, Vec), String> { + let Some(t) = self.tables.iter().find(|t| t.name == "Adobe_AdditionalMetadata") else { return Ok((Vec::new(), Vec::new())) }; + let columns = t.column_names.as_ref().ok_or_else(|| "cannot read Adobe_AdditionalMetadata columns".to_string())?; + let rows = self.db.read_table(t.rootpage, columns.len()).map_err(|e| format!("Adobe_AdditionalMetadata: {e:?}"))?; + if rows.len() > 1_000_000 { + return Err("Adobe_AdditionalMetadata: more than one million records".into()); + } + let mut warnings = Vec::new(); + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let image = row + .values + .iter() + .zip(columns) + .find_map(|(value, column)| { + (column == "image").then(|| match value { + SqlValue::Integer(n) => *n, + _ => 0, + }) + }) + .unwrap_or(0); + let mut mapped = Row::new(); + for (column, value) in columns.iter().zip(row.values) { + let value = match value { + SqlValue::Null => Some(Value::Null), + SqlValue::Integer(n) => Some(json!(n)), + SqlValue::Real(n) => Some(json!(n)), + SqlValue::Text(s) => Some(json!(s)), + SqlValue::Blob(bytes) if column == "xmp" => match decode_xmp_bytes(&bytes) { + Ok(packet) => Some(Value::String(packet)), + Err(error) => { + warnings.push(format!("image {image}: {error}")); + None + } + }, + SqlValue::Blob(_) => None, + }; + if let Some(value) = value { + mapped.insert(column.clone(), value); + } + } + out.push(mapped); + } + Ok((out, warnings)) + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -364,10 +418,38 @@ pub struct CatalogImport { pub warnings: Vec, } -#[derive(Default, Serialize, Deserialize)] -struct ImportIndex { - photos: BTreeMap, - collections: BTreeMap, +#[derive(Clone, Default, Serialize, Deserialize)] +pub(crate) struct ImportIndex { + pub(crate) photos: BTreeMap, + pub(crate) collections: BTreeMap, +} + +pub(crate) fn load_index(path: Option<&Path>) -> crate::Result { + match path { + Some(path) => { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ImportIndex::default()), + Err(e) => return Err(error(e.to_string())), + }; + if file.metadata().map_err(|e| error(e.to_string()))?.len() > 16 * 1024 * 1024 { + return Err(error("Lightroom import index exceeds 16 MiB")); + } + let mut bytes = Vec::new(); + use std::io::Read; + file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes).map_err(|e| error(e.to_string()))?; + if bytes.len() > 16 * 1024 * 1024 { + return Err(error("Lightroom import index exceeds 16 MiB")); + } + serde_json::from_slice(&bytes).map_err(|e| error(format!("invalid Lightroom import index: {e}"))) + } + None => Ok(ImportIndex::default()), + } +} + +pub(crate) struct AppliedImport { + pub(crate) report: Value, + pub(crate) index: ImportIndex, } fn identity(data: &CatalogImport, photo: &CatalogPhoto) -> String { @@ -406,6 +488,10 @@ fn validate(data: &CatalogImport) -> Result<(), String> { Ok(()) } +pub(crate) fn validate_for_job(data: &CatalogImport) -> Result<(), String> { + validate(data) +} + fn bytes(path: &Path) -> Result, String> { use std::io::Read; let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; @@ -440,6 +526,19 @@ fn xmp(v: Option<&Value>) -> Result { } } +fn decode_xmp_bytes(bytes: &[u8]) -> Result { + let size = bytes.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 decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(bytes.get(4..).ok_or("truncated XMP")?, 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()) +} + 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)) @@ -500,6 +599,17 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result { /// Inspect `.lrcat` plus committed WAL pages without modifying either source file. pub fn read(path: &Path) -> Result { + let cancel = AtomicBool::new(false); + let total = AtomicUsize::new(0); + let done = AtomicUsize::new(0); + read_with_progress(path, &cancel, &total, &done) +} + +pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result { + // 13 catalog tables plus one validation stage; ImportJob adds one unit per original path. + total.store(14, Ordering::Relaxed); + done.store(0, Ordering::Relaxed); + ensure_read_active(cancel)?; 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())); @@ -510,22 +620,33 @@ pub fn read(path: &Path) -> Result { Err(e) => return Err(e.to_string()), }; let wal_len = committed_wal_len(&main, &wal)?; + ensure_read_active(cancel)?; 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 reader = Reader { tables: db.live_tables()?, db }; + let mut table = |name: &str, required: bool| -> Result, String> { + ensure_read_active(cancel)?; + let rows = reader.table(name, required)?; + done.fetch_add(1, Ordering::Relaxed); + Ok(rows) + }; + let roots: HashMap<_, _> = table("AgLibraryRootFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let folders: HashMap<_, _> = table("AgLibraryFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let files: HashMap<_, _> = 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(); + table("Adobe_imageDevelopSettings", false)?.into_iter().map(|r| (number(&r, "image"), text(&r, "text").to_string())).collect(); + ensure_read_active(cancel)?; + let (metadata_rows, mut metadata_warnings) = reader.xmp_table()?; + done.fetch_add(1, Ordering::Relaxed); + let metadata: HashMap<_, _> = metadata_rows.into_iter().map(|r| (number(&r, "image"), r)).collect(); + let keyword_rows: HashMap<_, _> = 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)? { + for r in table("AgLibraryKeywordImage", false)? { + ensure_read_active(cancel)?; let mut parts = Vec::new(); let mut at = number(&r, "tag"); let mut seen = HashSet::new(); @@ -552,17 +673,18 @@ pub fn read(path: &Path) -> Result { } out }; - let mut history = group(reader.table("Adobe_libraryImageDevelopHistoryStep", false)?); - let mut snapshots = group(reader.table("Adobe_libraryImageDevelopSnapshot", false)?); + let mut history = group(table("Adobe_libraryImageDevelopHistoryStep", false)?); + let mut snapshots = group(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(), + collections: table("AgLibraryCollection", false)?, + members: table("AgLibraryCollectionImage", false)?, + collection_content: table("AgLibraryCollectionContent", false)?, + warnings: std::mem::take(&mut metadata_warnings), }; - for image in reader.table("Adobe_images", true)? { + for image in table("Adobe_images", true)? { + ensure_read_active(cancel)?; 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"))?; @@ -593,9 +715,14 @@ pub fn read(path: &Path) -> Result { snapshots: snapshots.remove(&id).unwrap_or_default(), }); } + ensure_read_active(cancel)?; Ok(out) } +fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { + if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } +} + 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); @@ -651,39 +778,60 @@ fn strip_xmp_sentinels(value: &mut Value) { /// 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) + let mut job = crate::lightroom_job::LightroomJob::new(s, path.to_path_buf(), update_existing)?; + let cancel = AtomicBool::new(false); + let prepared = job.prepare(&cancel)?; + let completion = crate::lightroom_job::commit_prepared(s, prepared)?; + if s.library.as_ref().is_some_and(|library| library.on_disk) { + s.persist()?; + } + completion.finalization.finish()?; + Ok(completion.report) } +#[cfg(test)] 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 archive_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Interop")); + let index_path = archive_dir.as_ref().map(|p| p.join("lightroom-index.json")); + let mut index = load_index(index_path.as_deref())?; + let archive_path = archive_dir.as_ref().map(|dir| crate::lightroom_archive::store(dir, &data).map_err(error)).transpose()?; + let undo0 = s.undo.len(); 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 missing_sources: HashSet = + data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect(); let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?; + let applied = + apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?; + if let Some(path) = index_path { + s.persist()?; + let bytes = serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?; + lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))?; + } + Ok(applied.report) +} + +/// Apply prepared normal-file results and Lightroom metadata in one owner-thread operation. +/// This function does no filesystem work; callers finish its returned index separately. +pub(crate) struct ApplyPrepared<'a> { + pub(crate) update_existing: bool, + pub(crate) files: crate::import::ImportReport, + pub(crate) index: &'a mut ImportIndex, + pub(crate) archive_path: Option, + pub(crate) missing_sources: &'a HashSet, + pub(crate) undo0: usize, +} + +pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { + let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context; + validate(&data).map_err(error)?; + let mut warnings = data.warnings.clone(); 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(); @@ -729,7 +877,7 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> } 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() { + if missing_sources.contains(&key(&src.path)) { missing.push(src.path.clone()); } let mut p = Photo::new( @@ -866,12 +1014,8 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> 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}), - ) + 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}), + index: index.clone(), + }) } diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs new file mode 100644 index 00000000..ab383aa1 --- /dev/null +++ b/crates/engine/src/lightroom_job.rs @@ -0,0 +1,386 @@ +//! Detached Lightroom catalog preparation and owner-thread commit. +//! +//! `LightroomJob::new` takes only an in-memory session snapshot. `prepare` owns all catalog +//! reading, probing and archive staging, while `commit_prepared` only applies catalog operations +//! to the session that created the job. Archive index bytes are returned for a worker to finish +//! after the owner has accepted the catalog operation. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +use serde_json::Value; + +use crate::import::{ImportJob, ImportMode, ImportOptions, Prepared}; +use crate::lightroom_catalog::{self, CatalogImport, ImportIndex}; + +const COMMAND: &str = "library.importLightroom"; +const MAX_INDEX_BYTES: usize = 16 * 1024 * 1024; + +fn error(message: impl Into) -> crate::EngineError { + crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() } +} + +/// Memory identity of the library which owns a prepared completion. +/// +/// Each successful open/close refreshes session identity, including reopening the same path. +/// The owner check deliberately does not compare catalog revisions: ordinary edits while preparation +/// runs must survive and are merged by the owner commit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightroomLibraryToken { + library_dir: Option, + identity: Arc<()>, +} + +impl LightroomLibraryToken { + fn capture(s: &crate::Session) -> Self { + Self { + library_dir: s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()), + identity: Arc::clone(&s.library_identity), + } + } + + /// Whether `s` still refers to the library which started preparation. + pub fn matches(&self, s: &crate::Session) -> bool { + Arc::ptr_eq(&self.identity, &s.library_identity) + && self.library_dir == s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()) + } + + pub fn library_dir(&self) -> Option<&Path> { + self.library_dir.as_deref() + } +} + +/// Filesystem-free result of catalog preparation. +pub struct PreparedLightroom { + pub(crate) data: CatalogImport, + pub(crate) normal: Prepared, + pub(crate) index: ImportIndex, + pub(crate) archive_path: Option, + pub(crate) token: LightroomLibraryToken, + pub(crate) missing_sources: HashSet, + pub(crate) update_existing: bool, + pub(crate) now: String, + cancelled: bool, +} + +impl PreparedLightroom { + pub fn source(&self) -> &str { + &self.data.source + } + + pub fn photo_count(&self) -> usize { + self.data.photos.len() + } + + pub fn token(&self) -> &LightroomLibraryToken { + &self.token + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled + } + + /// Roll back any normal-import filesystem placement held by this prepared value. + pub fn rollback(&self) { + self.normal.rollback(); + } +} + +/// Index write returned by owner commit. `finish` is intended for a worker after the owner has +/// returned, and performs the only archive-index filesystem operation in this pipeline. +pub struct LightroomArchiveFinalization { + path: Option, + bytes: Option>, +} + +impl LightroomArchiveFinalization { + pub fn finish(self) -> crate::Result<()> { + let (Some(path), Some(bytes)) = (self.path, self.bytes) else { return Ok(()) }; + if bytes.len() > MAX_INDEX_BYTES { + return Err(error("Lightroom import index exceeds 16 MiB")); + } + lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string())) + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } +} + +/// Owner-thread catalog result. The report is ready for the UI immediately; index finalization +/// can remain on the worker while its task stays alive. +pub struct LightroomCompletion { + pub report: Value, + pub finalization: LightroomArchiveFinalization, +} + +/// Alias used by hosts that call the result a commit. +pub type LightroomCommit = LightroomCompletion; + +/// A Lightroom import detached from the owner thread. +pub struct LightroomJob { + path: PathBuf, + update_existing: bool, + import: ImportJob, + token: LightroomLibraryToken, + archive_dir: Option, +} + +impl LightroomJob { + /// Snapshot session state only. This constructor performs no filesystem work. + pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result { + let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + let import = ImportJob::new(s, opts)?; + let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop")); + Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir }) + } + + pub fn total(&self) -> usize { + self.import.total.load(Ordering::Relaxed) + } + + pub fn done(&self) -> usize { + self.import.done.load(Ordering::Relaxed) + } + + pub fn progress(&self) -> (usize, usize) { + (self.done(), self.total()) + } + + pub fn token(&self) -> &LightroomLibraryToken { + &self.token + } + + pub fn total_atomic(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.import.total) + } + + pub fn done_atomic(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.import.done) + } + + /// Finish an owner commit's deferred archive-index write on a worker. + pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> { + finalization.finish() + } + + /// Read/probe/stage all source data off the owner thread. + pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result { + if cancel.load(Ordering::Relaxed) { + return Err(error("Lightroom import cancelled")); + } + self.import.total.store(1, Ordering::Relaxed); + self.import.done.store(0, Ordering::Relaxed); + let data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?; + lightroom_catalog::validate_for_job(&data).map_err(error)?; + if cancel.load(Ordering::Relaxed) { + return Err(error("Lightroom import cancelled")); + } + self.import.done.fetch_add(1, Ordering::Relaxed); + let paths: Vec = 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(); + let normal = self.import.prepare(&paths, cancel); + if cancel.load(Ordering::Relaxed) { + normal.rollback(); + return Err(error("Lightroom import cancelled")); + } + let archive_path = self.archive_dir.as_ref().map(|dir| crate::lightroom_archive::store(dir, &data).map_err(error)).transpose()?; + let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json")); + let index = lightroom_catalog::load_index(index_path.as_deref())?; + Ok(PreparedLightroom { + data, + normal, + index, + archive_path, + token: self.token.clone(), + missing_sources, + update_existing: self.update_existing, + now: self.import.now().to_string(), + cancelled: cancel.load(Ordering::Relaxed), + }) + } +} + +/// Apply prepared data on the owner thread. No source, archive or index filesystem work occurs. +pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result { + if prepared.cancelled { + prepared.normal.rollback(); + return Err(error("Lightroom import cancelled")); + } + if !prepared.token.matches(s) { + prepared.normal.rollback(); + return Err(error("Lightroom import belongs to a different open library")); + } + // A command-scoped Lightroom import must never rewrite source XMP sidecars. Direct depth-0 + // callers do not set this transient flag, so they cannot affect a later command. + if s.depth > 0 { + s.skip_auto_write = true; + } + + let undo0 = s.undo.len(); + prepared.normal.revalidate_add(s); + let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + let now = prepared.now.clone(); + let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?; + let applied = lightroom_catalog::apply_prepared( + s, + prepared.data, + lightroom_catalog::ApplyPrepared { + update_existing: prepared.update_existing, + files, + index: &mut prepared.index, + archive_path: prepared.archive_path, + missing_sources: &prepared.missing_sources, + undo0, + }, + )?; + let path = prepared.token.library_dir().map(|dir| dir.join("Interop/lightroom-index.json")); + let bytes = if path.is_some() { Some(serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?) } else { None }; + Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } }) +} + +fn normalize(path: &str) -> String { + let path = path.replace('\\', "/"); + if cfg!(windows) { path.to_lowercase() } else { path } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_data() -> CatalogImport { + CatalogImport { + source: "fixture.lrcat".into(), + photos: Vec::new(), + collections: Vec::new(), + members: Vec::new(), + collection_content: Vec::new(), + warnings: Vec::new(), + } + } + + fn cancelled_prepared(s: &crate::Session) -> PreparedLightroom { + PreparedLightroom { + data: empty_data(), + normal: Prepared::default(), + index: ImportIndex::default(), + archive_path: None, + token: LightroomLibraryToken::capture(s), + missing_sources: HashSet::new(), + update_existing: false, + now: "2026-01-01T00:00:00".into(), + cancelled: true, + } + } + + fn prepared_for(s: &crate::Session, path: &str, rating: i64) -> PreparedLightroom { + let mut image = std::collections::BTreeMap::new(); + image.insert("id_local".into(), serde_json::json!(1)); + image.insert("rating".into(), serde_json::json!(rating)); + image.insert("pick".into(), serde_json::json!(0)); + image.insert("fileFormat".into(), serde_json::json!("JPG")); + image.insert("fileWidth".into(), serde_json::json!(1)); + image.insert("fileHeight".into(), serde_json::json!(1)); + PreparedLightroom { + data: CatalogImport { + source: "fixture.lrcat".into(), + photos: vec![lightroom_catalog::CatalogPhoto { + source_id: 1, + uuid: "fixture-photo".into(), + path: path.into(), + image, + settings: String::new(), + xmp: String::new(), + keywords: Vec::new(), + history: Vec::new(), + snapshots: Vec::new(), + }], + collections: Vec::new(), + members: Vec::new(), + collection_content: Vec::new(), + warnings: Vec::new(), + }, + normal: Prepared::default(), + index: ImportIndex::default(), + archive_path: None, + token: LightroomLibraryToken::capture(s), + missing_sources: [normalize(path)].into_iter().collect(), + update_existing: false, + now: "2026-01-01T00:00:00".into(), + cancelled: false, + } + } + + #[test] + fn cancellation_before_commit_leaves_owner_catalog_unchanged() { + let mut session = crate::Session::new(); + let before = session.catalog.to_snapshot(); + let prepared = cancelled_prepared(&session); + assert!(commit_prepared(&mut session, prepared).is_err()); + assert_eq!(session.catalog.to_snapshot(), before); + assert!(session.undo.is_empty()); + } + + #[test] + fn library_identity_rejects_late_completion_after_reopen() { + let mut session = crate::Session::new(); + let token = LightroomLibraryToken::capture(&session); + assert!(token.matches(&session)); + session.library_identity = Arc::new(()); + assert!(!token.matches(&session)); + } + + #[test] + fn prepared_reimport_preserves_current_edit_and_uses_one_undo_per_import() { + let mut session = crate::Session::new(); + let path = "/missing/lightroom-job-edit.jpg"; + let prepared = prepared_for(&session, path, 4); + let first = commit_prepared(&mut session, prepared).unwrap(); + assert_eq!(first.report["mapping"]["1"].as_u64(), Some(1)); + assert_eq!(session.undo.len(), 1); + let id = lightcraft_catalog::PhotoId(1); + session.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal").unwrap(); + let before = session.catalog.photo(id).map(|photo| photo.develop.clone()); + let prepared = prepared_for(&session, path, 5); + commit_prepared(&mut session, prepared).unwrap(); + assert_eq!(session.catalog.photo(id).map(|photo| photo.develop.clone()), before); + assert_eq!(session.catalog.photo(id).map(|photo| photo.rating), Some(5)); + assert_eq!(session.undo.len(), 3); + } + + #[test] + fn execute_fn_import_does_not_rewrite_source_sidecar() { + let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id())); + let path = dir.join("source.jpg"); + let sidecar = dir.join("source.xmp"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, b"fixture").unwrap(); + std::fs::write(&sidecar, b"original sidecar").unwrap(); + let original = std::fs::read(&sidecar).unwrap(); + let mut session = crate::Session::new(); + session.xmp.auto_write = true; + let result = session.execute_fn("library.importLightroom", |s| { + let prepared = prepared_for(s, &path.to_string_lossy(), 5); + let completion = commit_prepared(s, prepared)?; + Ok(completion.report) + }); + assert!(result.is_ok()); + assert_eq!(std::fs::read(&sidecar).unwrap(), original); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs new file mode 100644 index 00000000..be7f4cb9 --- /dev/null +++ b/crates/engine/src/lightroom_sqlite.rs @@ -0,0 +1,1071 @@ +//! Small, read-only SQLite reader used by Lightroom catalog import. +//! +//! This is a clean-room implementation based on SQLite's public file-format +//! description: (sections 1.6, 1.7, +//! 2.1, 2.2, 2.3, 2.4 and 2.5). It is deliberately a page reader, rather +//! than a SQL engine. It never writes its input and has no SQLite runtime +//! dependency. + +use std::collections::{HashMap, HashSet}; + +const MAX_PAGE_ID: u32 = 16_777_216; +const MAX_COLUMNS: usize = 16_384; +const MAX_ROWS: usize = 1_000_000; +const MAX_CELLS: usize = 4_000_000; +const MAX_PAYLOAD: usize = 64 * 1024 * 1024; +const MAX_TABLE_PAYLOAD: usize = 256 * 1024 * 1024; +const MAX_OVERFLOW_PAGES: usize = 1_048_576; +const MAX_BTREE_DEPTH: usize = 64; + +/// Values supported by SQLite table records. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Integer(i64), + Real(f64), + Text(String), + Blob(Vec), +} + +/// A row returned by [`Database::read_table`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Record { + pub values: Vec, +} + +/// A table discovered in `sqlite_schema`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LiveTable { + pub name: String, + pub rootpage: u32, + pub column_names: Option>, +} + +/// Read-only SQLite database image, with a committed WAL overlay if supplied. +pub struct Database { + main: Vec, + overlay: HashMap>, + page_size: usize, + reserved: usize, + encoding: TextEncoding, + page_count: u32, + tables: Vec, + rowid_aliases: HashMap, +} + +#[derive(Clone, Copy)] +enum TextEncoding { + Utf8, + Utf16Le, + Utf16Be, +} + +struct WalkState { + seen: HashSet, + out: Vec, + payload_total: usize, +} + +struct WalOverlay { + page_size: usize, + frame_count: usize, + pages: HashMap>, + page_count: Option, +} + +struct SchemaInfo { + tables: Vec, + aliases: HashMap, +} + +impl Database { + /// Open a main database image and an already checksum-validated WAL prefix. + /// + /// The WAL argument must include its 32-byte header and only complete + /// frames through a commit. Frames are overlaid by page number, with the + /// final commit's database-size field defining the visible page count. + pub fn open_with_wal(main: Vec, committed_wal: &[u8]) -> Result { + let main_header = main.get(..100).ok_or("truncated SQLite header")?; + if main_header.get(..16) != Some(b"SQLite format 3\0") { + return Err("invalid SQLite header signature".into()); + } + let page_size = sqlite_page_size(be_u16(main_header, 16)?)?; + validate_header_fields(main_header, page_size)?; + if main.len() < page_size || main.len() % page_size != 0 { + return Err("SQLite image is not a whole number of pages".into()); + } + let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?; + if main_pages == 0 || main_pages > MAX_PAGE_ID { + return Err("invalid SQLite image page count".into()); + } + + let mut overlay = HashMap::new(); + let mut wal_page_count = None; + if !committed_wal.is_empty() { + let parsed = parse_wal(committed_wal, page_size)?; + if parsed.page_size != page_size { + return Err("WAL page size mismatch".into()); + } + if parsed.frame_count > MAX_CELLS { + return Err("WAL contains too many frames".into()); + } + overlay = parsed.pages; + wal_page_count = parsed.page_count; + } + let effective_header = overlay.get(&1).map(Vec::as_slice).unwrap_or(main_header); + if effective_header.get(..16) != Some(b"SQLite format 3\0") { + return Err("invalid SQLite header page in WAL".into()); + } + let effective_size = sqlite_page_size(be_u16(effective_header, 16)?)?; + if effective_size != page_size { + return Err("WAL page 1 changes SQLite page size".into()); + } + let reserved = validate_header_fields(effective_header, page_size)?; + let header_pages = be_u32(effective_header, 28)?; + let header_valid = be_u32(effective_header, 24)? != 0 && be_u32(effective_header, 24)? == be_u32(effective_header, 92)?; + if header_valid && header_pages > main_pages && wal_page_count.is_none() { + return Err("SQLite header page count exceeds image".into()); + } + let mut page_count = if header_valid && header_pages != 0 { header_pages } else { main_pages }; + if let Some(count) = wal_page_count { + page_count = count; + } + if page_count == 0 || page_count > MAX_PAGE_ID { + return Err("invalid visible SQLite page count".into()); + } + let encoding = match be_u32(effective_header, 56)? { + 0 | 1 => TextEncoding::Utf8, + 2 => TextEncoding::Utf16Le, + 3 => TextEncoding::Utf16Be, + _ => return Err("unsupported SQLite text encoding".into()), + }; + + let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; + let schema = db.parse_schema()?; + db.tables = schema.tables; + db.rowid_aliases = schema.aliases; + Ok(db) + } + + /// Return tables from `sqlite_schema` in schema order. + pub fn live_tables(&self) -> Result, String> { + Ok(self.tables.clone()) + } + + /// Read a table B-tree, decoding up to `ncols` values per record. + pub fn read_table(&self, rootpage: u32, ncols: usize) -> Result, String> { + if !(1..=MAX_PAGE_ID).contains(&rootpage) || rootpage > self.page_count { + return Err("table root page is outside database".into()); + } + if ncols > MAX_COLUMNS { + return Err("table has too many columns".into()); + } + let alias = self.rowid_aliases.get(&rootpage).copied(); + let mut state = WalkState { seen: HashSet::new(), out: Vec::new(), payload_total: 0 }; + self.visit_table(rootpage, ncols, alias, 0, &mut state)?; + Ok(state.out) + } + + fn page(&self, page_id: u32) -> Result<&[u8], String> { + if page_id == 0 || page_id > self.page_count { + return Err(format!("page {page_id} is outside database")); + } + if let Some(page) = self.overlay.get(&page_id) { + return Ok(page); + } + let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?; + let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?; + let end = start.checked_add(self.page_size).ok_or("page offset overflow")?; + self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated")) + } + + fn visit_table(&self, page_id: u32, ncols: usize, alias: Option, depth: usize, state: &mut WalkState) -> Result<(), String> { + if depth > MAX_BTREE_DEPTH { + return Err("SQLite B-tree is too deep".into()); + } + if !state.seen.insert(page_id) { + return Err("SQLite B-tree contains a cycle or shared page".into()); + } + let page = self.page(page_id)?; + let usable = self.page_size.checked_sub(self.reserved).ok_or("invalid usable page size")?; + let base = if page_id == 1 { 100 } else { 0 }; + let content = page.get(..usable).ok_or("truncated usable SQLite page")?; + let kind = *content.get(base).ok_or("truncated B-tree header")?; + let cells = usize::from(be_u16_at(content, base + 3)?); + if cells > MAX_CELLS || state.out.len().saturating_add(cells) > MAX_ROWS.saturating_add(MAX_CELLS) { + return Err("SQLite B-tree contains too many cells".into()); + } + match kind { + 0x0d => { + let ptr_start = base.checked_add(8).ok_or("B-tree header overflow")?; + let ptr_bytes = cells.checked_mul(2).ok_or("B-tree pointer-array overflow")?; + let ptr_end = ptr_start.checked_add(ptr_bytes).ok_or("B-tree pointer-array overflow")?; + if ptr_end > usable { + return Err("truncated B-tree pointer array".into()); + } + for i in 0..cells { + let p = ptr_start.checked_add(i.checked_mul(2).ok_or("cell offset overflow")?).ok_or("cell offset overflow")?; + let cell = usize::from(be_u16_at(content, p)?); + if cell < ptr_end || cell >= usable { + return Err("invalid B-tree cell pointer".into()); + } + let mut at = cell; + let payload_len = usize_from_u64(read_varint(content, &mut at)?, "payload length")?; + if payload_len > MAX_PAYLOAD { + return Err("SQLite record payload is too large".into()); + } + let rowid = i64::from_be_bytes(read_varint(content, &mut at)?.to_be_bytes()); + state.payload_total = state.payload_total.checked_add(payload_len).ok_or("SQLite table payload total overflow")?; + if state.payload_total > MAX_TABLE_PAYLOAD { + return Err("SQLite table payload exceeds bounded import size".into()); + } + let payload = self.payload(page, at, payload_len)?; + let mut values = decode_record(&payload, ncols, self.encoding)?; + if let Some(index) = alias { + if index >= values.len() { + return Err("INTEGER PRIMARY KEY column is missing from record".into()); + } + if matches!(values.get(index), Some(Value::Null)) { + if let Some(value) = values.get_mut(index) { + *value = Value::Integer(rowid); + } + } + } + if state.out.len() >= MAX_ROWS { + return Err("SQLite table contains more than one million records".into()); + } + state.out.push(Record { values }); + } + Ok(()) + } + 0x05 => { + let ptr_start = base.checked_add(12).ok_or("B-tree header overflow")?; + let ptr_bytes = cells.checked_mul(2).ok_or("B-tree pointer-array overflow")?; + let ptr_end = ptr_start.checked_add(ptr_bytes).ok_or("B-tree pointer-array overflow")?; + if ptr_end > usable { + return Err("truncated interior B-tree pointer array".into()); + } + for i in 0..cells { + let p = ptr_start.checked_add(i.checked_mul(2).ok_or("cell offset overflow")?).ok_or("cell offset overflow")?; + let cell = usize::from(be_u16_at(content, p)?); + if cell < ptr_end || cell >= usable { + return Err("invalid interior B-tree cell pointer".into()); + } + let child = be_u32_at(content, cell)?; + if child == 0 { + return Err("interior B-tree cell has null child".into()); + } + let mut key_at = cell.checked_add(4).ok_or("interior cell overflow")?; + let _ = read_varint(content, &mut key_at)?; + self.visit_table(child, ncols, alias, depth + 1, state)?; + } + let right = be_u32_at(content, base + 8)?; + if right == 0 { + return Err("interior B-tree has null right child".into()); + } + self.visit_table(right, ncols, alias, depth + 1, state) + } + 0x02 | 0x0a => Err("SQLite index B-trees are unsupported".into()), + other => Err(format!("unsupported SQLite B-tree page type 0x{other:02x}")), + } + } + + fn payload(&self, page: &[u8], cell_at: usize, payload_len: usize) -> Result, String> { + let usable = self.page_size.checked_sub(self.reserved).ok_or("invalid usable page size")?; + if usable < 8 { + return Err("SQLite usable page size is too small".into()); + } + let max_local = usable.saturating_sub(35); + let min_local = ((usable - 12).saturating_mul(32) / 255).saturating_sub(23); + let local = if payload_len <= max_local { + payload_len + } else { + let span = usable - 4; + let candidate = min_local.saturating_add((payload_len - min_local) % span); + if candidate > max_local { min_local } else { candidate } + }; + let local_end = cell_at.checked_add(local).ok_or("SQLite cell payload overflow")?; + if local_end > usable { + return Err("truncated SQLite cell payload".into()); + } + let mut out = Vec::with_capacity(payload_len); + out.extend_from_slice(page.get(cell_at..local_end).ok_or("truncated SQLite cell payload")?); + let mut remaining = payload_len.saturating_sub(local); + if remaining == 0 { + return Ok(out); + } + let overflow_at = local_end; + match overflow_at.checked_add(4) { + Some(end) if end <= usable => {} + _ => return Err("SQLite overflow pointer is in reserved page bytes".into()), + } + let first = be_u32_at(page, overflow_at)?; + let mut next = first; + let mut seen = HashSet::new(); + while remaining > 0 { + if next == 0 || !seen.insert(next) || seen.len() > MAX_OVERFLOW_PAGES { + return Err("invalid or cyclic SQLite overflow chain".into()); + } + let overflow = self.page(next)?; + let take = remaining.min(usable - 4); + let end = 4usize.checked_add(take).ok_or("overflow payload offset")?; + out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?); + remaining -= take; + if remaining > 0 { + next = be_u32_at(overflow, 0)?; + } + } + Ok(out) + } + + fn parse_schema(&self) -> Result { + let mut tables = Vec::new(); + let mut aliases = HashMap::new(); + let mut state = WalkState { seen: HashSet::new(), out: Vec::new(), payload_total: 0 }; + self.visit_table(1, 5, None, 0, &mut state)?; + for record in state.out { + if record.values.len() < 5 { + return Err("sqlite_schema row has too few columns".into()); + } + let kind = match record.values.first() { + Some(Value::Text(s)) => s.as_str(), + _ => return Err("sqlite_schema row has invalid type".into()), + }; + let name = match record.values.get(1) { + Some(Value::Text(s)) => s.clone(), + _ => return Err("sqlite_schema row has invalid name".into()), + }; + if kind.eq_ignore_ascii_case("virtual") { + return Err(format!("virtual table {name} is unsupported")); + } + if !kind.eq_ignore_ascii_case("table") { + continue; + } + let rootpage = match record.values.get(3) { + Some(Value::Integer(n)) if *n > 0 => u32::try_from(*n).map_err(|_| "sqlite_schema root page is invalid")?, + _ => return Err(format!("table {name} has invalid root page")), + }; + let sql = match record.values.get(4) { + Some(Value::Text(s)) => s, + _ => return Err(format!("table {name} has no CREATE TABLE SQL")), + }; + let (column_names, alias) = parse_create_table(sql)?; + if column_names.len() > MAX_COLUMNS { + return Err(format!("table {name} has too many columns")); + } + if tables.iter().any(|table: &LiveTable| table.rootpage == rootpage) { + return Err("duplicate SQLite table root page".into()); + } + if let Some(index) = alias { + aliases.insert(rootpage, index); + } + tables.push(LiveTable { name, rootpage, column_names: Some(column_names) }); + } + Ok(SchemaInfo { tables, aliases }) + } +} + +fn validate_header_fields(header: &[u8], page_size: usize) -> Result { + let write_version = *header.get(18).ok_or("truncated SQLite header")?; + let read_version = *header.get(19).ok_or("truncated SQLite header")?; + if !matches!(write_version, 1 | 2) || !matches!(read_version, 1 | 2) { + return Err("unsupported SQLite file format version".into()); + } + if *header.get(21).ok_or("truncated SQLite header")? != 64 + || *header.get(22).ok_or("truncated SQLite header")? != 32 + || *header.get(23).ok_or("truncated SQLite header")? != 32 + { + return Err("invalid SQLite payload fractions".into()); + } + let reserved = usize::from(*header.get(20).ok_or("truncated SQLite header")?); + let usable = page_size.checked_sub(reserved).ok_or("invalid SQLite reserved-byte count")?; + if usable < 480 { + return Err("SQLite usable page size is below 480 bytes".into()); + } + let schema_format = be_u32(header, 44)?; + if !(1..=4).contains(&schema_format) { + return Err("unsupported SQLite schema format".into()); + } + Ok(reserved) +} + +fn parse_wal(wal: &[u8], page_size: usize) -> Result { + let header = wal.get(..32).ok_or("truncated WAL header")?; + let magic = be_u32(header, 0)?; + if magic != 0x377f0682 && magic != 0x377f0683 { + return Err("invalid WAL magic".into()); + } + if be_u32(header, 4)? != 3_007_000 || be_u32(header, 8)? != u32::try_from(page_size).map_err(|_| "page size overflow")? { + return Err("invalid WAL page size or format version".into()); + } + let stride = page_size.checked_add(24).ok_or("WAL frame size overflow")?; + let body = wal.get(32..).ok_or("truncated WAL body")?; + if body.len() % stride != 0 { + return Err("WAL ends with a partial frame".into()); + } + let salt = (be_u32(header, 16)?, be_u32(header, 20)?); + let frame_count = body.len() / stride; + if frame_count > MAX_CELLS { + return Err("WAL has too many frames".into()); + } + let mut overlay = HashMap::new(); + let mut page_count = None; + for frame_no in 0..frame_count { + let start = frame_no.checked_mul(stride).ok_or("WAL frame offset overflow")?; + let end = start.checked_add(stride).ok_or("WAL frame offset overflow")?; + let frame = body.get(start..end).ok_or("truncated WAL frame")?; + let page_id = be_u32(frame, 0)?; + if page_id == 0 || page_id > MAX_PAGE_ID || salt != (be_u32(frame, 8)?, be_u32(frame, 12)?) { + return Err("invalid WAL frame page or salt".into()); + } + let commit = be_u32(frame, 4)?; + if commit != 0 { + if commit > MAX_PAGE_ID { + return Err("WAL commit database size is too large".into()); + } + page_count = Some(commit); + } + overlay.insert(page_id, frame.get(24..).ok_or("truncated WAL page")?.to_vec()); + } + Ok(WalOverlay { page_size, frame_count, pages: overlay, page_count }) +} + +fn decode_record(payload: &[u8], ncols: usize, encoding: TextEncoding) -> Result, String> { + let mut at = 0; + let header_len = usize_from_u64(read_varint(payload, &mut at)?, "record header length")?; + if header_len < at || header_len > payload.len() { + return Err("invalid SQLite record header length".into()); + } + let mut serials = Vec::new(); + while at < header_len { + if serials.len() >= ncols { + return Err("SQLite record has more columns than table schema".into()); + } + serials.push(read_varint(payload, &mut at)?); + } + if at != header_len { + return Err("SQLite record header is malformed".into()); + } + let mut body = header_len; + let mut values = Vec::with_capacity(ncols); + for serial in serials { + let (value, size) = serial_value(payload, &mut body, serial, encoding)?; + values.push(value); + if values.len() > MAX_COLUMNS { + return Err("SQLite record has too many columns".into()); + } + let _ = size; + } + if body > payload.len() { + return Err("SQLite record body is truncated".into()); + } + values.resize(ncols, Value::Null); + Ok(values) +} + +fn serial_value(payload: &[u8], body: &mut usize, serial: u64, encoding: TextEncoding) -> Result<(Value, usize), String> { + let (kind, len) = match serial { + 0 => (0, 0), + 1 => (1, 1), + 2 => (2, 2), + 3 => (3, 3), + 4 => (4, 4), + 5 => (5, 6), + 6 => (6, 8), + 7 => (7, 8), + 8 => (8, 0), + 9 => (9, 0), + 10 | 11 => return Err("reserved SQLite serial type".into()), + n if n >= 12 => { + let len = usize_from_u64((n - if n % 2 == 0 { 12 } else { 13 }) / 2, "SQLite field length")?; + (if n % 2 == 0 { 12 } else { 13 }, len) + } + _ => return Err("invalid SQLite serial type".into()), + }; + let end = body.checked_add(len).ok_or("SQLite record body offset overflow")?; + let bytes = payload.get(*body..end).ok_or("SQLite record body is truncated")?; + let value = match kind { + 0 => Value::Null, + 1 => Value::Integer(i64::from(i8::from_be_bytes([*bytes.first().ok_or("truncated integer")?]))), + 2 => Value::Integer(i64::from(i16::from_be_bytes(bytes.try_into().map_err(|_| "truncated integer")?))), + 3 => { + let first = *bytes.first().ok_or("truncated integer")?; + let second = *bytes.get(1).ok_or("truncated integer")?; + let third = *bytes.get(2).ok_or("truncated integer")?; + let n = (u32::from(first) << 16) | (u32::from(second) << 8) | u32::from(third); + let signed = if n & 0x80_0000 != 0 { n | 0xff00_0000 } else { n }; + Value::Integer(i64::from(i32::from_be_bytes(signed.to_be_bytes()))) + } + 4 => Value::Integer(i64::from(i32::from_be_bytes(bytes.try_into().map_err(|_| "truncated integer")?))), + 5 => { + let n = bytes.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b)); + let signed = if n & (1 << 47) != 0 { n | 0xffff_0000_0000_0000 } else { n }; + Value::Integer(i64::from_be_bytes(signed.to_be_bytes())) + } + 6 => Value::Integer(i64::from_be_bytes(bytes.try_into().map_err(|_| "truncated integer")?)), + 7 => { + let n = f64::from_be_bytes(bytes.try_into().map_err(|_| "truncated real")?); + if !n.is_finite() { + return Err("non-finite SQLite real value".into()); + } + Value::Real(n) + } + 8 => Value::Integer(0), + 9 => Value::Integer(1), + 12 => Value::Blob(bytes.to_vec()), + 13 => Value::Text(decode_text(bytes, encoding)?), + _ => return Err("invalid SQLite field type".into()), + }; + *body = end; + Ok((value, len)) +} + +fn decode_text(bytes: &[u8], encoding: TextEncoding) -> Result { + match encoding { + TextEncoding::Utf8 => String::from_utf8(bytes.to_vec()).map_err(|_| "invalid UTF-8 SQLite text".into()), + TextEncoding::Utf16Le | TextEncoding::Utf16Be => { + if bytes.len() % 2 != 0 { + return Err("odd-length UTF-16 SQLite text".into()); + } + let units: Result, String> = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| { + let first = *pair.first().ok_or("truncated UTF-16 SQLite text")?; + let second = *pair.get(1).ok_or("truncated UTF-16 SQLite text")?; + Ok(match encoding { + TextEncoding::Utf16Le => u16::from_le_bytes([first, second]), + TextEncoding::Utf16Be => u16::from_be_bytes([first, second]), + TextEncoding::Utf8 => return Err("invalid UTF-16 encoding state".into()), + }) + }) + .collect(); + String::from_utf16(&units?).map_err(|_| "invalid UTF-16 SQLite text".into()) + } + } +} + +fn parse_create_table(sql: &str) -> Result<(Vec, Option), String> { + let tokens = tokenize_sql(sql)?; + if !tokens.first().is_some_and(|t| t.word_is("CREATE")) { + return Err("malformed CREATE TABLE SQL".into()); + } + let mut kind_at = 1usize; + if tokens.get(kind_at).is_some_and(|t| t.word_is("TEMPORARY") || t.word_is("TEMP")) { + kind_at += 1; + } + if tokens.get(kind_at).is_some_and(|t| t.word_is("VIRTUAL")) { + return Err("virtual tables are unsupported".into()); + } + if !tokens.get(kind_at).is_some_and(|t| t.word_is("TABLE")) { + return Err("malformed CREATE TABLE SQL".into()); + } + let open = tokens.iter().position(|t| t.is_symbol(b'(')).ok_or("CREATE TABLE has no column list")?; + let mut depth = 0usize; + let mut close = None; + for (i, token) in tokens.iter().enumerate().skip(open) { + if token.is_symbol(b'(') { + depth += 1; + } else if token.is_symbol(b')') { + depth = depth.checked_sub(1).ok_or("unbalanced CREATE TABLE parentheses")?; + if depth == 0 { + close = Some(i); + break; + } + } + } + let close = close.ok_or("unterminated CREATE TABLE column list")?; + if tokens + .get(close + 1..) + .ok_or("invalid CREATE TABLE suffix")? + .windows(2) + .any(|w| w.first().is_some_and(|t| t.word_is("WITHOUT")) && w.get(1).is_some_and(|t| t.word_is("ROWID"))) + { + return Err("WITHOUT ROWID tables are unsupported".into()); + } + let body = tokens.get(open + 1..close).ok_or("invalid CREATE TABLE column list")?; + let mut columns = Vec::new(); + let mut alias = None; + let mut integer_columns = HashSet::new(); + let mut table_primary = None; + let mut start = 0usize; + let mut nested = 0usize; + for i in 0..=body.len() { + let comma = i == body.len() || (nested == 0 && body.get(i).is_some_and(|t| t.is_symbol(b','))); + if comma { + let part = body.get(start..i).ok_or("invalid CREATE TABLE column range")?; + if part.is_empty() { + return Err("empty CREATE TABLE column definition".into()); + } + if is_table_constraint(part) { + if table_primary.is_none() { + table_primary = table_primary_column(part); + } + } else { + let name = part.first().and_then(Token::identifier).ok_or("column name is not an identifier")?; + if columns.iter().any(|old| old == name) { + return Err("duplicate SQLite column name".into()); + } + let index = columns.len(); + if exact_integer_decl(part) { + integer_columns.insert(name.to_ascii_lowercase()); + } + if integer_primary_key_alias(part) { + alias = Some(index); + } + columns.push(name.to_string()); + } + start = i + 1; + } else if body.get(i).is_some_and(|token| token.is_symbol(b'(')) { + nested += 1; + } else if body.get(i).is_some_and(|token| token.is_symbol(b')')) { + nested = nested.checked_sub(1).ok_or("unbalanced CREATE TABLE column definition")?; + } + } + if columns.is_empty() { + return Err("CREATE TABLE has no columns".into()); + } + if alias.is_none() { + if let Some(primary) = table_primary { + let key = primary.to_ascii_lowercase(); + if integer_columns.contains(&key) { + alias = columns.iter().position(|column| column.eq_ignore_ascii_case(&primary)); + } + } + } + Ok((columns, alias)) +} + +fn is_table_constraint(part: &[Token]) -> bool { + part.first().is_some_and(|token| ["CONSTRAINT", "PRIMARY", "UNIQUE", "CHECK", "FOREIGN"].iter().any(|word| token.word_is(word))) +} + +fn exact_integer_decl(part: &[Token]) -> bool { + if !part.get(1).is_some_and(|token| token.word_is("INTEGER")) { + return false; + } + match part.get(2) { + None => true, + Some(token) if token.is_symbol(b'(') => false, + Some(token) => ["PRIMARY", "NOT", "NULL", "UNIQUE", "CHECK", "DEFAULT", "COLLATE", "REFERENCES", "CONSTRAINT", "GENERATED"] + .iter() + .any(|word| token.word_is(word)), + } +} + +fn table_primary_column(part: &[Token]) -> Option { + let primary = part.iter().position(|token| token.word_is("PRIMARY"))?; + if !part.get(primary + 1).is_some_and(|token| token.word_is("KEY")) { + return None; + } + let open = primary + 2; + if !part.get(open).is_some_and(|token| token.is_symbol(b'(')) { + return None; + } + let close = (open + 1..part.len()).find(|index| part.get(*index).is_some_and(|token| token.is_symbol(b')')))?; + let terms = part.get(open + 1..close)?; + if terms.len() == 1 || (terms.len() == 2 && terms.get(1).is_some_and(|token| token.word_is("ASC") || token.word_is("DESC"))) { + return terms.first().and_then(Token::identifier).map(str::to_owned); + } + None +} + +fn integer_primary_key_alias(part: &[Token]) -> bool { + if !exact_integer_decl(part) { + return false; + } + let mut primary = None; + let mut depth = 0usize; + for i in 2..part.len() { + if part.get(i).is_some_and(|token| token.is_symbol(b'(')) { + depth += 1; + } else if part.get(i).is_some_and(|token| token.is_symbol(b')')) { + depth = depth.saturating_sub(1); + } else if depth == 0 && part.get(i).is_some_and(|token| token.word_is("PRIMARY")) && part.get(i + 1).is_some_and(|t| t.word_is("KEY")) { + primary = Some(i); + break; + } + } + let Some(i) = primary else { return false }; + !part.get(i + 2).is_some_and(|token| token.word_is("DESC")) && !part.get(i + 2).is_some_and(|token| token.is_symbol(b'(')) +} + +#[derive(Clone, Debug)] +enum Token { + Word(String), + Quoted(String), + StringLiteral, + Symbol(u8), +} + +impl Token { + fn is_symbol(&self, symbol: u8) -> bool { + matches!(self, Token::Symbol(value) if *value == symbol) + } + + fn word_is(&self, wanted: &str) -> bool { + matches!(self, Token::Word(word) if word.eq_ignore_ascii_case(wanted)) + } + + fn identifier(&self) -> Option<&str> { + match self { + Token::Word(word) | Token::Quoted(word) => Some(word), + _ => None, + } + } +} + +fn tokenize_sql(sql: &str) -> Result, String> { + let bytes = sql.as_bytes(); + let mut out = Vec::new(); + let mut at = 0usize; + while at < bytes.len() { + if bytes.get(at).is_some_and(u8::is_ascii_whitespace) { + at += 1; + } else if bytes.get(at) == Some(&b'-') && bytes.get(at + 1) == Some(&b'-') { + at += 2; + while at < bytes.len() && bytes.get(at) != Some(&b'\n') { + at += 1; + } + } else if bytes.get(at) == Some(&b'/') && bytes.get(at + 1) == Some(&b'*') { + at += 2; + let mut closed = false; + while at + 1 < bytes.len() { + if bytes.get(at) == Some(&b'*') && bytes.get(at + 1) == Some(&b'/') { + at += 2; + closed = true; + break; + } + at += 1; + } + if !closed { + return Err("unterminated SQL comment".into()); + } + } else if matches!(bytes.get(at), Some(b'(' | b')' | b',' | b'.' | b';')) { + out.push(Token::Symbol(*bytes.get(at).ok_or("truncated SQL punctuation")?)); + at += 1; + } else if matches!(bytes.get(at), Some(b'"' | b'`' | b'[')) { + let quote = *bytes.get(at).ok_or("truncated SQL quote")?; + let end_quote = if quote == b'[' { b']' } else { quote }; + at += 1; + let mut value = Vec::new(); + let mut closed = false; + while at < bytes.len() { + if bytes.get(at) == Some(&end_quote) { + if bytes.get(at + 1) == Some(&end_quote) { + value.push(end_quote); + at += 2; + } else { + at += 1; + closed = true; + break; + } + } else { + value.push(*bytes.get(at).ok_or("truncated SQL identifier")?); + at += 1; + } + } + if !closed { + return Err("unterminated quoted SQL identifier".into()); + } + out.push(Token::Quoted(String::from_utf8(value).map_err(|_| "invalid SQL identifier encoding")?)); + } else if bytes.get(at) == Some(&b'\'') { + at += 1; + let mut closed = false; + while at < bytes.len() { + if bytes.get(at) == Some(&b'\'') { + if bytes.get(at + 1) == Some(&b'\'') { + at += 2; + } else { + at += 1; + closed = true; + break; + } + } else { + at += 1; + } + } + if !closed { + return Err("unterminated SQL string".into()); + } + out.push(Token::StringLiteral); + } else { + let start = at; + while at < bytes.len() + && !bytes.get(at).is_some_and(u8::is_ascii_whitespace) + && !matches!(bytes.get(at), Some(b'(' | b')' | b',' | b'.' | b';' | b'\'' | b'"' | b'`' | b'[')) + { + at += 1; + } + if start == at { + return Err("unsupported SQL punctuation".into()); + } + out.push(Token::Word(sql.get(start..at).ok_or("invalid SQL token boundary")?.to_string())); + } + } + Ok(out) +} + +fn sqlite_page_size(value: u16) -> Result { + let size = if value == 1 { 65_536 } else { usize::from(value) }; + if !(512..=65_536).contains(&size) || !size.is_power_of_two() { + return Err("invalid SQLite page size".into()); + } + Ok(size) +} + +fn read_varint(bytes: &[u8], at: &mut usize) -> Result { + let mut value = 0u64; + for i in 0..9 { + let byte = *bytes.get(*at).ok_or("truncated SQLite varint")?; + *at = (*at).checked_add(1).ok_or("SQLite varint offset overflow")?; + if i == 8 { + value = (value << 8) | u64::from(byte); + return Ok(value); + } + value = (value << 7) | u64::from(byte & 0x7f); + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err("invalid SQLite varint".into()) +} + +fn usize_from_u64(value: u64, label: &str) -> Result { + usize::try_from(value).map_err(|_| format!("{label} exceeds host size")) +} + +fn be_u16(bytes: &[u8], at: usize) -> Result { + let end = at.checked_add(2).ok_or("SQLite integer offset overflow")?; + bytes.get(at..end).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes).ok_or("truncated SQLite integer".into()) +} + +fn be_u16_at(bytes: &[u8], at: usize) -> Result { + be_u16(bytes, at) +} + +fn be_u32(bytes: &[u8], at: usize) -> Result { + let end = at.checked_add(4).ok_or("SQLite integer offset overflow")?; + bytes.get(at..end).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_be_bytes).ok_or("truncated SQLite integer".into()) +} + +fn be_u32_at(bytes: &[u8], at: usize) -> Result { + be_u32(bytes, at) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn varint(mut value: u64) -> Vec { + let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; + value >>= 7; + while value != 0 { + out.push(u8::try_from(value & 0x7f).unwrap_or(0) | 0x80); + value >>= 7; + } + out.reverse(); + out + } + + fn leaf(page: &mut [u8], base: usize, rowid: u64, payload: &[u8]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(rowid)); + cell.extend(payload); + let start = page.len() - cell.len(); + page[start..].copy_from_slice(&cell); + page[base + 8..base + 10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[base + 5..base + 7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + } + + #[test] + fn schema_names_and_integer_primary_key_alias_are_decoded() { + let sql = r#"CREATE TABLE "t" ("id_local" INTEGER PRIMARY KEY, "odd,name" TEXT, PRIMARY KEY ("id_local"))"#; + let (names, alias) = parse_create_table(sql).unwrap(); + assert_eq!(names, vec!["id_local", "odd,name"]); + assert_eq!(alias, Some(0)); + let (_, no_alias) = parse_create_table("CREATE TABLE t (id INTEGER PRIMARY KEY DESC, value TEXT)").unwrap(); + assert_eq!(no_alias, None); + let (table_names, table_alias) = parse_create_table("CREATE TABLE t (id INTEGER, value TEXT, PRIMARY KEY(id))").unwrap(); + assert_eq!(table_names, vec!["id", "value"]); + assert_eq!(table_alias, Some(0)); + } + + #[test] + fn synthetic_btree_substitutes_rowid_and_reads_schema() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let sql = "CREATE TABLE \"t\" (\"id_local\" INTEGER PRIMARY KEY, \"name\" TEXT)"; + // rootpage is an integer field, so replace fourth text value with a compact record. + let mut header = vec![23, 15, 15, 1, u8::try_from(13 + 2 * sql.len()).unwrap_or(0)]; + let mut body = b"tablett".to_vec(); + body.push(2); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.append(&mut header); + record.extend(body); + let schema = record; + leaf(&mut main[512..], 0, 1, &[3, 0, 15, b'x']); + leaf(&mut main[..512], 100, 1, &schema); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert!(tables.iter().any(|table| table.name == "t")); + let rows = db.read_table(2, 2).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); + } + + #[test] + fn interior_table_pointer_array_uses_cell_offsets() { + let mut main = vec![0u8; 4 * 512]; + let mut interior = vec![0u8; 512]; + interior[0] = 0x05; + interior[3..5].copy_from_slice(&1u16.to_be_bytes()); + interior[8..12].copy_from_slice(&4u32.to_be_bytes()); + interior[12..14].copy_from_slice(&500u16.to_be_bytes()); + interior[500..504].copy_from_slice(&3u32.to_be_bytes()); + interior[504] = 1; + main[512..1024].copy_from_slice(&interior); + leaf(&mut main[1024..1536], 0, 1, &[2, 0]); + leaf(&mut main[1536..], 0, 2, &[2, 0]); + let db = Database { + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 4, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert_eq!(db.read_table(2, 1).unwrap().len(), 2); + } + + #[test] + fn negative_rowid_varint_is_signed() { + let mut main = vec![0u8; 512]; + let page = &mut main[..]; + page[100] = 0x0d; + page[103..105].copy_from_slice(&1u16.to_be_bytes()); + let start = 500usize; + page[start] = 2; + for offset in 1..10 { + if let Some(byte) = page.get_mut(start + offset) { + *byte = 0xff; + } + } + page[start + 10..start + 12].copy_from_slice(&[2, 0]); + page[108..110].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[105..107].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let mut aliases = HashMap::new(); + aliases.insert(1, 0); + let db = Database { + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: aliases, + }; + let rows = db.read_table(1, 1).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(-1))); + } + + #[test] + fn reserved_page_trailer_cannot_supply_cells() { + let mut main = vec![0u8; 512]; + main[100] = 0x0d; + main[103..105].copy_from_slice(&1u16.to_be_bytes()); + main[108..110].copy_from_slice(&490u16.to_be_bytes()); + let db = Database { + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 32, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.read_table(1, 1).is_err()); + } + + #[test] + fn utf16_and_overflow_payloads_are_supported() { + let mut payload = vec![2, 17, b'A', 0]; + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Le).unwrap(), vec![Value::Text("A".into())]); + payload.clear(); + payload.extend([2, 17, 0, b'A']); + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Be).unwrap(), vec![Value::Text("A".into())]); + + let mut main = vec![0u8; 2 * 512]; + let db = Database { + main: std::mem::take(&mut main), + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 2, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + let mut cell_page = vec![b'x'; 512]; + cell_page[100..104].copy_from_slice(&2u32.to_be_bytes()); + let inline_page = vec![b'i'; 512]; + assert_eq!(db.payload(&inline_page, 0, 110).unwrap(), vec![b'i'; 110]); + let overflow_page = vec![b'y'; 512]; + let mut db = db; + db.main[512..].copy_from_slice(&overflow_page); + let mut with_overflow = db.overlay; + with_overflow.insert(2, overflow_page); + db.overlay = with_overflow; + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(bytes.len(), 600); + } + + #[test] + fn malformed_varint_and_wal_cycle_fail_cleanly() { + let mut at = 0; + assert!(read_varint(&[0x80; 8], &mut at).is_err()); + let mut wal = vec![0u8; 32 + 24 + 512]; + wal[..4].copy_from_slice(&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(&7u32.to_be_bytes()); + wal[20..24].copy_from_slice(&8u32.to_be_bytes()); + wal[32..36].copy_from_slice(&1u32.to_be_bytes()); + wal[36..40].copy_from_slice(&1u32.to_be_bytes()); + wal[40..44].copy_from_slice(&7u32.to_be_bytes()); + wal[44..48].copy_from_slice(&8u32.to_be_bytes()); + let parsed = parse_wal(&wal, 512).unwrap(); + assert_eq!(parsed.frame_count, 1); + assert_eq!(parsed.page_count, Some(1)); + assert_eq!(parsed.pages.get(&1).map(Vec::len), Some(512)); + let mut page = vec![0u8; 512]; + page[16..18].copy_from_slice(&512u16.to_be_bytes()); + let db = Database { + main: vec![0; 512], + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.payload(&page, 0, 600).is_err()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b7b99a4..1252fdb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod headless; pub mod i18n; pub mod icons; pub mod import; +pub mod lightroom_import; pub mod links; pub mod menubar; pub mod menus; @@ -187,6 +188,10 @@ pub struct LightcraftApp { pub import: Option, /// A folder scan in progress (feeds the import review). pub scan: Option, + /// A Lightroom catalog inspect/import in progress. + pub lightroom: Option, + /// Last terminal Lightroom result, exposed by the command's status/wait response. + pub lightroom_last: Option, /// A background export in progress. pub export: Option, /// Background file-system work of other commands (Find Missing Photos, auto import…). @@ -246,6 +251,8 @@ impl LightcraftApp { merge: merge::MergeState::default(), import: None, scan: None, + lightroom: None, + lightroom_last: None, export: None, tasks: Default::default(), last_export_result: None, @@ -621,6 +628,7 @@ impl LightcraftApp { merge::poll(self, ctx); import::poll_scan(self, ctx); import::tick(self, ctx); + lightroom_import::tick(self, ctx); tasks::poll(self, ctx); self.preview_build_status(ctx); self.save_status(ctx); @@ -642,7 +650,7 @@ impl LightcraftApp { if let Some(folder) = self.session.import_defaults.auto_folder.clone() { const LABEL: &str = "Auto Import"; let now = ctx.input(|i| i.time); - if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && !self.tasks.is_running(LABEL) { + if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && self.lightroom.is_none() && !self.tasks.is_running(LABEL) { self.ui.auto_import_at = now; let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { @@ -683,10 +691,12 @@ impl LightcraftApp { let _ = self.run("file.importPresets", serde_json::json!({"paths": presets})); } // read and added on a worker thread (dropped folders can be large, or on a slow drive) - if !photos.is_empty() - && let Err(e) = import::start_paths(self, photos) - { - self.toast(ctx, e); + if !photos.is_empty() { + if self.lightroom.is_some() { + self.toast(ctx, "Finish Lightroom catalog import before adding photos"); + } else if let Err(e) = import::start_paths(self, photos) { + self.toast(ctx, e); + } } } } @@ -823,6 +833,7 @@ impl LightcraftApp { panels::library_problem::show(self, &ctx); import::progress(self, &ctx); import::scan_progress(self, &ctx); + lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::toast(self, &ctx); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs new file mode 100644 index 00000000..6b2e0a2d --- /dev/null +++ b/crates/ui-egui/src/lightroom_import.rs @@ -0,0 +1,456 @@ +//! Background Lightroom catalog inspection and import. +//! +//! Reading a catalog, probing its originals and staging its archive can take minutes on a +//! removable or network drive. This task keeps that work off the owner thread, commits prepared +//! data only on the owner thread, then finishes the archive index on a worker. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; + +use egui::Align2; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +#[derive(Clone, Copy)] +enum Kind { + Inspect, + Import, +} + +enum Message { + Inspected(Result), + Prepared(Result), + Finalized(Result<(), String>), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Reading, + Committing, + Finalizing, +} + +/// A Lightroom task in flight. The receiver is polled from the egui owner thread. +pub struct LightroomTask { + kind: Kind, + path: PathBuf, + cancel: Arc, + total: Arc, + done: Arc, + rx: mpsc::Receiver, + phase: Phase, + cancelled: bool, +} + +impl LightroomTask { + #[cfg(not(target_arch = "wasm32"))] + fn status(&self) -> Value { + let kind = match self.kind { + Kind::Inspect => "inspect", + Kind::Import => "import", + }; + let phase = match self.phase { + Phase::Reading => "reading", + Phase::Committing => "committing", + Phase::Finalizing => "finalizing", + }; + json!({ + "running": true, + "kind": kind, + "path": self.path, + "phase": phase, + "done": self.done.load(Ordering::Relaxed), + "total": self.total.load(Ordering::Relaxed), + "cancelled": self.cancelled || self.cancel.load(Ordering::Relaxed), + }) + } + + fn cancel(&mut self) { + self.cancelled = true; + self.cancel.store(true, Ordering::Relaxed); + } +} + +impl Drop for LightroomTask { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +#[cfg(target_arch = "wasm32")] +fn unsupported_wasm() -> Result { + Err("Lightroom catalog import is unavailable in browser builds; open the catalog in the native LightCraft app".into()) +} + +/// Whether a Lightroom task currently owns the catalog transition. +pub fn is_running(app: &LightcraftApp) -> bool { + app.lightroom.is_some() +} + +#[cfg(not(target_arch = "wasm32"))] +fn busy(app: &LightcraftApp) -> Result<(), String> { + if app.lightroom.is_some() { + return Err("a Lightroom catalog task is already running".into()); + } + if app.import.is_some() || app.scan.is_some() { + return Err("an import is running".into()); + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn_inspect( + path: PathBuf, + cancel: Arc, + total: Arc, + done: Arc, + tx: mpsc::Sender, + ctx: egui::Context, +) -> Result<(), String> { + std::thread::Builder::new() + .name("lc-lightroom-inspect".into()) + .spawn(move || { + let result = lightcraft_engine::guard::catch("Lightroom inspect", || { + lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &total, &done) + }) + .and_then(|r| r.map(inspect_report)); + let _ = tx.send(Message::Inspected(result)); + ctx.request_repaint(); + }) + .map(|_| ()) + .map_err(|e| format!("could not start Lightroom inspect: {e}")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn_prepare( + mut job: lightcraft_engine::lightroom_job::LightroomJob, + cancel: Arc, + tx: mpsc::Sender, + ctx: egui::Context, +) -> Result<(), String> { + std::thread::Builder::new() + .name("lc-lightroom-import".into()) + .spawn(move || { + let result = lightcraft_engine::guard::catch("Lightroom import", || job.prepare(&cancel)).and_then(|r| r.map_err(|e| e.to_string())); + if let Err(mpsc::SendError(Message::Prepared(Ok(prepared)))) = tx.send(Message::Prepared(result)) { + // The owner dropped the task (for example while closing): staged copies/moves + // must be put back even though no UI receiver remains to accept the result. + prepared.rollback(); + } + ctx.request_repaint(); + }) + .map(|_| ()) + .map_err(|e| format!("could not start Lightroom import: {e}")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn_finalize( + finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization, + tx: mpsc::Sender, + ctx: egui::Context, +) -> Result<(), String> { + std::thread::Builder::new() + .name("lc-lightroom-finalize".into()) + .spawn(move || { + let result = lightcraft_engine::guard::catch("Lightroom archive finalization", || finalization.finish()) + .and_then(|r| r.map_err(|e| e.to_string())); + let _ = tx.send(Message::Finalized(result)); + ctx.request_repaint(); + }) + .map(|_| ()) + .map_err(|e| format!("could not start Lightroom archive finalization: {e}")) +} + +#[cfg(target_arch = "wasm32")] +fn spawn_finalize( + _finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization, + _tx: mpsc::Sender, + _ctx: egui::Context, +) -> Result<(), String> { + unsupported_wasm().map(|_| ()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> Value { + 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, + }) +} + +fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) { + 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}")); + format!("Lightroom {} failed: {e}{warning}", if matches!(kind, Kind::Inspect) { "inspection" } else { "import" }) + } else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) { + format!("Lightroom {} cancelled", if matches!(kind, Kind::Inspect) { "inspection" } else { "import" }) + } else if matches!(kind, Kind::Inspect) { + let photos = value["photos"].as_u64().unwrap_or(0); + let collections = value["collections"].as_u64().unwrap_or(0); + let missing = value["missing"].as_array().map_or(0, Vec::len); + format!("Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing") + } else { + let report = value.get("report").unwrap_or(&value); + let imported = report["imported"].as_u64().unwrap_or(0); + let failed = report["failed"].as_array().map_or(0, Vec::len); + let warning = value.get("indexWarning").and_then(Value::as_str); + if let Some(warning) = warning { + format!("Lightroom import complete: {imported} photos added; index warning: {warning}") + } else if failed == 0 { + format!("Lightroom import complete: {imported} photos added") + } else { + format!("Lightroom import complete: {imported} photos added, {failed} failed") + } + }; + app.lightroom_last = Some(value); + app.lightroom = None; + app.toast_for(ctx, message, 6.0); +} + +#[cfg(not(target_arch = "wasm32"))] +fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result { + busy(app)?; + let cancel = Arc::new(AtomicBool::new(false)); + let total = Arc::new(AtomicUsize::new(0)); + let done = Arc::new(AtomicUsize::new(0)); + let (tx, rx) = mpsc::channel(); + spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?; + app.lightroom_last = None; + app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + Ok(json!({"running": true, "kind": "inspect", "path": path})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, ctx: &egui::Context) -> Result { + busy(app)?; + let job = lightcraft_engine::lightroom_job::LightroomJob::new(&mut app.session, path.clone(), update_existing).map_err(|e| e.to_string())?; + let cancel = Arc::new(AtomicBool::new(false)); + let total = job.total_atomic(); + let done = job.done_atomic(); + let (tx, rx) = mpsc::channel(); + spawn_prepare(job, cancel.clone(), tx, ctx.clone())?; + app.lightroom_last = None; + app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + Ok(json!({"running": true, "kind": "import", "path": path})) +} + +/// Run Lightroom UI commands before the generic engine dispatcher. +pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context) -> Result { + #[cfg(target_arch = "wasm32")] + { + let _ = (app, id, p, ctx); + return unsupported_wasm(); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = p.get("path").and_then(Value::as_str).map(PathBuf::from); + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let status = p.get("status").and_then(Value::as_bool).unwrap_or(false) || path.is_none(); + if status { + if p.get("cancel").and_then(Value::as_bool).unwrap_or(false) { + if let Some(task) = app.lightroom.as_mut() { + task.cancel(); + } + } + if wait && app.lightroom.is_some() { + return wait_for(app, ctx, std::time::Duration::from_secs(600)); + } + return Ok(app.lightroom.as_ref().map(LightroomTask::status).or_else(|| app.lightroom_last.clone()).unwrap_or(Value::Null)); + } + let Some(path) = path else { return Err("missing `path`".into()) }; + let result = if id == "library.inspectLightroom" { + start_inspect(app, path, ctx) + } else { + start_import(app, path, p.get("updateExisting").and_then(Value::as_bool).unwrap_or(false), ctx) + }?; + if wait { + return wait_for(app, ctx, std::time::Duration::from_secs(600)); + } + Ok(result) + } +} + +/// Poll owner-thread work, commit a prepared import, and finish its archive index. +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(mut task) = app.lightroom.take() else { return }; + let message = match task.rx.try_recv() { + Ok(m) => m, + Err(mpsc::TryRecvError::Empty) => { + app.lightroom = Some(task); + return; + } + Err(mpsc::TryRecvError::Disconnected) => { + terminal(app, ctx, task.kind, json!({"error": "Lightroom worker stopped unexpectedly"})); + return; + } + }; + match message { + Message::Inspected(result) => match result { + Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, task.kind, report), + Ok(_) => terminal(app, ctx, task.kind, json!({"cancelled": true})), + Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, task.kind, json!({"cancelled": true})), + Err(e) => terminal(app, ctx, task.kind, json!({"error": e})), + }, + Message::Prepared(result) => { + let prepared = match result { + Ok(p) => p, + Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => { + terminal(app, ctx, task.kind, json!({"cancelled": true})); + return; + } + Err(e) => { + terminal(app, ctx, task.kind, json!({"error": e})); + return; + } + }; + if task.cancel.load(Ordering::Relaxed) || prepared.is_cancelled() || !prepared.token().matches(&app.session) { + prepared.rollback(); + terminal( + app, + ctx, + task.kind, + if task.cancel.load(Ordering::Relaxed) { + json!({"cancelled": true}) + } else { + json!({"error": "Lightroom import belongs to a different open library"}) + }, + ); + return; + } + task.phase = Phase::Committing; + let mut finalization = None; + let mut report = None; + let committed = app.session.execute_fn("library.importLightroom", |s| { + let completion = lightcraft_engine::lightroom_job::commit_prepared(s, prepared)?; + report = Some(completion.report.clone()); + finalization = Some(completion.finalization); + Ok(completion.report) + }); + let report = report.unwrap_or(Value::Null); + task.phase = Phase::Finalizing; + let (tx, rx) = mpsc::channel(); + let commit_error = committed.as_ref().err().map(ToString::to_string); + let Some(finalization) = finalization else { + terminal(app, ctx, task.kind, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())})); + return; + }; + if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) { + let mut result = json!({"report": report, "indexWarning": e}); + if let Some(error) = commit_error { + result["error"] = json!(error); + } + terminal(app, ctx, task.kind, result); + return; + } + task.rx = rx; + task.cancelled = false; + let mut pending = json!({"running": true, "kind": "import", "report": report}); + if let Some(error) = commit_error { + pending["error"] = json!(error); + } + app.lightroom_last = Some(pending); + app.lightroom = Some(task); + } + Message::Finalized(result) => { + let pending = app.lightroom_last.take().unwrap_or(Value::Null); + let report = pending.get("report").cloned().unwrap_or(Value::Null); + match result { + Ok(()) if pending.get("error").is_none() => terminal(app, ctx, task.kind, report), + Ok(()) => terminal(app, ctx, task.kind, json!({"report": report, "error": pending["error"].clone()})), + Err(e) => { + let mut value = json!({"report": report, "indexWarning": e}); + if let Some(error) = pending.get("error") { + value["error"] = error.clone(); + } + terminal(app, ctx, task.kind, value); + } + } + } + } + ctx.request_repaint(); +} + +/// Wait for a task in control/headless mode while still applying owner-thread completion. +#[cfg(not(target_arch = "wasm32"))] +pub fn wait_for(app: &mut LightcraftApp, ctx: &egui::Context, timeout: std::time::Duration) -> Result { + let start = std::time::Instant::now(); + while app.lightroom.is_some() { + tick(app, ctx); + if start.elapsed() >= timeout { + return Err("timed out waiting for Lightroom task".into()); + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + Ok(app.lightroom_last.clone().unwrap_or(Value::Null)) +} + +#[cfg(target_arch = "wasm32")] +pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::time::Duration) -> Result { + unsupported_wasm() +} + +/// The progress window for inspect/import, with cancellation. +pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(task) = app.lightroom.as_ref() else { return }; + let total = task.total.load(Ordering::Relaxed); + let done = task.done.load(Ordering::Relaxed); + let title = match task.kind { + Kind::Inspect => "Inspecting Lightroom Catalog", + Kind::Import => "Importing Lightroom Catalog", + }; + let text = match task.phase { + Phase::Reading if total == 0 => "Reading Lightroom catalog…".to_string(), + Phase::Reading => format!("Reading Lightroom catalog… {done} of {total}"), + Phase::Committing => "Adding Lightroom photos…".to_string(), + Phase::Finalizing => "Saving Lightroom import index…".to_string(), + }; + let mut cancel = false; + let t = Tokens::get(ctx); + egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.0]).show( + ctx, + |ui| { + ui.label(egui::RichText::new(text).color(t.text)); + ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0)); + let r = ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new("Cancel")); + register(ui.ctx(), "button:lightroomCancel", r.rect); + cancel = r.clicked(); + }, + ); + if cancel { + if let Some(task) = app.lightroom.as_mut() { + task.cancel(); + } + ctx.request_repaint(); + } + if app.lightroom.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +#[cfg(test)] +mod tests { + use super::inspect_report; + use lightcraft_engine::lightroom_catalog::CatalogImport; + + #[test] + fn inspect_report_keeps_empty_catalog_shape() { + let report = inspect_report(CatalogImport { + source: "synthetic.lrcat".into(), + photos: Vec::new(), + collections: Vec::new(), + members: Vec::new(), + collection_content: Vec::new(), + warnings: vec!["warning".into()], + }); + assert_eq!(report["photos"], 0); + assert_eq!(report["collections"], 0); + assert_eq!(report["missing"], serde_json::json!([])); + assert_eq!(report["warnings"], serde_json::json!(["warning"])); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c841d9e..b04ba2bd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -236,6 +236,10 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { + if matches!(id, "library.inspectLightroom" | "library.importLightroom") { + let ctx = egui::Context::default(); + return Some(crate::lightroom_import::command(app, id, p, &ctx)); + } if let Some(language) = language_from_command(id) { app.ui.language = language; // Immediately, not on the next frame: the reply and anything else run this frame @@ -948,11 +952,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before browsing folders".into())); + } // listed and read in the background (see `import::browse`) let path = p.get("path").and_then(Value::as_str)?; crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) } "file.addPhotos" => { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before adding photos".into())); + } let paths = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(), @@ -1278,7 +1288,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, - "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(), + "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), "file.backupLibrary" => app.services.backup_library.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index e191668d..745c06ad 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -530,6 +530,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, /// or a folder chosen in a dialog. Remembered as the library to open at launch. pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { + if crate::lightroom_import::is_running(app) { + return Err("wait for Lightroom catalog import to finish before switching libraries".into()); + } let path = match p.get("path").and_then(Value::as_str) { Some(x) => x.to_string(), None => match app.services.pick_folder.as_mut() { @@ -542,6 +545,7 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result }; app.session.close_library().map_err(|e| e.to_string())?; app.session.open_library(&path, false).map_err(|e| e.to_string())?; + app.lightroom_last = None; app.renderer.forget_all(); app.ui.compare = None; app.ui.settings.library_path = path.clone(); diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index a5412da1..694441aa 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -19,10 +19,15 @@ identity prevents reimport from duplicating virtual copies and collections. One 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 +snapshots and collection content are saved to compressed `Interop/lightroom-import-N.lca` archives +in the LightCraft library. Unchanged source data reuses its existing archive. Opaque SQL BLOBs are +skipped; decoded XMP and structured recovery data remain. Each archive is capped at 32 MiB +(uncompressed and on disk), with at most eight archives and 128 MiB total retained. Oldest managed +archives are retired; legacy `.json` archives remain untouched. `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. +one million rows per table. 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. Unsupported +WITHOUT ROWID and virtual tables produce explicit errors. 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 @@ -34,5 +39,15 @@ Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog 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. +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. +Ordinary edits during preparation remain intact. CLI commands complete synchronously using the +same preparation/commit pipeline; browser builds reject native catalog import. + +Archive version 1 begins with the 12 bytes `LC-LRARCH\0\x01Z`, followed by zlib-compressed JSON. +Recovery readers must cap decompression at 32 MiB. JSON retains source identities, photo paths, +settings, decoded XMP, keywords, history, snapshots, collection definitions and membership. + 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 3aee3d8e..79ae9f5e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -716,7 +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-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-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, existing edits preserved. Settings/history/snapshots/collection rules retained in deduplicated compressed archives (32 MiB each, eight archives/128 MiB aggregate); opaque SQL BLOBs skipped. 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 +) | From 6a05f865eedca606dffc07807bb3afbcdde8c7a2 Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Thu, 8 Oct 2026 12:44:30 +0530 Subject: [PATCH 03/25] LRC-LIB-CATALOG-IMPORT: refresh rebased parity summary Co-authored-by: Codex --- docs/parity.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 79ae9f5e..1ea2d967 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | -| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) | +| Lightroom Classic extras | 31 | 11 | 39 | 9 | — | 21/23 (91%) | +| **Total** | 389 | 35 | 86 | 37 | 194/200 (97%) | 139/150 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 510 in-scope rows — P0 98.5% of 200 · P1 95.3% of 150 · P2 41.6% of 160. ## Top gaps From d367d83ee644b29988b71cd9054f78616830a85b Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Thu, 8 Oct 2026 12:46:02 +0530 Subject: [PATCH 04/25] LRC-LIB-CATALOG-IMPORT: exercise actual concurrent develop edit Co-authored-by: Codex --- crates/engine/src/lightroom_job.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index ab383aa1..49e22a2b 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -354,7 +354,9 @@ mod tests { assert_eq!(first.report["mapping"]["1"].as_u64(), Some(1)); assert_eq!(session.undo.len(), 1); let id = lightcraft_catalog::PhotoId(1); - session.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal").unwrap(); + let mut personal = lightcraft_develop::DevelopSettings::default(); + personal.light.exposure = 1.5; + session.set_develop(id, personal, "Personal").unwrap(); let before = session.catalog.photo(id).map(|photo| photo.develop.clone()); let prepared = prepared_for(&session, path, 5); commit_prepared(&mut session, prepared).unwrap(); From e8c817ddeaf44c3024bdbfbdcb4e046dfe16d1f2 Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Thu, 8 Oct 2026 13:04:50 +0530 Subject: [PATCH 05/25] PR218: satisfy current CI clippy without changing import behavior Co-authored-by: Codex --- crates/engine/src/lightroom_archive.rs | 5 ++--- crates/engine/src/lightroom_catalog.rs | 4 ++-- crates/engine/src/lightroom_sqlite.rs | 22 ++++++++++------------ 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index ecb53e25..73042e88 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -50,9 +50,8 @@ pub fn store(directory: &Path, data: &CatalogImport) -> Result managed.sort_by_key(|entry| entry.index); let temporary = temporary_path(directory)?; - write_archive(&temporary, data).map_err(|error| { + write_archive(&temporary, data).inspect_err(|_| { let _ = remove_managed_file(&temporary); - error })?; let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES); @@ -351,7 +350,7 @@ fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Re let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?; let mut retirees = Vec::new(); while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES { - let Some(position) = ordered.iter().position(|entry| protected.map_or(true, |path| entry.path != path)) else { + let Some(position) = ordered.iter().position(|entry| protected.is_none_or(|path| entry.path != path)) else { return Err("cannot satisfy archive retention limit".into()); }; let oldest = ordered.remove(position); diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 47f22247..fcdcbc02 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -363,7 +363,7 @@ impl Reader { .iter() .zip(columns) .find_map(|(value, column)| { - (column == "image").then(|| match value { + (column == "image").then_some(match value { SqlValue::Integer(n) => *n, _ => 0, }) @@ -628,7 +628,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, return Err("catalog changed during reading; close Lightroom and retry".into()); } let reader = Reader { tables: db.live_tables()?, db }; - let mut table = |name: &str, required: bool| -> Result, String> { + let table = |name: &str, required: bool| -> Result, String> { ensure_read_active(cancel)?; let rows = reader.table(name, required)?; done.fetch_add(1, Ordering::Relaxed); diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index be7f4cb9..010d0313 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -91,7 +91,7 @@ impl Database { } let page_size = sqlite_page_size(be_u16(main_header, 16)?)?; validate_header_fields(main_header, page_size)?; - if main.len() < page_size || main.len() % page_size != 0 { + if main.len() < page_size || !main.len().is_multiple_of(page_size) { return Err("SQLite image is not a whole number of pages".into()); } let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?; @@ -225,10 +225,8 @@ impl Database { if index >= values.len() { return Err("INTEGER PRIMARY KEY column is missing from record".into()); } - if matches!(values.get(index), Some(Value::Null)) { - if let Some(value) = values.get_mut(index) { - *value = Value::Integer(rowid); - } + if let Some(value @ Value::Null) = values.get_mut(index) { + *value = Value::Integer(rowid); } } if state.out.len() >= MAX_ROWS { @@ -524,7 +522,7 @@ fn decode_text(bytes: &[u8], encoding: TextEncoding) -> Result { match encoding { TextEncoding::Utf8 => String::from_utf8(bytes.to_vec()).map_err(|_| "invalid UTF-8 SQLite text".into()), TextEncoding::Utf16Le | TextEncoding::Utf16Be => { - if bytes.len() % 2 != 0 { + if !bytes.len().is_multiple_of(2) { return Err("odd-length UTF-16 SQLite text".into()); } let units: Result, String> = bytes @@ -626,12 +624,12 @@ fn parse_create_table(sql: &str) -> Result<(Vec, Option), String> if columns.is_empty() { return Err("CREATE TABLE has no columns".into()); } - if alias.is_none() { - if let Some(primary) = table_primary { - let key = primary.to_ascii_lowercase(); - if integer_columns.contains(&key) { - alias = columns.iter().position(|column| column.eq_ignore_ascii_case(&primary)); - } + if alias.is_none() + && let Some(primary) = table_primary + { + let key = primary.to_ascii_lowercase(); + if integer_columns.contains(&key) { + alias = columns.iter().position(|column| column.eq_ignore_ascii_case(&primary)); } } Ok((columns, alias)) From b256b8b63561d54e34594af3569c72fd5c89ba1b Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Thu, 8 Oct 2026 13:21:07 +0530 Subject: [PATCH 06/25] PR218: bound egui worker message size & simplify cancellation Co-authored-by: Codex --- crates/ui-egui/src/lightroom_import.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 6b2e0a2d..00f4f77f 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -23,7 +23,7 @@ enum Kind { enum Message { Inspected(Result), - Prepared(Result), + Prepared(Result, String>), Finalized(Result<(), String>), } @@ -135,7 +135,8 @@ fn spawn_prepare( std::thread::Builder::new() .name("lc-lightroom-import".into()) .spawn(move || { - let result = lightcraft_engine::guard::catch("Lightroom import", || job.prepare(&cancel)).and_then(|r| r.map_err(|e| e.to_string())); + let result = + lightcraft_engine::guard::catch("Lightroom import", || job.prepare(&cancel)).and_then(|r| r.map(Box::new).map_err(|e| e.to_string())); if let Err(mpsc::SendError(Message::Prepared(Ok(prepared)))) = tx.send(Message::Prepared(result)) { // The owner dropped the task (for example while closing): staged copies/moves // must be put back even though no UI receiver remains to accept the result. @@ -253,10 +254,10 @@ pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); let status = p.get("status").and_then(Value::as_bool).unwrap_or(false) || path.is_none(); if status { - if p.get("cancel").and_then(Value::as_bool).unwrap_or(false) { - if let Some(task) = app.lightroom.as_mut() { - task.cancel(); - } + if p.get("cancel").and_then(Value::as_bool).unwrap_or(false) + && let Some(task) = app.lightroom.as_mut() + { + task.cancel(); } if wait && app.lightroom.is_some() { return wait_for(app, ctx, std::time::Duration::from_secs(600)); @@ -327,7 +328,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let mut finalization = None; let mut report = None; let committed = app.session.execute_fn("library.importLightroom", |s| { - let completion = lightcraft_engine::lightroom_job::commit_prepared(s, prepared)?; + let completion = lightcraft_engine::lightroom_job::commit_prepared(s, *prepared)?; report = Some(completion.report.clone()); finalization = Some(completion.finalization); Ok(completion.report) From 37cfc46e0dc3ea5e4dedb84344f82b54588812ce Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Thu, 8 Oct 2026 13:36:23 +0530 Subject: [PATCH 07/25] PR218: place prepared-import regression tests after production items Co-authored-by: Codex --- crates/engine/src/import.rs | 56 ++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 1e94226e..404cb890 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -656,34 +656,6 @@ fn normalize_import_path(path: &str) -> String { if cfg!(windows) { path.to_lowercase() } else { path } } -#[cfg(test)] -mod prepared_tests { - use super::*; - - #[test] - fn revalidate_add_only_converts_matching_ready_item() { - let mut s = Session::new(); - let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); - let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); - let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); - s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); - let ready = |path: &Path| ReadyFile { - path: path.to_string_lossy().into(), - stored: path.to_string_lossy().into(), - info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, - sidecar: None, - placed: None, - }; - let mut prepared = Prepared { - scanned: 2, - items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], - }; - prepared.revalidate_add(&s); - assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); - assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); - } -} - impl ImportJob { /// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes /// the probes a preceding scan left ([`Session::import_probes`]). @@ -1093,3 +1065,31 @@ impl Session { self } } + +#[cfg(test)] +mod prepared_tests { + use super::*; + + #[test] + fn revalidate_add_only_converts_matching_ready_item() { + let mut s = Session::new(); + let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); + let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); + let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); + s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); + let ready = |path: &Path| ReadyFile { + path: path.to_string_lossy().into(), + stored: path.to_string_lossy().into(), + info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, + sidecar: None, + placed: None, + }; + let mut prepared = Prepared { + scanned: 2, + items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], + }; + prepared.revalidate_add(&s); + assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); + assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); + } +} From 1e6035d9db28a2f12994b5bb52e1c00139740950 Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Thu, 8 Oct 2026 14:37:56 +0530 Subject: [PATCH 08/25] test: encode SQLite fixture serial types & overflow offsets correctly Co-authored-by: Codex --- crates/engine/src/lightroom_sqlite.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 010d0313..ea32b837 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -907,7 +907,8 @@ mod tests { main[56..60].copy_from_slice(&1u32.to_be_bytes()); let sql = "CREATE TABLE \"t\" (\"id_local\" INTEGER PRIMARY KEY, \"name\" TEXT)"; // rootpage is an integer field, so replace fourth text value with a compact record. - let mut header = vec![23, 15, 15, 1, u8::try_from(13 + 2 * sql.len()).unwrap_or(0)]; + let mut header = vec![23, 15, 15, 1]; + header.extend(varint((13 + 2 * sql.len()) as u64)); let mut body = b"tablett".to_vec(); body.push(2); body.extend(sql.as_bytes()); @@ -1021,10 +1022,13 @@ mod tests { rowid_aliases: HashMap::new(), }; let mut cell_page = vec![b'x'; 512]; - cell_page[100..104].copy_from_slice(&2u32.to_be_bytes()); + // For a 512-byte table leaf & 600-byte payload, SQLite stores 92 + // payload bytes locally, followed by the overflow page pointer. + cell_page[92..96].copy_from_slice(&2u32.to_be_bytes()); let inline_page = vec![b'i'; 512]; assert_eq!(db.payload(&inline_page, 0, 110).unwrap(), vec![b'i'; 110]); - let overflow_page = vec![b'y'; 512]; + let mut overflow_page = vec![b'y'; 512]; + overflow_page[..4].copy_from_slice(&0u32.to_be_bytes()); let mut db = db; db.main[512..].copy_from_slice(&overflow_page); let mut with_overflow = db.overlay; @@ -1032,6 +1036,8 @@ mod tests { db.overlay = with_overflow; let bytes = db.payload(&cell_page, 0, 600).unwrap(); assert_eq!(bytes.len(), 600); + assert_eq!(&bytes[..92], &[b'x'; 92]); + assert_eq!(&bytes[92..], &[b'y'; 508]); } #[test] From bbcfdf085b04b7fa1c74f88211c2e3cc8c3cdcf2 Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:05:31 +0530 Subject: [PATCH 09/25] PR218: harden catalog import review fixes Co-authored-by: Codex --- apps/lightcraft/src/main.rs | 4 +- crates/engine/src/lightroom_archive.rs | 32 ++ crates/engine/src/lightroom_catalog.rs | 613 +++++++++++++------------ crates/engine/src/lightroom_job.rs | 8 +- crates/engine/src/lightroom_sqlite.rs | 39 +- crates/ui-egui/src/lightroom_import.rs | 30 +- 6 files changed, 409 insertions(+), 317 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index f419b9aa..c04ba930 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -232,8 +232,8 @@ fn services() -> Services { Services { pick_lightroom_catalog: Some(Box::new(|| { rfd::FileDialog::new() - .set_title("Import Lightroom Catalog") - .add_filter("Lightroom Classic Catalog", &["lrcat"]) + .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) + .add_filter(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) })), diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index 73042e88..0dceb421 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -126,6 +126,16 @@ pub fn store(directory: &Path, data: &CatalogImport) -> Result Ok(destination) } +/// Store source data when possible. An archive that reaches its bounded size is +/// omitted with a warning so catalog import can still complete. +pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result, String> { + match store(directory, data) { + Ok(path) => Ok(Some(path)), + Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None), + Err(error) => Err(error), + } +} + #[derive(Clone, Debug)] struct Managed { index: u64, @@ -596,6 +606,28 @@ mod tests { fs::remove_dir_all(path).unwrap(); } + #[test] + fn large_catalog_history_is_best_effort_and_does_not_abort_import() { + let path = tempdir(); + let mut input = data("catalog.lrcat".into()); + input.photos = (0..5_000) + .map(|index| CatalogPhoto { + source_id: index, + uuid: format!("photo-{index}"), + path: format!("/photos/{index}.raw"), + image: BTreeMap::new(), + settings: String::new(), + xmp: String::new(), + keywords: Vec::new(), + history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])], + snapshots: Vec::new(), + }) + .collect(); + assert_eq!(store_best_effort(&path, &input).unwrap(), None); + assert_eq!(managed_count(&path), 0); + fs::remove_dir_all(path).unwrap(); + } + #[test] fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() { let path = tempdir(); diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index fcdcbc02..9968ca66 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -21,278 +21,6 @@ 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("") } @@ -348,6 +76,16 @@ impl Reader { .collect()) } + fn optional_table(&self, name: &str, warnings: &mut Vec) -> Result, String> { + match self.table(name, false) { + Ok(rows) => Ok(rows), + Err(error) => { + warnings.push(format!("skipping optional Lightroom table {name}: {error}")); + Ok(Vec::new()) + } + } + } + fn xmp_table(&self) -> Result<(Vec, Vec), String> { let Some(t) = self.tables.iter().find(|t| t.name == "Adobe_AdditionalMetadata") else { return Ok((Vec::new(), Vec::new())) }; let columns = t.column_names.as_ref().ok_or_else(|| "cannot read Adobe_AdditionalMetadata columns".to_string())?; @@ -628,24 +366,40 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, return Err("catalog changed during reading; close Lightroom and retry".into()); } let reader = Reader { tables: db.live_tables()?, db }; + let mut warnings = Vec::new(); let table = |name: &str, required: bool| -> Result, String> { ensure_read_active(cancel)?; let rows = reader.table(name, required)?; done.fetch_add(1, Ordering::Relaxed); Ok(rows) }; + let optional_table = |name: &str, warnings: &mut Vec| -> Result, String> { + ensure_read_active(cancel)?; + let rows = reader.optional_table(name, warnings)?; + done.fetch_add(1, Ordering::Relaxed); + Ok(rows) + }; let roots: HashMap<_, _> = table("AgLibraryRootFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); let folders: HashMap<_, _> = table("AgLibraryFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); let files: HashMap<_, _> = table("AgLibraryFile", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); - let develops: HashMap<_, _> = - table("Adobe_imageDevelopSettings", false)?.into_iter().map(|r| (number(&r, "image"), text(&r, "text").to_string())).collect(); + let develops: HashMap<_, _> = optional_table("Adobe_imageDevelopSettings", &mut warnings)? + .into_iter() + .map(|r| (number(&r, "image"), text(&r, "text").to_string())) + .collect(); ensure_read_active(cancel)?; - let (metadata_rows, mut metadata_warnings) = reader.xmp_table()?; + let (metadata_rows, metadata_warnings) = match reader.xmp_table() { + Ok(result) => result, + Err(error) => { + warnings.push(format!("skipping optional Lightroom table Adobe_AdditionalMetadata: {error}")); + (Vec::new(), Vec::new()) + } + }; + warnings.extend(metadata_warnings); done.fetch_add(1, Ordering::Relaxed); let metadata: HashMap<_, _> = metadata_rows.into_iter().map(|r| (number(&r, "image"), r)).collect(); - let keyword_rows: HashMap<_, _> = table("AgLibraryKeyword", false)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let keyword_rows: HashMap<_, _> = optional_table("AgLibraryKeyword", &mut warnings)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); let mut keywords: HashMap> = HashMap::new(); - for r in table("AgLibraryKeywordImage", false)? { + for r in optional_table("AgLibraryKeywordImage", &mut warnings)? { ensure_read_active(cancel)?; let mut parts = Vec::new(); let mut at = number(&r, "tag"); @@ -673,15 +427,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, } out }; - let mut history = group(table("Adobe_libraryImageDevelopHistoryStep", false)?); - let mut snapshots = group(table("Adobe_libraryImageDevelopSnapshot", false)?); + let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?); + let mut snapshots = group(optional_table("Adobe_libraryImageDevelopSnapshot", &mut warnings)?); let mut out = CatalogImport { source: path.to_string_lossy().into(), photos: Vec::new(), - collections: table("AgLibraryCollection", false)?, - members: table("AgLibraryCollectionImage", false)?, - collection_content: table("AgLibraryCollectionContent", false)?, - warnings: std::mem::take(&mut metadata_warnings), + collections: optional_table("AgLibraryCollection", &mut warnings)?, + members: optional_table("AgLibraryCollectionImage", &mut warnings)?, + collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?, + warnings, }; for image in table("Adobe_images", true)? { ensure_read_active(cancel)?; @@ -889,7 +643,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex 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()) { + if ["RAW", "DNG", "ARW", "CR2", "CR3", "NEF", "NRW", "RAF", "ORF", "RW2", "PEF"].contains(&p.format.to_uppercase().as_str()) { p.kind = lightcraft_catalog::MediaKind::Raw; } p @@ -972,6 +726,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex 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 grouped_members: HashMap> = HashMap::new(); + for member in &data.members { + grouped_members.entry(number(member, "collection")).or_default().push(member); + } + for members in grouped_members.values_mut() { + members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); + } let mut ordered: Vec<_> = data.collections.iter().collect(); ordered.sort_by_key(|r| { let mut depth = 0; @@ -991,16 +752,20 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex 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(); + album.photos = grouped_members + .get(&number(collection, "id_local")) + .into_iter() + .flatten() + .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(); + let mut seen: HashSet<_> = photos.iter().copied().collect(); for photo in album.photos { - if !photos.contains(&photo) { + if seen.insert(photo) { photos.push(photo); } } @@ -1019,3 +784,275 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex index: index.clone(), }) } + +#[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(); + } +} diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 49e22a2b..0346dd62 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -175,7 +175,7 @@ impl LightroomJob { } self.import.total.store(1, Ordering::Relaxed); self.import.done.store(0, Ordering::Relaxed); - let data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?; + let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?; lightroom_catalog::validate_for_job(&data).map_err(error)?; if cancel.load(Ordering::Relaxed) { return Err(error("Lightroom import cancelled")); @@ -199,7 +199,11 @@ impl LightroomJob { normal.rollback(); return Err(error("Lightroom import cancelled")); } - let archive_path = self.archive_dir.as_ref().map(|dir| crate::lightroom_archive::store(dir, &data).map_err(error)).transpose()?; + let archive_path = + self.archive_dir.as_ref().map(|dir| crate::lightroom_archive::store_best_effort(dir, &data).map_err(error)).transpose()?.flatten(); + if self.archive_dir.is_some() && archive_path.is_none() { + data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into()); + } let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json")); let index = lightroom_catalog::load_index(index_path.as_deref())?; Ok(PreparedLightroom { diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index ea32b837..70f69a94 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -273,22 +273,37 @@ impl Database { if usable < 8 { return Err("SQLite usable page size is too small".into()); } - let max_local = usable.saturating_sub(35); - let min_local = ((usable - 12).saturating_mul(32) / 255).saturating_sub(23); + let max_local = usable.checked_sub(35).ok_or("SQLite usable page size is too small")?; + let min_local = usable + .checked_sub(12) + .ok_or("SQLite usable page size is too small")? + .saturating_mul(32) + .checked_div(255) + .ok_or("SQLite local payload calculation failed")? + .saturating_sub(23); let local = if payload_len <= max_local { payload_len } else { let span = usable - 4; - let candidate = min_local.saturating_add((payload_len - min_local) % span); + let candidate = min_local.saturating_add(payload_len.checked_sub(min_local).ok_or("SQLite payload length is too small")? % span); if candidate > max_local { min_local } else { candidate } }; let local_end = cell_at.checked_add(local).ok_or("SQLite cell payload overflow")?; if local_end > usable { return Err("truncated SQLite cell payload".into()); } - let mut out = Vec::with_capacity(payload_len); + let overflow_bytes = self + .page_count + .saturating_sub(1) + .try_into() + .unwrap_or(usize::MAX) + .min(MAX_OVERFLOW_PAGES) + .checked_mul(usable.checked_sub(4).ok_or("SQLite usable page size is too small")?) + .ok_or("SQLite overflow capacity overflow")?; + let capacity = local.checked_add(overflow_bytes).ok_or("SQLite payload capacity overflow")?.min(payload_len); + let mut out = Vec::with_capacity(capacity); out.extend_from_slice(page.get(cell_at..local_end).ok_or("truncated SQLite cell payload")?); - let mut remaining = payload_len.saturating_sub(local); + let mut remaining = payload_len.checked_sub(local).ok_or("SQLite payload length is too small")?; if remaining == 0 { return Ok(out); } @@ -305,7 +320,7 @@ impl Database { return Err("invalid or cyclic SQLite overflow chain".into()); } let overflow = self.page(next)?; - let take = remaining.min(usable - 4); + let take = remaining.min(usable.checked_sub(4).ok_or("SQLite usable page size is too small")?); let end = 4usize.checked_add(take).ok_or("overflow payload offset")?; out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?); remaining -= take; @@ -334,25 +349,25 @@ impl Database { _ => return Err("sqlite_schema row has invalid name".into()), }; if kind.eq_ignore_ascii_case("virtual") { - return Err(format!("virtual table {name} is unsupported")); + continue; } if !kind.eq_ignore_ascii_case("table") { continue; } let rootpage = match record.values.get(3) { Some(Value::Integer(n)) if *n > 0 => u32::try_from(*n).map_err(|_| "sqlite_schema root page is invalid")?, - _ => return Err(format!("table {name} has invalid root page")), + _ => continue, }; let sql = match record.values.get(4) { Some(Value::Text(s)) => s, - _ => return Err(format!("table {name} has no CREATE TABLE SQL")), + _ => continue, }; - let (column_names, alias) = parse_create_table(sql)?; + let Ok((column_names, alias)) = parse_create_table(sql) else { continue }; if column_names.len() > MAX_COLUMNS { - return Err(format!("table {name} has too many columns")); + continue; } if tables.iter().any(|table: &LiveTable| table.rootpage == rootpage) { - return Err("duplicate SQLite table root page".into()); + continue; } if let Some(index) = alias { aliases.insert(rootpage, index); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 00f4f77f..f841651d 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -188,25 +188,28 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) { 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}")); - format!("Lightroom {} failed: {e}{warning}", if matches!(kind, Kind::Inspect) { "inspection" } else { "import" }) + format!( + "{}: {e}{warning}", + crate::i18n::tr(if matches!(kind, Kind::Inspect) { "Lightroom inspection failed" } else { "Lightroom import failed" }) + ) } else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) { - format!("Lightroom {} cancelled", if matches!(kind, Kind::Inspect) { "inspection" } else { "import" }) + crate::i18n::tr(if matches!(kind, Kind::Inspect) { "Lightroom inspection cancelled" } else { "Lightroom import cancelled" }).into() } else if matches!(kind, Kind::Inspect) { let photos = value["photos"].as_u64().unwrap_or(0); let collections = value["collections"].as_u64().unwrap_or(0); let missing = value["missing"].as_array().map_or(0, Vec::len); - format!("Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing") + format!("{}: {photos} photos, {collections} collections, {missing} missing", crate::i18n::tr("Lightroom catalog inspected")) } else { let report = value.get("report").unwrap_or(&value); let imported = report["imported"].as_u64().unwrap_or(0); let failed = report["failed"].as_array().map_or(0, Vec::len); let warning = value.get("indexWarning").and_then(Value::as_str); if let Some(warning) = warning { - format!("Lightroom import complete: {imported} photos added; index warning: {warning}") + format!("{}: {imported} photos added; index warning: {warning}", crate::i18n::tr("Lightroom import complete")) } else if failed == 0 { - format!("Lightroom import complete: {imported} photos added") + format!("{}: {imported} photos added", crate::i18n::tr("Lightroom import complete")) } else { - format!("Lightroom import complete: {imported} photos added, {failed} failed") + format!("{}: {imported} photos added, {failed} failed", crate::i18n::tr("Lightroom import complete")) } }; app.lightroom_last = Some(value); @@ -402,14 +405,14 @@ pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { let total = task.total.load(Ordering::Relaxed); let done = task.done.load(Ordering::Relaxed); let title = match task.kind { - Kind::Inspect => "Inspecting Lightroom Catalog", - Kind::Import => "Importing Lightroom Catalog", + Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"), + Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"), }; let text = match task.phase { - Phase::Reading if total == 0 => "Reading Lightroom catalog…".to_string(), - Phase::Reading => format!("Reading Lightroom catalog… {done} of {total}"), - Phase::Committing => "Adding Lightroom photos…".to_string(), - Phase::Finalizing => "Saving Lightroom import index…".to_string(), + Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(), + Phase::Reading => format!("{} {done} of {total}", crate::i18n::tr("Reading Lightroom catalog…")), + Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(), + Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(), }; let mut cancel = false; let t = Tokens::get(ctx); @@ -418,7 +421,8 @@ pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { |ui| { ui.label(egui::RichText::new(text).color(t.text)); ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0)); - let r = ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new("Cancel")); + let r = + ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel"))); register(ui.ctx(), "button:lightroomCancel", r.rect); cancel = r.clicked(); }, From d16ab1f23860d2175d558ec0f882ae2f88f932ba Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:05:52 +0530 Subject: [PATCH 10/25] PR218: check SQLite local payload span Co-authored-by: Codex --- crates/engine/src/lightroom_sqlite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 70f69a94..25572522 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -284,7 +284,7 @@ impl Database { let local = if payload_len <= max_local { payload_len } else { - let span = usable - 4; + let span = usable.checked_sub(4).ok_or("SQLite usable page size is too small")?; let candidate = min_local.saturating_add(payload_len.checked_sub(min_local).ok_or("SQLite payload length is too small")? % span); if candidate > max_local { min_local } else { candidate } }; From 7cf3719c6373ca2215279719ad8408cd7bd0a259 Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:11:29 +0530 Subject: [PATCH 11/25] PR218: localize complete import messages & scale prepared path Co-authored-by: Codex --- crates/engine/src/lightroom_job.rs | 55 +++++++++++++++++++++ crates/ui-egui/locales/ja-formats.json | 7 +++ crates/ui-egui/locales/pt-br-formats.json | 7 +++ crates/ui-egui/locales/zh-hans-formats.json | 7 +++ crates/ui-egui/locales/zh-hant-formats.json | 7 +++ crates/ui-egui/src/lightroom_import.rs | 28 +++++++---- 6 files changed, 102 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 0346dd62..e3e00ea6 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -369,6 +369,61 @@ mod tests { assert_eq!(session.undo.len(), 3); } + #[test] + fn oversized_history_archive_keeps_prepared_import_successful_with_warning() { + let archive_dir = std::env::temp_dir().join(format!("lightcraft-lr-prepared-scale-{}", std::process::id())); + let mut data = empty_data(); + data.photos = (0..5_000) + .map(|source_id| { + let image = std::collections::BTreeMap::from([ + ("id_local".into(), serde_json::json!(source_id)), + ("fileFormat".into(), serde_json::json!("JPG")), + ("fileWidth".into(), serde_json::json!(1)), + ("fileHeight".into(), serde_json::json!(1)), + ]); + lightroom_catalog::CatalogPhoto { + source_id, + uuid: format!("scale-{source_id}"), + path: format!("/missing/scale-{source_id}.jpg"), + image, + settings: String::new(), + xmp: String::new(), + keywords: Vec::new(), + history: vec![std::collections::BTreeMap::from([( + "text".into(), + serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))), + )])], + snapshots: Vec::new(), + } + }) + .collect(); + let archive_path = crate::lightroom_archive::store_best_effort(&archive_dir, &data).unwrap(); + assert!(archive_path.is_none()); + data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into()); + let mut session = crate::Session::new(); + let token = LightroomLibraryToken::capture(&session); + let prepared = PreparedLightroom { + data, + normal: Prepared::default(), + index: ImportIndex::default(), + archive_path, + token, + missing_sources: HashSet::new(), + update_existing: false, + now: "2026-01-01T00:00:00".into(), + cancelled: false, + }; + let completion = commit_prepared(&mut session, prepared).unwrap(); + assert_eq!(completion.report["photos"], 5_000); + assert!( + completion.report["warnings"] + .as_array() + .is_some_and(|warnings| warnings.iter().any(|warning| warning == "Lightroom source archive exceeded 32 MiB; archive skipped")) + ); + assert_eq!(session.catalog.len(), 5_000); + std::fs::remove_dir_all(archive_dir).unwrap(); + } + #[test] fn execute_fn_import_does_not_rewrite_source_sidecar() { let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id())); diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 1944d7b6..77abbadd 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -46,6 +46,7 @@ "Proof Preview · {}": "校正プレビュー・{}", "Quick Develop · {n} photos": "クイック現像・{n}枚", "Rated {}": "評価:{}", + "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", "Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚", "Redo {}": "やり直す:{}", "Remove “{}” from Favorites": "「{}」をお気に入りから削除", @@ -109,6 +110,12 @@ "Build Smart Previews ({scope})": "スマートプレビューを作成({scope})", "Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})", "Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", + "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", "Label Set: {name}": "ラベルセット:{name}", "Quick Develop: {name}": "クイック現像: {name}", "Reset {name}": "{name}をリセット", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 50b0f2ab..c79f96de 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -43,6 +43,12 @@ "Imported {n} preset{}": "Importadas {n} predefinição{:.0}", "Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível", "Imported {} photo{}": "Importadas {} foto{:.0}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", + "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", "Label Set: {name}": "Conjunto de rótulos: {name}", "Matches: {}": "Correspondências: {}", "Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%", @@ -58,6 +64,7 @@ "Quick Develop · {n} photos": "Revelação rápida · {n} fotos", "Quick Develop: {name}": "Revelação rápida: {name}", "Rated {}": "Avaliada {}", + "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", "Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}", "Reading photos… {} of {}": "Lendo fotos… {} de {}", "Redo {}": "Refazer {}", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index c3713eb1..bdd23fb3 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -46,6 +46,7 @@ "Proof Preview · {}": "校样预览 · {0}", "Quick Develop · {n} photos": "快速编辑 · {n} 张照片", "Rated {}": "已评级 {0}", + "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", "Redo {}": "重做:{0}", "Remove “{}” from Favorites": "从收藏夹中移除“{0}”", @@ -109,6 +110,12 @@ "Build Smart Previews ({scope})": "生成智能预览({scope})", "Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})", "Discard Smart Previews ({scope})": "丢弃智能预览({scope})", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", + "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", "Label Set: {name}": "标签集:{name}", "Quick Develop: {name}": "快速编辑:{name}", "Reset {name}": "重置{name}", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 816936c0..0147db0c 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -40,6 +40,12 @@ "Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}", "Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}", "Imported {} photo{}": "已匯入 {} 張照片{:.0}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", + "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", "Label Set: {name}": "標籤集:{name}", "Matches: {}": "符合條件:{}", "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%", @@ -55,6 +61,7 @@ "Quick Develop · {n} photos": "快速顯像 · {n} 張照片", "Quick Develop: {name}": "快速顯像:{name}", "Rated {}": "評分:{}", + "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", "Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}", "Reading photos… {} of {}": "正在讀取照片… {}/{}", "Redo {}": "重做 {}", diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index f841651d..8cee63e3 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -188,28 +188,38 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) { 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}")); - format!( - "{}: {e}{warning}", - crate::i18n::tr(if matches!(kind, Kind::Inspect) { "Lightroom inspection failed" } else { "Lightroom import failed" }) - ) + if matches!(kind, Kind::Inspect) { + crate::i18n::tr_format!("Lightroom inspection failed: {e}{warning}", e = e, warning = warning) + } else { + crate::i18n::tr_format!("Lightroom import failed: {e}{warning}", e = e, warning = warning) + } } else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) { crate::i18n::tr(if matches!(kind, Kind::Inspect) { "Lightroom inspection cancelled" } else { "Lightroom import cancelled" }).into() } else if matches!(kind, Kind::Inspect) { let photos = value["photos"].as_u64().unwrap_or(0); let collections = value["collections"].as_u64().unwrap_or(0); let missing = value["missing"].as_array().map_or(0, Vec::len); - format!("{}: {photos} photos, {collections} collections, {missing} missing", crate::i18n::tr("Lightroom catalog inspected")) + crate::i18n::tr_format!( + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", + photos = photos, + collections = collections, + missing = missing + ) } else { let report = value.get("report").unwrap_or(&value); let imported = report["imported"].as_u64().unwrap_or(0); let failed = report["failed"].as_array().map_or(0, Vec::len); let warning = value.get("indexWarning").and_then(Value::as_str); if let Some(warning) = warning { - format!("{}: {imported} photos added; index warning: {warning}", crate::i18n::tr("Lightroom import complete")) + crate::i18n::tr_format!( + "Lightroom import complete: {imported} photos added; index warning: {warning}", + imported = imported, + warning = warning + ) } else if failed == 0 { - format!("{}: {imported} photos added", crate::i18n::tr("Lightroom import complete")) + crate::i18n::tr_format!("Lightroom import complete: {imported} photos added", imported = imported) } else { - format!("{}: {imported} photos added, {failed} failed", crate::i18n::tr("Lightroom import complete")) + crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed) } }; app.lightroom_last = Some(value); @@ -410,7 +420,7 @@ pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { }; let text = match task.phase { Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(), - Phase::Reading => format!("{} {done} of {total}", crate::i18n::tr("Reading Lightroom catalog…")), + Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total), Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(), Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(), }; From 3c76902bbb9d7f52582e4116a49362e5f9ab1fab Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:12:06 +0530 Subject: [PATCH 12/25] PR218: cover unsupported schema entries Co-authored-by: Codex --- crates/engine/src/lightroom_sqlite.rs | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 25572522..95b09d9d 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -894,6 +894,40 @@ mod tests { page[base + 5..base + 7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); } + fn leaf_rows(page: &mut [u8], base: usize, rows: &[(u64, &[u8])]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&u16::try_from(rows.len()).unwrap_or(0).to_be_bytes()); + let mut end = page.len(); + for (index, (rowid, payload)) in rows.iter().enumerate() { + let mut cell = varint(payload.len() as u64); + cell.extend(varint(*rowid)); + cell.extend(payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + let pointer = base + 8 + index * 2; + page[pointer..pointer + 2].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); + } + page[base + 5..base + 7].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); + } + + fn schema_record(name: &str, root: u8, sql: &str) -> Vec { + let mut header = vec![23]; + let text_serial = |text: &str| 13 + 2 * text.len() as u64; + header.extend(varint(text_serial(name))); + header.extend(varint(text_serial(name))); + header.push(1); + header.extend(varint(text_serial(sql))); + let mut body = b"table".to_vec(); + body.extend(name.as_bytes()); + body.extend(name.as_bytes()); + body.push(root); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.extend(header); + record.extend(body); + record + } + #[test] fn schema_names_and_integer_primary_key_alias_are_decoded() { let sql = r#"CREATE TABLE "t" ("id_local" INTEGER PRIMARY KEY, "odd,name" TEXT, PRIMARY KEY ("id_local"))"#; @@ -907,6 +941,30 @@ mod tests { assert_eq!(table_alias, Some(0)); } + #[test] + fn unsupported_unrelated_schema_entries_are_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let virtual_sql = "CREATE VIRTUAL TABLE unrelated USING fts5(text)"; + let supported_sql = "CREATE TABLE supported (id INTEGER PRIMARY KEY)"; + let virtual_record = schema_record("unrelated", 2, virtual_sql); + let supported_record = schema_record("supported", 2, supported_sql); + leaf_rows(&mut main[..512], 100, &[(1, &virtual_record), (2, &supported_record)]); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].name, "supported"); + } + #[test] fn synthetic_btree_substitutes_rowid_and_reads_schema() { let mut main = vec![0u8; 2 * 512]; From 07d967820959cc3f77317d26196dae5345d267eb Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:12:39 +0530 Subject: [PATCH 13/25] PR218: cover optional payload cap warning Co-authored-by: Codex --- crates/engine/src/lightroom_catalog.rs | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 9968ca66..9dbb9412 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -1055,4 +1055,35 @@ mod tests { assert!(read(&path).is_err()); std::fs::remove_file(path).unwrap(); } + + #[test] + fn oversized_optional_table_warns_and_is_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let page = &mut main[512..]; + page[0] = 0x0d; + page[3..5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint((64 * 1024 * 1024 + 1) as u64); + cell.extend(varint(1)); + let start = 500 - cell.len(); + page[start..start + cell.len()].copy_from_slice(&cell); + page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let db = Database::open_with_wal(main, &[]).unwrap(); + let reader = + Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; + let mut warnings = Vec::new(); + let rows = reader.optional_table("optional_history", &mut warnings).unwrap(); + assert!(rows.is_empty()); + assert!(warnings.iter().any(|warning| warning.contains("optional_history"))); + } } From 5413c8bb3a50eaf61d217154b32dd4445ef9803e Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:13:49 +0530 Subject: [PATCH 14/25] PR218: make archive scale test exercise staging Co-authored-by: Codex --- crates/engine/src/lightroom_catalog.rs | 1 + crates/engine/src/lightroom_job.rs | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 9dbb9412..3c510fde 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -1069,6 +1069,7 @@ mod tests { main[28..32].copy_from_slice(&2u32.to_be_bytes()); main[44..48].copy_from_slice(&4u32.to_be_bytes()); main[56..60].copy_from_slice(&1u32.to_be_bytes()); + main[100] = 0x0d; let page = &mut main[512..]; page[0] = 0x0d; page[3..5].copy_from_slice(&1u16.to_be_bytes()); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index e3e00ea6..fd939341 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -24,6 +24,14 @@ fn error(message: impl Into) -> crate::EngineError { crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() } } +fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result> { + let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten(); + if archive_dir.is_some() && archive_path.is_none() { + data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into()); + } + Ok(archive_path) +} + /// Memory identity of the library which owns a prepared completion. /// /// Each successful open/close refreshes session identity, including reopening the same path. @@ -199,11 +207,7 @@ impl LightroomJob { normal.rollback(); return Err(error("Lightroom import cancelled")); } - let archive_path = - self.archive_dir.as_ref().map(|dir| crate::lightroom_archive::store_best_effort(dir, &data).map_err(error)).transpose()?.flatten(); - if self.archive_dir.is_some() && archive_path.is_none() { - data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into()); - } + let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?; let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json")); let index = lightroom_catalog::load_index(index_path.as_deref())?; Ok(PreparedLightroom { @@ -397,9 +401,8 @@ mod tests { } }) .collect(); - let archive_path = crate::lightroom_archive::store_best_effort(&archive_dir, &data).unwrap(); + let archive_path = stage_archive(Some(&archive_dir), &mut data).unwrap(); assert!(archive_path.is_none()); - data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into()); let mut session = crate::Session::new(); let token = LightroomLibraryToken::capture(&session); let prepared = PreparedLightroom { From b4a1166f4ba908818f94a05dcf93ce585dee0686 Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:17:24 +0530 Subject: [PATCH 15/25] PR218: document optional-data and recovery warnings --- docs/lightroom-catalog-import.md | 36 +++++++++++++++++++------------- docs/parity.md | 2 +- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 694441aa..d3d1940e 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -16,27 +16,33 @@ hierarchical keywords, regular collections/collection sets, virtual copies and m 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. +catalog changes; any saved recovery archives remain available. -Before changing records, source image records, decoded XMP, verbatim develop settings, history, -snapshots and collection content are saved to compressed `Interop/lightroom-import-N.lca` archives -in the LightCraft library. Unchanged source data reuses its existing archive. Opaque SQL BLOBs are -skipped; decoded XMP and structured recovery data remain. Each archive is capped at 32 MiB -(uncompressed and on disk), with at most eight archives and 128 MiB total retained. Oldest managed -archives are retired; legacy `.json` archives remain untouched. `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 specification; -it introduces no dependency or C runtime. Table payloads are capped at 256 MiB. Unsupported -WITHOUT ROWID and virtual tables produce explicit errors. +Before changing records, the importer attempts to save source image records, decoded XMP, +verbatim develop settings, history, snapshots and collection content to compressed +`Interop/lightroom-import-N.lca` recovery archives in the LightCraft library. Archiving is +best-effort: a size limit or write failure produces a warning and does not prevent photo import. +Unchanged source data reuses its existing archive. Opaque SQL BLOBs are skipped. Each archive is +capped at 32 MiB (uncompressed and on disk), with at most eight archives and 128 MiB total +retained. Oldest managed archives are retired; legacy `.json` archives remain untouched. +`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 +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 +WITHOUT ROWID or virtual table produces an explicit error, handled as a warning when optional. 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. +fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots +and original smart-collection rules. Smart collections become regular albums with current +membership. Archived history and snapshots are source data, not 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 +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. Native desktop inspection/import runs on a cancellable background worker with progress. Only diff --git a/docs/parity.md b/docs/parity.md index 1ea2d967..0212a681 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -716,7 +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-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, existing edits preserved. Settings/history/snapshots/collection rules retained in deduplicated compressed archives (32 MiB each, eight archives/128 MiB aggregate); opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | +| 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-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, 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. 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 +) | From 095c7dce87070b120887a8bfb08cc3a0962b487b Mon Sep 17 00:00:00 2001 From: Adrian D'souza Date: Fri, 9 Oct 2026 00:56:28 +0530 Subject: [PATCH 16/25] fix sqlite test payload extension --- crates/engine/src/lightroom_sqlite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 95b09d9d..900fe4a2 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -901,7 +901,7 @@ mod tests { for (index, (rowid, payload)) in rows.iter().enumerate() { let mut cell = varint(payload.len() as u64); cell.extend(varint(*rowid)); - cell.extend(payload); + cell.extend(*payload); end -= cell.len(); page[end..end + cell.len()].copy_from_slice(&cell); let pointer = base + 8 + index * 2; From e2b626b7f732e87359c9d2f33b0d9b19c9a1d1a7 Mon Sep 17 00:00:00 2001 From: parkerHurst Date: Thu, 8 Oct 2026 14:54:50 -0500 Subject: [PATCH 17/25] =?UTF-8?q?LR-PREF-SHORTCUTS:=20editable=20keyboard?= =?UTF-8?q?=20shortcuts=20in=20Help=20=E2=96=B8=20Keyboard=20Shortcuts=20(?= =?UTF-8?q?=E2=8C=98/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ⌘/ dialog is now a keymap editor: click a shortcut and press the new keys (Esc cancels), × removes it, ↺ restores the default, Reset All clears every change. A key another command had moves to the new command, with a toast. Changes go through the new app.setShortcut / app.resetShortcuts commands into AppSettings.keymap (saved in ui.json) and show up in the menus, the native macOS menu and the control channel's command list. Modifier keys alone never record (holding ⌘ waits for the key it modifies), ⌘C/⌘X/⌘V are recorded from the copy/cut/paste events, and saved entries that don't parse fall back to the default shortcut. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/native_menu.rs | 16 +- crates/ui-egui/src/control.rs | 15 +- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/menus.rs | 11 +- crates/ui-egui/src/panels/dialogs.rs | 35 +-- crates/ui-egui/src/panels/keymap.rs | 218 ++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/shortcuts.rs | 330 +++++++++++++++++++++++---- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/src/tests_keymap.rs | 148 ++++++++++++ docs/parity.md | 8 +- docs/whats-new.md | 5 + 12 files changed, 712 insertions(+), 84 deletions(-) create mode 100644 crates/ui-egui/src/panels/keymap.rs create mode 100644 crates/ui-egui/src/tests_keymap.rs diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 557c9abf..a36c5d33 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -51,6 +51,9 @@ pub struct NativeMenu { /// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it. structure: String, text_focus: bool, + /// The keyboard shortcuts editor is recording a key: every accelerator is off so the key + /// press reaches it. + capturing: bool, } /// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape). @@ -196,7 +199,7 @@ impl NativeMenu { let _ = tx.send(e.id.0); repaint.request_repaint(); })); - let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false }; + let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false }; m.rebuild(app); app.native_menu = true; m @@ -266,6 +269,7 @@ impl NativeMenu { } self.menu.init_for_nsapp(); self.text_focus = false; + self.capturing = false; self.publish_shortcuts(app); } @@ -334,7 +338,7 @@ impl NativeMenu { .values() .filter(|i| i.accel.is_some()) .filter_map(|i| i.shortcut.clone()) - .filter(|sc| !(self.text_focus && yields_to_text(sc))) + .filter(|sc| !self.capturing && !(self.text_focus && yields_to_text(sc))) .collect::>(); app.native_shortcuts.insert(SETTINGS_KEY.to_string()); } @@ -397,14 +401,16 @@ impl NativeMenu { walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); let focus = ctx.egui_wants_keyboard_input(); - if focus != self.text_focus { + let capturing = app.recording_shortcut.is_some(); + if focus != self.text_focus || capturing != self.capturing { self.text_focus = focus; + self.capturing = capturing; for it in self.items.values() { let Some(sc) = it.shortcut.as_deref() else { continue }; - if it.accel.is_none() || !yields_to_text(sc) { + if it.accel.is_none() { continue; } - let accel = if focus { None } else { it.accel }; + let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel }; let _ = match &it.handle { Handle::Plain(h) => h.set_accelerator(accel), Handle::Check(h) => h.set_accelerator(accel), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1a6abdde..71a8ae1c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -54,8 +54,21 @@ fn wrap(r: Result) -> Outcome { } pub fn all_commands(app: &LightcraftApp) -> Value { - let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + let keymap = &app.ui.settings.keymap; + let mut v: Vec = app + .session + .commands() + .into_iter() + .map(|c| { + let mut v = serde_json::to_value(&c).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.insert("shortcut".into(), json!(crate::shortcuts::shortcut_of(keymap, c.id))); + } + v + }) + .collect(); for (id, label, sc, menu) in crate::menus::ui_commands() { + let sc = crate::shortcuts::binding(keymap, id, *sc); v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true})); } Value::Array(v) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7f4d6d6..ed4c601c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,8 @@ mod tests_curve; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_keymap; +#[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_masking; @@ -140,6 +142,9 @@ pub struct LightcraftApp { /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler /// leaves them alone so nothing fires twice. pub native_shortcuts: std::collections::HashSet, + /// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut + /// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled. + pub recording_shortcut: Option, /// The host is [`headless::Headless`] (it answers viewport screenshot commands itself). pub headless_host: bool, /// Warnings to show one at a time (damaged settings files…, issue #103). @@ -219,6 +224,7 @@ impl LightcraftApp { integrated_titlebar: false, native_menu: false, native_shortcuts: Default::default(), + recording_shortcut: None, headless_host: false, notices: vec![], quit_prompt: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e62a382..6fd7e1aa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -168,6 +168,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.github", "LightCraft on GitHub", None, "Help"), ("app.artcraft", "ArtCraft Website", None, "Help"), ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), + ("app.setShortcut", "Set Keyboard Shortcut", None, ""), + ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), ("app.export", "Export Now", None, ""), ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), @@ -946,6 +948,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::shortcuts::set_shortcut(app, p), + "app.resetShortcuts" => { + app.ui.settings.keymap.clear(); + Ok(Value::Null) + } "library.browse" if !cfg!(target_arch = "wasm32") => { // listed and read in the background (see `import::browse`) let path = p.get("path").and_then(Value::as_str)?; @@ -1297,7 +1304,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { id: id.to_string(), label: label.to_string(), menu: m.split('>').map(str::to_string).collect(), - shortcut: sc.map(str::to_string), + shortcut: crate::shortcuts::binding(&app.ui.settings.keymap, id, *sc).map(str::to_string), enabled: ui_enabled(app, id), }) .collect(); @@ -1307,7 +1314,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { id: c.id.into(), label: c.label.into(), menu: c.menu.iter().map(|s| s.to_string()).collect(), - shortcut: c.shortcut.map(str::to_string), + shortcut: crate::shortcuts::shortcut_of(&app.ui.settings.keymap, c.id).map(str::to_string), enabled: c.enabled, }); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 91ffb1c9..f1e90549 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -754,39 +754,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } } - Dialog::Shortcuts => { - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { - for (id, label, sc, _) in crate::menus::ui_commands() { - if let Some(sc) = sc { - ui.label(crate::i18n::tr(label)); - ui.label(*sc); - ui.label(egui::RichText::new(*id).color(t.text_dim)); - ui.end_row(); - } - } - for c in lightcraft_engine::command_specs() { - if let Some(sc) = c.shortcut { - ui.label(crate::i18n::tr(c.label)); - ui.label(sc); - ui.label(egui::RichText::new(c.id).color(t.text_dim)); - ui.end_row(); - } - } - for (sc, id, _) in crate::shortcuts::ALIASES { - let label = crate::menus::ui_commands() - .find(|c| c.0 == *id) - .map(|c| c.1) - .or_else(|| lightcraft_engine::find_command(id).map(|c| c.label)) - .unwrap_or(id); - ui.label(crate::i18n::tr(label)); - ui.label(*sc); - ui.label(egui::RichText::new(*id).color(t.text_dim)); - ui.end_row(); - } - }); - }); - } + Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), } ui.add_space(4.0); ui.horizontal(|ui| { @@ -841,6 +809,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.move_to_top(w.response.layer_id); crate::widgets::register(ctx, "dialog:window", w.response.rect); } + // (while the shortcuts editor records a key, it takes Esc itself to cancel) if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs new file mode 100644 index 00000000..d74fbc72 --- /dev/null +++ b/crates/ui-egui/src/panels/keymap.rs @@ -0,0 +1,218 @@ +//! Help ▸ Keyboard Shortcuts (⌘/): every command's shortcut, editable. +//! +//! Click a shortcut, then press the new keys (Esc cancels); × removes it, ↺ restores the declared +//! one. A key another command had moves to the new command. Changes go through +//! [`crate::shortcuts::assign`] into [`crate::state::AppSettings::keymap`] (saved in `ui.json`), +//! the same path as the `app.setShortcut` command. + +use egui::RichText; + +use crate::LightcraftApp; +use crate::shortcuts::{self, Bindable}; +use crate::theme::Tokens; +use crate::widgets::register; + +const SEARCH: &str = "shortcuts-search"; +const SHOW_ALL: &str = "shortcuts-show-all"; + +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + capture(app, ui.ctx()); + ui.set_min_width(560.0); + let (search_id, all_id) = (egui::Id::new(SEARCH), egui::Id::new(SHOW_ALL)); + let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let mut show_all: bool = ui.data(|d| d.get_temp(all_id)).unwrap_or(false); + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut search).hint_text(crate::i18n::tr("Search commands or keys")).desired_width(220.0)); + register(ui.ctx(), "field:shortcutsSearch", r.rect); + let r = ui.checkbox(&mut show_all, crate::i18n::tr("Show commands without a shortcut")); + register(ui.ctx(), "check:shortcuts.showAll", r.rect); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add_enabled(!app.ui.settings.keymap.is_empty(), egui::Button::new(crate::i18n::tr("Reset All"))); + register(ui.ctx(), "button:shortcutsResetAll", r.rect); + if r.clicked() { + let _ = app.run("app.resetShortcuts", serde_json::json!({})); + app.recording_shortcut = None; + } + }); + }); + ui.data_mut(|d| { + d.insert_temp(search_id, search.clone()); + d.insert_temp(all_id, show_all); + }); + ui.label(RichText::new(crate::i18n::tr("Click a shortcut, then press the new keys (Esc cancels).")).size(11.0).color(t.text_dim)); + ui.add_space(4.0); + + let needle = search.trim().to_lowercase(); + let mut rows: Vec<(&'static Bindable, Option)> = shortcuts::bindable() + .iter() + .map(|b| (b, shortcuts::binding(&app.ui.settings.keymap, b.id, b.default).map(str::to_string))) + .filter(|(b, sc)| show_all || sc.is_some() || app.recording_shortcut.as_deref() == Some(b.id)) + .filter(|(b, sc)| { + needle.is_empty() + || crate::i18n::tr(b.label).to_lowercase().contains(&needle) + || b.id.to_lowercase().contains(&needle) + || sc.as_deref().is_some_and(|sc| sc.to_lowercase().contains(&needle) || menu_text(sc, mac).to_lowercase().contains(&needle)) + }) + .collect(); + rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase()); + + // a fixed height, so the dialog doesn't jump around while the search narrows the list + egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| { + egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| { + for (b, sc) in &rows { + row(app, ui, t, b, sc.as_deref(), mac); + } + if rows.is_empty() { + ui.label(RichText::new(crate::i18n::tr("No matching commands")).color(t.text_dim)); + ui.end_row(); + } + }); + fixed_keys(app, ui, t, mac, &needle); + }); +} + +/// `Cmd+Shift+Z` as menus show it (`⌘⇧Z` on macOS). +fn menu_text(sc: &str, mac: bool) -> String { + crate::menubar::shortcut_text(sc, mac) +} + +fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) { + let changed = app.ui.settings.keymap.contains_key(b.id); + let mut name = RichText::new(crate::i18n::tr(b.label)).color(t.text); + if changed { + name = name.strong(); + } + ui.label(name).on_hover_text(b.id); + let recording = app.recording_shortcut.as_deref() == Some(b.id); + let text = if recording { + RichText::new(crate::i18n::tr("Press keys…")).color(egui::Color32::WHITE) + } else { + match sc { + Some(sc) => RichText::new(menu_text(sc, mac)).color(t.text), + None => RichText::new("—").color(t.text_dim), + } + }; + let r = ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0)).selected(recording)); + register(ui.ctx(), format!("button:shortcut-{}", b.id), r.rect); + if r.clicked() { + app.recording_shortcut = if recording { None } else { Some(b.id.to_string()) }; + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let r = ui.add_enabled(sc.is_some(), egui::Button::new("×").small()).on_hover_text(crate::i18n::tr("Remove shortcut")); + register(ui.ctx(), format!("button:shortcutClear-{}", b.id), r.rect); + if r.clicked() { + apply(app, ui.ctx(), serde_json::json!({"id": b.id, "shortcut": null})); + } + let r = ui.add_enabled(changed, egui::Button::new("↺").small()).on_hover_text(match b.default { + Some(d) => format!("Restore {}", menu_text(d, mac)), + None => crate::i18n::tr("Restore (no shortcut)").to_string(), + }); + register(ui.ctx(), format!("button:shortcutReset-{}", b.id), r.rect); + if r.clicked() { + apply(app, ui.ctx(), serde_json::json!({"id": b.id, "reset": true})); + } + }); + ui.label(RichText::new(b.id).size(10.5).color(t.text_dim)); + ui.end_row(); +} + +/// Keys that aren't a command's own shortcut (ratings, labels, secondary keys): listed, not editable. +/// One the user gave to a command shows struck through. +fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, needle: &str) { + let mut fixed: Vec<(String, &str)> = Vec::new(); + for (sc, id, _) in shortcuts::ALIASES { + let label = shortcuts::find_bindable(id).map_or(*id, |b| b.label); + fixed.push((sc.to_string(), label)); + } + for n in 0..=5 { + fixed.push((n.to_string(), ["Clear rating", "Rate ★", "Rate ★★", "Rate ★★★", "Rate ★★★★", "Rate ★★★★★"][n])); + fixed.push(( + format!("Shift+{n}"), + [ + "Clear rating and advance", + "Rate ★ and advance", + "Rate ★★ and advance", + "Rate ★★★ and advance", + "Rate ★★★★ and advance", + "Rate ★★★★★ and advance", + ][n], + )); + } + for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] { + fixed.push((sc.to_string(), label)); + } + let fixed: Vec<_> = fixed + .into_iter() + .filter(|(sc, label)| { + needle.is_empty() || crate::i18n::tr(label).to_lowercase().contains(needle) || menu_text(sc, mac).to_lowercase().contains(needle) + }) + .collect(); + if fixed.is_empty() { + return; + } + ui.add_space(8.0); + ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text)); + let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect(); + egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { + for (sc, label) in fixed { + ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label)); + let mut text = RichText::new(menu_text(&sc, mac)).color(t.text_dim); + if shortcuts::parse(&sc).is_some_and(|k| taken.contains(&k)) { + text = text.strikethrough(); + } + ui.label(text); + ui.end_row(); + } + }); +} + +/// Run `app.setShortcut` and say what changed (a key taken from another command, or why not). +fn apply(app: &mut LightcraftApp, ctx: &egui::Context, params: serde_json::Value) { + match app.run("app.setShortcut", params) { + Ok(r) => { + let lost: Vec = r["removedFrom"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .map(|id| shortcuts::find_bindable(id).map_or(id, |b| crate::i18n::tr(b.label)).to_string()) + .collect(); + if !lost.is_empty() { + app.toast_for(ctx, format!("Removed from {}", lost.join(", ")), 3.0); + } + } + Err(e) => app.toast_for(ctx, e, 3.0), + } +} + +/// While a row is recording: the first key press becomes its shortcut, Esc cancels. +fn capture(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(id) = app.recording_shortcut.clone() else { return }; + let pressed = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + // ⌘, ⇧, ⌥ and ⌃ on their own arrive as keys too: keep waiting for the key they modify + egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !shortcuts::is_modifier(*key) => Some((*key, *modifiers)), + // the windowing layer turns ⌘C / ⌘X / ⌘V (with or without other modifiers) into these + egui::Event::Copy => Some((egui::Key::C, i.modifiers)), + egui::Event::Cut => Some((egui::Key::X, i.modifiers)), + egui::Event::Paste(_) => Some((egui::Key::V, i.modifiers)), + _ => None, + }) + }); + let Some((key, modifiers)) = pressed else { return }; + // the key press is ours: nothing else (a focused button's Space, Tab navigation) acts on it + ctx.input_mut(|i| { + i.events + .retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))); + }); + app.recording_shortcut = None; + if key == egui::Key::Escape && modifiers.is_none() { + return; + } + match shortcuts::format(modifiers, key) { + Some(sc) => apply(app, ctx, serde_json::json!({"id": id, "shortcut": sc})), + None => app.toast_for(ctx, crate::i18n::tr("That key can't be used as a shortcut"), 3.0), + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 8317dda7..2bb4eaca 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,6 +9,7 @@ pub mod dialogs; pub mod edit; pub mod filterbar; pub mod grid; +pub mod keymap; pub mod left; pub mod library_problem; pub mod masking; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f76757e0..5b7b1ba8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -1,10 +1,195 @@ -//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings and dispatch UI and engine commands. +//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings, apply the user's keymap (Help ▸ +//! Keyboard Shortcuts, `app.setShortcut`) and dispatch UI and engine commands. + +use std::collections::BTreeMap; +use std::sync::OnceLock; use egui::{Key, Modifiers}; -use serde_json::json; +use serde_json::{Value, json}; use crate::LightcraftApp; +/// The user's changes to the keymap: command id → shortcut (`""` = no shortcut). Saved with the +/// app settings (`ui.json`); commands not listed keep their declared shortcut. +pub type Keymap = BTreeMap; + +/// Shortcuts the app menu owns (Settings…, Quit), which no command can take. +pub const RESERVED: &[&str] = &["Cmd+,", "Cmd+Q"]; + +/// A command that can have a shortcut. +#[derive(Clone, Copy, Debug)] +pub struct Bindable { + pub id: &'static str, + pub label: &'static str, + /// The declared shortcut. An engine command whose key a UI command wraps (e.g. `W` opens the + /// White Balance Selector rather than sampling without a point) has none: the UI one owns it. + pub default: Option<&'static str>, +} + +/// Every command that can have a shortcut: UI commands, then engine commands (one entry per id). +pub fn bindable() -> &'static [Bindable] { + static ALL: OnceLock> = OnceLock::new(); + ALL.get_or_init(|| { + let mut v: Vec = Vec::new(); + for (id, label, sc, _) in crate::menus::ui_commands() { + if !v.iter().any(|b| b.id == *id) { + v.push(Bindable { id, label, default: *sc }); + } + } + let ui_keys: Vec<(Modifiers, Key)> = v.iter().filter_map(|b| b.default.and_then(parse)).collect(); + for c in lightcraft_engine::command_specs() { + if v.iter().any(|b| b.id == c.id) { + continue; + } + let default = c.shortcut.filter(|sc| parse(sc).is_some_and(|k| !ui_keys.contains(&k))); + v.push(Bindable { id: c.id, label: c.label, default }); + } + v + }) +} + +pub fn find_bindable(id: &str) -> Option<&'static Bindable> { + bindable().iter().find(|b| b.id == id) +} + +/// The shortcut `id` has now: the user's choice (`""` = none), else the declared one. An entry +/// that doesn't parse (a hand-edited `ui.json`, a modifier-only key saved by an older build) is +/// ignored. +pub fn binding<'a>(keymap: &'a Keymap, id: &str, default: Option<&'a str>) -> Option<&'a str> { + match keymap.get(id) { + Some(sc) if sc.is_empty() => None, + Some(sc) if parse(sc).is_some() => Some(sc.as_str()), + _ => default, + } +} + +/// The effective shortcut of command `id` (`None` for unknown commands). +pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> { + find_bindable(id).and_then(|b| binding(keymap, id, b.default)) +} + +/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. +pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; + +/// Commands other than `id` whose shortcut is the key `sc` (contextual partners excepted). +pub fn conflicts(keymap: &Keymap, id: &str, sc: &str) -> Vec<&'static str> { + let Some(key) = parse(sc) else { return vec![] }; + bindable() + .iter() + .filter(|b| b.id != id && !CONTEXTUAL.iter().any(|(x, y)| (*x == id && *y == b.id) || (*y == id && *x == b.id))) + .filter(|b| binding(keymap, b.id, b.default).and_then(parse) == Some(key)) + .map(|b| b.id) + .collect() +} + +/// Give `id` the shortcut `sc` (`None` = no shortcut), taking it away from commands that had the +/// same key. Returns the commands that lost it. +pub fn assign(keymap: &mut Keymap, id: &str, sc: Option<&str>) -> Result, String> { + let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?; + let sc = match sc.map(str::trim).filter(|s| !s.is_empty()) { + None => None, + Some(s) => { + let key = parse(s).ok_or_else(|| format!("not a shortcut: {s}"))?; + // store the canonical spelling so equal keys compare equal (menus, native accelerators) + let canonical = format(key.0, key.1).unwrap_or_else(|| s.to_string()); + if RESERVED.iter().any(|r| parse(r) == Some(key)) { + return Err(format!("{canonical} is reserved for the app menu")); + } + Some(canonical) + } + }; + let lost = sc.as_deref().map(|s| conflicts(keymap, id, s)).unwrap_or_default(); + for other in &lost { + set(keymap, other, None); + } + set(keymap, b.id, sc.as_deref()); + Ok(lost) +} + +/// Store `sc` for `id`, dropping the entry when it equals the declared shortcut. +fn set(keymap: &mut Keymap, id: &str, sc: Option<&str>) { + let default = find_bindable(id).and_then(|b| b.default); + let same = match (sc, default) { + (None, None) => true, + (Some(a), Some(b)) => parse(a) == parse(b), + _ => false, + }; + if same { + keymap.remove(id); + } else { + keymap.insert(id.to_string(), sc.unwrap_or_default().to_string()); + } +} + +/// Restore the declared shortcut of `id`, taking it away from a command the user gave it to. +pub fn reset(keymap: &mut Keymap, id: &str) -> Result, String> { + let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?; + assign(keymap, id, b.default) +} + +/// `app.setShortcut {id, shortcut?, reset?}`: `shortcut` null or `""` removes it. +pub fn set_shortcut(app: &mut LightcraftApp, p: &Value) -> Result { + let id = p.get("id").and_then(Value::as_str).ok_or("missing id")?; + let keymap = &mut app.ui.settings.keymap; + let lost = if p.get("reset").and_then(Value::as_bool).unwrap_or(false) { + reset(keymap, id)? + } else { + match p.get("shortcut") { + None => return Err("missing shortcut (null removes it)".into()), + Some(Value::Null) => assign(keymap, id, None)?, + Some(Value::String(s)) => assign(keymap, id, Some(s))?, + Some(_) => return Err("shortcut must be a string or null".into()), + } + }; + Ok(json!({"id": id, "shortcut": shortcut_of(&app.ui.settings.keymap, id), "removedFrom": lost})) +} + +/// ⌘ / ⇧ / ⌥ / ⌃ themselves: they modify a shortcut's key, they can't be it. +pub fn is_modifier(k: Key) -> bool { + matches!( + k, + Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight + ) +} + +/// The shortcut text for a key press (`Cmd+Shift+K`), or `None` for keys a shortcut can't name. +/// `Cmd` is ⌘ on macOS and Ctrl elsewhere; `Ctrl` is the macOS Control key. +pub fn format(m: Modifiers, k: Key) -> Option { + if is_modifier(k) { + return None; + } + let key = match k { + Key::Backspace | Key::Delete => "Delete", + Key::Slash => "/", + Key::Backslash => "\\", + Key::Equals => "=", + Key::Minus => "-", + Key::OpenBracket => "[", + Key::CloseBracket => "]", + Key::Quote => "'", + Key::Comma => ",", + k => k.name(), + }; + let mut s = String::new(); + if m.command || m.mac_cmd { + s.push_str("Cmd+"); + } + // off macOS Ctrl *is* Cmd; on macOS it is the separate Control key + if m.ctrl && (m.mac_cmd || !m.command) { + s.push_str("Ctrl+"); + } + if m.alt { + s.push_str("Alt+"); + } + if m.shift { + s.push_str("Shift+"); + } + s.push_str(key); + let back = parse(&s)?; + let want = if k == Key::Delete { Key::Backspace } else { k }; + (back.1 == want).then_some(s) +} + /// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`. /// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our /// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts. @@ -44,21 +229,26 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { // "Delete" means the key labelled ⌫ (egui's Backspace); forward-delete also matches, see `matches`. "Delete" => key = Some(Key::Backspace), k => { - key = Key::from_name(k).or(match k { - "Right" => Some(Key::ArrowRight), - "Left" => Some(Key::ArrowLeft), - "Up" => Some(Key::ArrowUp), - "Down" => Some(Key::ArrowDown), - "\\" => Some(Key::Backslash), - "/" => Some(Key::Slash), - "=" => Some(Key::Equals), - "-" => Some(Key::Minus), - "[" => Some(Key::OpenBracket), - "]" => Some(Key::CloseBracket), - "'" => Some(Key::Quote), - "," => Some(Key::Comma), - _ => None, - }) + // an unknown part (`Hyper`, a typo) makes the whole shortcut invalid + key = Some( + Key::from_name(k) + .or(match k { + "Right" => Some(Key::ArrowRight), + "Left" => Some(Key::ArrowLeft), + "Up" => Some(Key::ArrowUp), + "Down" => Some(Key::ArrowDown), + "\\" => Some(Key::Backslash), + "/" => Some(Key::Slash), + "=" => Some(Key::Equals), + "-" => Some(Key::Minus), + "[" => Some(Key::OpenBracket), + "]" => Some(Key::CloseBracket), + "'" => Some(Key::Quote), + "," => Some(Key::Comma), + _ => None, + }) + .filter(|k| !is_modifier(*k))?, + ) } } } @@ -79,36 +269,33 @@ fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool { } pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { - // don't steal keys from text fields - if ctx.egui_wants_keyboard_input() { + if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) { + app.recording_shortcut = None; + } + // don't steal keys from text fields or from the keymap editor recording a shortcut + if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() { return; } let mut fire: Vec = Vec::new(); // shortcuts the native menu bar handles (it consumes those key presses itself) let native = |sc: &str| app.native_shortcuts.contains(sc); let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); + let keymap = &app.ui.settings.keymap; + // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them + let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); ctx.input(|i| { - let mut ui_keys = Vec::new(); - for (id, _, sc, _) in crate::menus::ui_commands() { - if let Some((m, k)) = sc.and_then(parse) { - ui_keys.push((m, k)); - if !native(sc.unwrap_or_default()) && matches(i, m, k) { - fire.push(id.to_string()); - } - } - } - for c in lightcraft_engine::command_specs() { - // A UI command bound to the same key wraps the engine command (e.g. `W` opens the - // White Balance Selector tool rather than sampling without a point): the UI one wins. - if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse) - && !ui_keys.contains(&(m, k)) + for b in bindable() { + if let Some(sc) = binding(keymap, b.id, b.default) + && let Some((m, k)) = parse(sc) + && !native(sc) && matches(i, m, k) { - fire.push(c.id.to_string()); + fire.push(b.id.to_string()); } } for (sc, id, params) in ALIASES { if let Some((m, k)) = parse(sc).filter(|_| !native(sc)) + && !taken.contains(&(m, k)) && matches(i, m, k) { aliased.push((id, serde_json::from_str(params).unwrap_or_default())); @@ -116,15 +303,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } // rating 0-5, colour labels 6-9 (with Shift: and advance) for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { - if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { fire.push(format!("rate:{n}:0")); } - if matches(i, Modifiers::SHIFT, *key) { + if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { fire.push(format!("rate:{n}:1")); } } for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { - if matches(i, Modifiers::NONE, key) && !native(sc) { + if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { fire.push(format!("label:{label}")); } } @@ -316,8 +503,73 @@ mod tests { } } - /// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. - const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; + #[test] + fn key_presses_format_to_shortcuts_that_parse_back() { + for k in Key::ALL { + for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] { + if let Some(sc) = format(m, *k) { + let want = if *k == Key::Delete { Key::Backspace } else { *k }; + assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}"); + } + } + } + assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K")); + assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/")); + assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete")); + } + + #[test] + fn assigning_a_key_moves_it_and_reset_restores_it() { + let mut keymap = Keymap::new(); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + // D belongs to Detail: Survey takes it, Detail loses it + let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap(); + assert_eq!(lost, vec!["view.detail"]); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D")); + assert_eq!(shortcut_of(&keymap, "view.detail"), None); + // restoring Detail takes D back + let lost = reset(&mut keymap, "view.detail").unwrap(); + assert_eq!(lost, vec!["view.survey"]); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + reset(&mut keymap, "view.survey").unwrap(); + assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}"); + // a command without a declared shortcut can get one, and lose it again + assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err(); + assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1")); + assign(&mut keymap, "view.photoGrid", None).unwrap(); + assert!(keymap.is_empty()); + } + + #[test] + fn bad_assignments_are_errors() { + let mut keymap = Keymap::new(); + assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err()); + assert!(keymap.is_empty()); + } + + /// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic. + #[test] + fn junk_keymap_entries_mean_no_shortcut() { + let mut keymap = Keymap::new(); + keymap.insert("view.survey".into(), "Hyper+☃".into()); + keymap.insert("no.suchCommand".into(), "K".into()); + keymap.insert("view.detail".into(), "Cmd+SuperLeft".into()); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey")); + assert_eq!(parse("Hyper+K"), None); + } + + /// Contextual partners keep sharing their key when one is reassigned. + #[test] + fn contextual_partners_are_not_conflicts() { + let keymap = Keymap::new(); + assert!(conflicts(&keymap, "photo.reject", "X").is_empty()); + } /// No key fires two different actions (a UI command may shadow the engine command it wraps). #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9d4df859..27d357ea 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -152,6 +152,8 @@ pub struct AppSettings { pub film_badges: bool, /// Grid: when to show the rating / flag / edited badges. pub grid_badges: GridBadges, + /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. + pub keymap: crate::shortcuts::Keymap, } impl Default for AppSettings { @@ -167,6 +169,7 @@ impl Default for AppSettings { film_names: true, film_badges: true, grid_badges: GridBadges::Auto, + keymap: Default::default(), } } } diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs new file mode 100644 index 00000000..a0a1e1d0 --- /dev/null +++ b/crates/ui-egui/src/tests_keymap.rs @@ -0,0 +1,148 @@ +//! Headless tests of the editable keymap (Help ▸ Keyboard Shortcuts): a changed shortcut fires +//! its command and not the old one's, the editor records a key press without running it, Esc +//! cancels recording without closing the dialog, and the keymap survives a save/load of `ui.json`. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{Dialog, ViewMode}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn key(h: &mut Headless, k: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": k, "shift": shift}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +#[test] +fn rebound_shortcut_fires_and_the_old_key_does_not() { + let mut h = demo(); + let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+K"})).unwrap(); + assert_eq!(r["shortcut"], "Shift+K", "{r}"); + // the old key (N) no longer opens Survey… + key(&mut h, "N", false); + assert_eq!(h.app.ui.view, ViewMode::Detail); + // …the new one does, and menus show it + key(&mut h, "K", true); + assert_eq!(h.app.ui.view, ViewMode::Survey); + let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "view.survey").unwrap(); + assert_eq!(entry.shortcut.as_deref(), Some("Shift+K")); + // the keymap is saved with the UI state + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap(); + assert_eq!(back.settings.keymap.get("view.survey").map(String::as_str), Some("Shift+K")); +} + +#[test] +fn the_editor_records_a_key_and_esc_cancels_without_closing() { + let mut h = demo(); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + // the list is longer than the dialog: narrow it so the row is on screen + click(&mut h, "field:shortcutsSearch"); + assert_eq!(h.request("ui.text", json!({"text": "compare"}), T)["ok"], true); + h.step(); + h.step(); + // record Shift+J for Compare: the key press is taken, not run + click(&mut h, "button:shortcut-view.compare"); + assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.compare")); + key(&mut h, "J", true); + assert_eq!(h.app.recording_shortcut, None); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J")); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + // Esc while recording cancels; the dialog stays open and nothing changes + click(&mut h, "button:shortcut-view.compare"); + key(&mut h, "Escape", false); + assert_eq!(h.app.recording_shortcut, None); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J")); + // the next Esc closes it; the new key works + key(&mut h, "Escape", false); + assert!(h.app.ui.dialog.is_none()); + key(&mut h, "J", true); + assert_eq!(h.app.ui.view, ViewMode::Compare); + // Reset All restores the declared keys + h.app.run("app.resetShortcuts", json!({})).unwrap(); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+C")); +} + +#[test] +fn a_key_given_to_a_command_overrides_a_fixed_key() { + let mut h = demo(); + let before = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating); + // 3 (rate ★★★) now opens Survey instead + h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "3"})).unwrap(); + key(&mut h, "3", false); + assert_eq!(h.app.ui.view, ViewMode::Survey); + let after = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating); + assert_eq!(before, after, "the rating key must not fire too"); +} + +/// Open the editor, filtered to `search`, and start recording for `id`. +fn record(h: &mut Headless, search: &str, id: &str) { + if h.app.ui.dialog.is_none() { + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + click(h, "field:shortcutsSearch"); + assert_eq!(h.request("ui.text", json!({"text": search}), T)["ok"], true); + h.step(); + h.step(); + } + click(h, &format!("button:shortcut-{id}")); + assert_eq!(h.app.recording_shortcut.as_deref(), Some(id)); +} + +/// Pressing ⌘ (a key event of its own) before S used to record `Cmd+SuperLeft`, so no ⌘ shortcut +/// could be set; ⌘C/⌘X/⌘V arrive as copy/cut/paste events, not keys, and were ignored. +#[test] +fn modifiers_wait_for_their_key_and_clipboard_keys_record() { + let mut h = demo(); + record(&mut h, "survey", "view.survey"); + let r = h.request("ui.key", json!({"key": "SuperLeft", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.survey"), "still waiting after ⌘ alone"); + let r = h.request("ui.key", json!({"key": "K", "cmd": true, "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.recording_shortcut, None); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Shift+K")); + // ⌘⌥C: the windowing layer sends a Copy event with the modifiers held + record(&mut h, "survey", "view.survey"); + let m = egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND }; + h.app.synthetic.push(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: m }); + h.app.synthetic.push(egui::Event::Copy); + h.step(); + h.step(); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Alt+C")); + // a saved modifier-only shortcut (from the bug) means no shortcut + assert_eq!(crate::shortcuts::parse("Cmd+SuperLeft"), None); +} diff --git a/docs/parity.md b/docs/parity.md index d5a31ea9..841720f7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | +| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | -| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 390 | 34 | 86 | 36 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 510 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160. ## Top gaps @@ -470,7 +470,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | -| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | +| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | | LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | | | LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | | | LR-PREF-DEVICE | Device settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index ea0243f4..bad18f5f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## October 2026 +### Keyboard shortcuts +- Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and + press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that + belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`. + ### RAW decoding - 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. From 43aa795539183514ed458c15d21bab18f2b8f3c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:24:13 +0000 Subject: [PATCH 18/25] Follow-up to #373: translated keymap editor, contextual keys noted - The keymap editor's strings, the fixed-key labels and the two new command labels in the ja, pt-br, zh-hans, zh-hant, de and ru catalogs; 'Restore {}' and 'Removed from {}' go through tr_format!. - The Presets and Soft Proofing rows say what their key does in the Library grids (Flag as Pick and advance / expand or collapse the stack), which main's static Shortcuts list showed as a separate label. - The macOS menu publishes no shortcuts while a key is being recorded (merged with main's owned_by_menu). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- crates/ui-egui/locales/de-formats.json | 2 ++ crates/ui-egui/locales/de.json | 30 +++++++++++++++++++++ crates/ui-egui/locales/ja-formats.json | 2 ++ crates/ui-egui/locales/ja.json | 30 +++++++++++++++++++++ crates/ui-egui/locales/pt-br-formats.json | 2 ++ crates/ui-egui/locales/pt-br.json | 30 +++++++++++++++++++++ crates/ui-egui/locales/ru-formats.json | 2 ++ crates/ui-egui/locales/ru.json | 30 +++++++++++++++++++++ crates/ui-egui/locales/zh-hans-formats.json | 2 ++ crates/ui-egui/locales/zh-hans.json | 30 +++++++++++++++++++++ crates/ui-egui/locales/zh-hant-formats.json | 2 ++ crates/ui-egui/locales/zh-hant.json | 30 +++++++++++++++++++++ crates/ui-egui/src/panels/keymap.rs | 12 ++++++--- 13 files changed, 201 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index adc0343d..a72e9d79 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -91,6 +91,7 @@ "Redo {}": "Wiederholen: {}", "Remove “{name}” and its {count} photo{} from the library?": "„{name}“ mit {count} Foto{} aus der Bibliothek entfernen?", "Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen", + "Removed from {}": "Entfernt von {}", "Removed {what} from {name}": "{what} aus {name} entfernt", "Rename {n} Photos…": "{n} Fotos umbenennen…", "Rename “{name}”": "„{name}“ umbenennen", @@ -99,6 +100,7 @@ "Replace {} with:": "{} ersetzen durch:", "Reset {name}": "{name} zurücksetzen", "Reset {} Channel": "Kanal {} zurücksetzen", + "Restore {}": "{} wiederherstellen", "Restore {} Photo{}": "{} Foto{} wiederherstellen", "Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 84a033c2..41b6e079 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -165,6 +165,7 @@ "Blue Primary": "Primärfarbe Blau", "Blue Saturation": "Blau – Sättigung", "Blue channel": "Blaukanal", + "Blue label": "Blaue Markierung", "Blue/Yellow Fringe": "Blaue/gelbe Farbsäume", "Blurry photos below the score are flagged as rejects.": "Unscharfe Fotos unterhalb des Grenzwerts werden als abgelehnt markiert.", "Boundary Warp": "Randverkrümmung", @@ -229,8 +230,11 @@ "Clear Reference": "Referenz entfernen", "Clear all": "Alles löschen", "Clear form": "Formular leeren", + "Clear rating": "Bewertung entfernen", + "Clear rating and advance": "Bewertung entfernen und weiter", "Cleared History": "Verlauf gelöscht", "Click Zoom Ratio": "Zoomfaktor beim Klicken", + "Click a shortcut, then press the new keys (Esc cancels).": "Auf ein Tastenkürzel klicken, dann die neuen Tasten drücken (Esc bricht ab).", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klicke auf einen Platzhalter, um ihn einzufügen. Die Beispiele gelten für IMG_0042.CR3, aufgenommen am 14. Januar 2026 um 05:58:48.", "Click photos in the grid to toggle the keyword; Esc stops": "Klicke auf Fotos im Raster, um das Stichwort umzuschalten; Esc beendet den Vorgang", "Click the object to select it · ⌥-click leaves a part out": "Objekt zum Auswählen anklicken · ⌥-Klick lässt einen Teil aus", @@ -536,6 +540,7 @@ "Fit to width": "An Breite anpassen", "Fit visible": "Sichtbaren Bereich einpassen", "Five stars": "Fünf Sterne", + "Fixed keys": "Feste Tasten", "Flag": "Markierung", "Flag as Pick": "Als ausgewählt markieren", "Flag as Reject": "Als abgelehnt markieren", @@ -598,6 +603,7 @@ "Green Primary": "Primärfarbe Grün", "Green Saturation": "Grün – Sättigung", "Green channel": "Grünkanal", + "Green label": "Grüne Markierung", "Grid": "Raster", "Grid Info": "Rasterinformationen", "Gritty Street": "Raue Straße", @@ -678,6 +684,8 @@ "Imported Photos": "Importierte Fotos", "Importing": "Wird importiert", "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Beim Import wird die Quelle nach Fotos durchsucht; der Ordner wird nicht zu „Lokal“ hinzugefügt (verwende Lokal → Ordner durchsuchen…, um ohne Import in einem Ordner zu arbeiten).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "In Fotoraster und quadratischem Raster: als ausgewählt markieren und weiter", + "In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen", "In Recently Deleted": "In „Zuletzt gelöscht“", "In library": "In Bibliothek", "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", @@ -919,6 +927,7 @@ "No filter presets yet": "Noch keine Filtervorgaben", "No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).", "No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.", + "No matching commands": "Keine passenden Befehle", "No matching photos": "Keine passenden Fotos", "No metadata found": "Keine Metadaten gefunden", "No model credits were built into this copy.": "Diese Version enthält keine Modellangaben.", @@ -1046,6 +1055,7 @@ "Presets (Shift+P)": "Vorgaben (Umschalt+P)", "Presets and profiles": "Vorgaben und Profile", "Presets ▾": "Vorgaben ⌄", + "Press keys…": "Tasten drücken …", "Preview": "Vorschau", "Preview Build Progress": "Fortschritt der Vorschauerstellung", "Preview only": "Nur Vorschau", @@ -1090,6 +1100,16 @@ "Radius": "Radius", "Random": "Zufällig", "Range": "Bereich", + "Rate ★": "Mit ★ bewerten", + "Rate ★ and advance": "Mit ★ bewerten und weiter", + "Rate ★★": "Mit ★★ bewerten", + "Rate ★★ and advance": "Mit ★★ bewerten und weiter", + "Rate ★★★": "Mit ★★★ bewerten", + "Rate ★★★ and advance": "Mit ★★★ bewerten und weiter", + "Rate ★★★★": "Mit ★★★★ bewerten", + "Rate ★★★★ and advance": "Mit ★★★★ bewerten und weiter", + "Rate ★★★★★": "Mit ★★★★★ bewerten", + "Rate ★★★★★ and advance": "Mit ★★★★★ bewerten und weiter", "Rating": "Bewertung", "Rating cleared": "Bewertung entfernt", "Ratings & flags": "Bewertungen und Markierungen", @@ -1116,6 +1136,7 @@ "Red Primary": "Primärfarbe Rot", "Red Saturation": "Rot – Sättigung", "Red channel": "Rotkanal", + "Red label": "Rote Markierung", "Red/Cyan Fringe": "Rote/cyanfarbene Farbsäume", "Redo": "Wiederholen", "Reference": "Referenz", @@ -1156,6 +1177,7 @@ "Remove from Target Album": "Aus Zielalbum entfernen", "Remove from survey": "Aus Übersicht entfernen", "Remove location info": "Ortsinformationen entfernen", + "Remove shortcut": "Tastenkürzel entfernen", "Remove this face box (undo with Edit ▸ Undo)": "Diesen Gesichtsrahmen entfernen (rückgängig über Bearbeiten → Rückgängig)", "Remove this filter": "Diesen Filter entfernen", "Remove this group": "Diese Gruppe entfernen", @@ -1182,7 +1204,9 @@ "Rendering…": "Wird gerendert…", "Reorder Masks": "Masken umordnen", "Reset": "Zurücksetzen", + "Reset All": "Alle zurücksetzen", "Reset All Curves": "Alle Kurven zurücksetzen", + "Reset All Keyboard Shortcuts": "Alle Tastenkürzel zurücksetzen", "Reset Before to Import State": "Vorher auf Importzustand zurücksetzen", "Reset Control": "Regler zurücksetzen", "Reset Crop": "Zuschnitt zurücksetzen", @@ -1197,6 +1221,7 @@ "Resize to": "Größe ändern auf", "Resolution (ppi)": "Auflösung (ppi)", "Restore": "Wiederherstellen", + "Restore (no shortcut)": "Wiederherstellen (kein Tastenkürzel)", "Restore Library from Backup…": "Bibliothek aus Sicherung wiederherstellen…", "Restore Version": "Version wiederherstellen", "Restore keeps their edits; Import as new deletes the old record first.": "Wiederherstellen behält ihre Bearbeitungen; „Neu importieren“ löscht zuerst den alten Eintrag.", @@ -1251,6 +1276,7 @@ "Screen": "Bildschirm", "Search": "Suche", "Search Photos": "Fotos suchen", + "Search commands or keys": "Befehle oder Tasten suchen", "Search…": "Suchen…", "Seasons": "Jahreszeiten", "Second Window": "Zweites Fenster", @@ -1281,6 +1307,7 @@ "Set Develop Values": "Entwicklungswerte festlegen", "Set Flag": "Markierung festlegen", "Set Image Zoom and Pan": "Bildzoom und -ausschnitt festlegen", + "Set Keyboard Shortcut": "Tastenkürzel festlegen", "Set Mask Adjustments": "Maskenkorrekturen festlegen", "Set Point Curve": "Punktkurve festlegen", "Set Profile": "Profil festlegen", @@ -1331,6 +1358,7 @@ "Show Pins": "Pins anzeigen", "Show Source": "Quelle anzeigen", "Show Thumbnails": "Miniaturbilder anzeigen", + "Show commands without a shortcut": "Befehle ohne Tastenkürzel anzeigen", "Show destination gamut warning": "Zielfarbraumwarnung anzeigen", "Show display gamut warning": "Bildschirmfarbraumwarnung anzeigen", "Show file names": "Dateinamen anzeigen", @@ -1441,6 +1469,7 @@ "Text direction": "Textrichtung", "Texture": "Struktur", "That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.", + "That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden", "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.", "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek", "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält", @@ -1561,6 +1590,7 @@ "Yellow Hue": "Gelb – Farbton", "Yellow Luminance": "Gelb – Luminanz", "Yellow Saturation": "Gelb – Sättigung", + "Yellow label": "Gelbe Markierung", "Yes": "Ja", "You can close this: the download continues, and resumes if interrupted.": "Du kannst dieses Fenster schließen: Der Download läuft weiter und wird nach Unterbrechungen fortgesetzt.", "Your Name": "Dein Name", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 81e27cb0..c0c5505b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -53,10 +53,12 @@ "Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚", "Redo {}": "やり直す:{}", "Remove “{}” from Favorites": "「{}」をお気に入りから削除", + "Removed from {}": "{} から削除しました", "Rename {n} Photos…": "{n}枚の写真の名前を変更…", "Rename “{name}”": "「{name}」の名前を変更", "Rendering on the CPU: {why}": "CPUで描画:{why}", "Reset {} Channel": "{}チャンネルをリセット", + "Restore {}": "{} に戻す", "Restore {} Photo{}": "{}枚の写真を復元{:.0}", "Saved export preset “{}”": "書き出しプリセット「{}」を保存しました", "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index d3e39f27..1e8f11c2 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -149,6 +149,7 @@ "Blue Primary": "ブルー原色", "Blue Saturation": "ブルーの彩度", "Blue channel": "ブルーチャンネル", + "Blue label": "ブルーラベル", "Blue/Yellow Fringe": "青/黄のフリンジ", "Blurry photos below the score are flagged as rejects.": "基準値未満のぼけた写真に不採用フラグを付けます", "Boundary Warp": "境界の変形", @@ -209,8 +210,11 @@ "Clear Quick Collection": "クイックコレクションを空にする", "Clear Reference": "参照写真を解除", "Clear all": "すべて解除", + "Clear rating": "評価を解除", + "Clear rating and advance": "評価を解除して次へ", "Cleared History": "履歴を消去しました", "Click Zoom Ratio": "クリック時の拡大率", + "Click a shortcut, then press the new keys (Esc cancels).": "ショートカットをクリックして新しいキーを押します(Esc でキャンセル)。", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "タグをクリックすると挿入できます。例は2026年1月14日05:58:48撮影のIMG_0042.CR3です", "Click photos in the grid to toggle the keyword; Esc stops": "グリッドの写真をクリックしてキーワードを付け外し(Escで終了)", "Click the object to select it · ⌥-click leaves a part out": "オブジェクトをクリックして選択(⌥クリックで一部を除外)", @@ -466,6 +470,7 @@ "First": "最初", "First commit": "最初のコミット", "Five stars": "星5つ", + "Fixed keys": "固定キー", "Flagged as Pick": "採用フラグ付き", "Flagged as Reject": "不採用フラグ付き", "Focal Length": "焦点距離", @@ -483,6 +488,7 @@ "Golden Glow": "ゴールデングロー", "Golden Hour": "ゴールデンアワー", "Grab bag": "名前の一覧", + "Green label": "グリーンラベル", "Gritty Street": "ざらついたストリート", "Group by Date: Automatic": "日付でグループ化:自動", "Group name": "グループ名", @@ -505,6 +511,8 @@ "Import Profiles": "プロファイルを読み込む", "Import as new": "新規として読み込む", "Imported": "読み込み済み", + "In Photo Grid and Square Grid: Flag as Pick and advance": "写真グリッドと正方形グリッドでは:採用フラグを付けて次へ", + "In Photo Grid and Square Grid: expand or collapse the stack": "写真グリッドと正方形グリッドでは:スタックを展開または折りたたむ", "Increase Brush Feather": "ブラシのぼかしを大きく", "Increase Brush Size": "ブラシのサイズを大きく", "Info Overlay Off": "情報オーバーレイなし", @@ -588,6 +596,7 @@ "Next Photo": "次の写真", "Night Blue": "ナイトブルー", "No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません", + "No matching commands": "一致するコマンドはありません", "No model credits were built into this copy.": "このビルドにはモデルのクレジットが含まれていません", "No named people in these photos": "これらの写真に名前付きの人物はいません", "No named people yet": "名前付きの人物はまだいません", @@ -617,6 +626,7 @@ "Pictures": "ピクチャ", "Prepare AI Masks": "AIマスクを準備", "Presets and profiles": "プリセットとプロファイル", + "Press keys…": "キーを押してください…", "Preview Build Progress": "プレビュー作成の進行状況", "Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中", "Previous Photo": "前の写真", @@ -630,12 +640,23 @@ "RAW decoding": "RAWデコード", "Radial": "円形", "Random": "ランダム", + "Rate ★": "評価 ★", + "Rate ★ and advance": "評価 ★ にして次へ", + "Rate ★★": "評価 ★★", + "Rate ★★ and advance": "評価 ★★ にして次へ", + "Rate ★★★": "評価 ★★★", + "Rate ★★★ and advance": "評価 ★★★ にして次へ", + "Rate ★★★★": "評価 ★★★★", + "Rate ★★★★ and advance": "評価 ★★★★ にして次へ", + "Rate ★★★★★": "評価 ★★★★★", + "Rate ★★★★★ and advance": "評価 ★★★★★ にして次へ", "Rating cleared": "評価を解除しました", "Raw": "RAW", "Read the SAM License": "SAMライセンスを読む", "Real name": "本名", "Recent": "最近使用", "Recent Keywords": "最近使用したキーワード", + "Red label": "レッドラベル", "Refine": "微調整", "Refine AI Mask Detail": "AIマスクの細部を微調整", "Refine Edges": "境界を微調整", @@ -650,6 +671,7 @@ "Remove Face Box": "顔の枠を除去", "Remove a filter above, or choose Clear all": "上のフィルターを解除するか、「すべて解除」を選択してください", "Remove from Target Album": "追加先アルバムから除去", + "Remove shortcut": "ショートカットを削除", "Remove this face box (undo with Edit ▸ Undo)": "この顔の枠を除去(「編集 ▸ 取り消す」で元に戻せます)", "Rename Component": "構成要素の名前を変更", "Rename Folder": "フォルダ名を変更", @@ -658,6 +680,8 @@ "Rename Preview": "名前変更のプレビュー", "Rename Template Tags": "名前変更テンプレートのタグ", "Reorder Masks": "マスクを並べ替え", + "Reset All": "すべてリセット", + "Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット", "Reshuffle": "シャッフルし直す", "File name": "ファイル名", "Fill Edges": "境界を補完", @@ -1088,6 +1112,7 @@ "Resize to": "サイズ変更", "Resolution (ppi)": "解像度(ppi)", "Restore": "復元", + "Restore (no shortcut)": "元に戻す(ショートカットなし)", "Restore Version": "バージョンを復元", "Restore keeps their edits; Import as new deletes the old record first.": "復元すると編集内容が残ります。新規として読み込むと、先に古い記録を削除します。", "Restore them": "復元する", @@ -1136,6 +1161,7 @@ "Screen": "画面用", "Search": "検索", "Search Photos": "写真を検索", + "Search commands or keys": "コマンドまたはキーを検索", "Seasons": "四季", "Second Window": "第2ウィンドウ", "Select": "選択", @@ -1165,6 +1191,7 @@ "Set Develop Values": "現像設定を変更", "Set Flag": "フラグを設定", "Set Image Zoom and Pan": "画像のズームとパンを設定", + "Set Keyboard Shortcut": "キーボードショートカットを設定", "Set Mask Adjustments": "マスクの調整を設定", "Set Point Curve": "ポイントカーブを変更", "Set Profile": "プロファイルを設定", @@ -1214,6 +1241,7 @@ "Show Pins": "ピンを表示", "Show Source": "コピー元を表示", "Show Thumbnails": "サムネイルを表示", + "Show commands without a shortcut": "ショートカットのないコマンドも表示", "Show destination gamut warning": "出力先の色域警告を表示", "Show display gamut warning": "ディスプレイの色域警告を表示", "Show file names": "ファイル名を表示", @@ -1311,6 +1339,7 @@ "Template": "テンプレート", "Text": "文字", "Texture": "テクスチャ", + "That key can't be used as a shortcut": "そのキーはショートカットに使えません", "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3モデルはインストール済みです。オブジェクトマスクと説明マスクを使用できます", "The download didn't work:": "ダウンロードに失敗しました:", "The number at the end of the original name (IMG_0042 → 0042)": "元の名前の末尾の番号(IMG_0042 → 0042)", @@ -1416,6 +1445,7 @@ "Yellow Hue": "イエローの色相", "Yellow Luminance": "イエローの輝度", "Yellow Saturation": "イエローの彩度", + "Yellow label": "イエローラベル", "Yes": "はい", "You can close this: the download continues, and resumes if interrupted.": "このウィンドウは閉じてもかまいません。ダウンロードは続行され、中断しても再開します", "Your Name": "あなたの名前", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 14ae2f65..73b609c1 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -67,11 +67,13 @@ "Redo {}": "Refazer {}", "Remove “{name}” and its {count} photo{} from the library?": "Remover “{name}” e {count} foto{} da biblioteca?", "Remove “{}” from Favorites": "Remover “{}” dos favoritos", + "Removed from {}": "Removido de {}", "Rename {n} Photos…": "Renomear {n} fotos…", "Rename “{name}”": "Renomear “{name}”", "Rendering on the CPU: {why}": "Renderizando na CPU: {why}", "Reset {name}": "Redefinir {name}", "Reset {} Channel": "Redefinir canal {}", + "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{:.0}", "Saved export preset “{}”": "Predefinição de exportação “{}” salva", "Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 6e7045bf..06df4290 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -153,6 +153,7 @@ "Blue Primary": "Azul primário", "Blue Saturation": "Saturação do azul", "Blue channel": "Canal azul", + "Blue label": "Rótulo azul", "Blue/Yellow Fringe": "Franja azul/amarela", "Blurry photos below the score are flagged as rejects.": "Fotos desfocadas abaixo da pontuação são marcadas como rejeitadas.", "Boundary Warp": "Deformação de borda", @@ -216,8 +217,11 @@ "Clear Reference": "Limpar referência", "Clear all": "Limpar tudo", "Clear form": "Limpar formulário", + "Clear rating": "Limpar avaliação", + "Clear rating and advance": "Limpar avaliação e avançar", "Cleared History": "Histórico limpo", "Click Zoom Ratio": "Proporção de zoom por clique", + "Click a shortcut, then press the new keys (Esc cancels).": "Clique em um atalho e pressione as novas teclas (Esc cancela).", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Clique em uma tag para inseri-la. Os exemplos são de IMG_0042.CR3 tirada em 14 de jan. de 2026, 05:58:48.", "Click photos in the grid to toggle the keyword; Esc stops": "Clique nas fotos da grade para alternar a palavra-chave; Esc encerra", "Click the object to select it · ⌥-click leaves a part out": "Clique no objeto para selecioná-lo · ⌥-clique deixa uma parte de fora", @@ -510,6 +514,7 @@ "Fit to width": "Ajustar à largura", "Fit visible": "Ajustar ao visível", "Five stars": "Cinco estrelas", + "Fixed keys": "Teclas fixas", "Flag": "Marcar", "Flag as Pick": "Marcar como seleção", "Flag as Reject": "Marcar como rejeitada", @@ -572,6 +577,7 @@ "Green Primary": "Primária verde", "Green Saturation": "Saturação do verde", "Green channel": "Canal verde", + "Green label": "Rótulo verde", "Grid": "Grade", "Grid Info": "Informações da grade", "Gritty Street": "Urbano cru", @@ -647,6 +653,8 @@ "Imported Photos": "Fotos importadas", "Importing": "Importando", "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "A importação examina a origem em busca de fotos; ela não adiciona a pasta a Local (use Local → Procurar pasta… para trabalhar em uma pasta sem importar).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "Na Grade de fotos e na Grade quadrada: marcar como seleção e avançar", + "In Photo Grid and Square Grid: expand or collapse the stack": "Na Grade de fotos e na Grade quadrada: expandir ou recolher a pilha", "In Recently Deleted": "Em Recentemente excluídos", "In library": "Na biblioteca", "In memory — nothing is saved": "Na memória — nada é salvo", @@ -882,6 +890,7 @@ "No filter presets yet": "Ainda não há predefinições de filtro", "No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).", "No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.", + "No matching commands": "Nenhum comando corresponde", "No matching photos": "Nenhuma foto correspondente", "No metadata found": "Nenhum metadado encontrado", "No model credits were built into this copy.": "Esta cópia não inclui créditos de modelos.", @@ -1006,6 +1015,7 @@ "Presets (Shift+P)": "Predefinições (Shift+P)", "Presets and profiles": "Predefinições e perfis", "Presets ▾": "Predefinições", + "Press keys…": "Pressione as teclas…", "Preview": "Pré-visualização", "Preview Build Progress": "Progresso da geração de pré-visualizações", "Preview only": "Somente pré-visualização", @@ -1050,6 +1060,16 @@ "Radius": "Raio", "Random": "Aleatória", "Range": "Faixa", + "Rate ★": "Avaliar ★", + "Rate ★ and advance": "Avaliar ★ e avançar", + "Rate ★★": "Avaliar ★★", + "Rate ★★ and advance": "Avaliar ★★ e avançar", + "Rate ★★★": "Avaliar ★★★", + "Rate ★★★ and advance": "Avaliar ★★★ e avançar", + "Rate ★★★★": "Avaliar ★★★★", + "Rate ★★★★ and advance": "Avaliar ★★★★ e avançar", + "Rate ★★★★★": "Avaliar ★★★★★", + "Rate ★★★★★ and advance": "Avaliar ★★★★★ e avançar", "Rating": "Avaliação", "Rating cleared": "Avaliação removida", "Ratings & flags": "Avaliações e marcações", @@ -1076,6 +1096,7 @@ "Red Primary": "Primária vermelha", "Red Saturation": "Saturação do vermelho", "Red channel": "Canal vermelho", + "Red label": "Rótulo vermelho", "Red/Cyan Fringe": "Franja vermelha/ciano", "Redo": "Refazer", "Reference": "Referência", @@ -1116,6 +1137,7 @@ "Remove from Target Album": "Remover do álbum de destino", "Remove from survey": "Remover da inspeção", "Remove location info": "Remover informações de local", + "Remove shortcut": "Remover atalho", "Remove this face box (undo with Edit ▸ Undo)": "Remover esta caixa de rosto (desfaça com Editar ▸ Desfazer)", "Remove this filter": "Remover este filtro", "Remove this group": "Remover este grupo", @@ -1139,7 +1161,9 @@ "Rendering…": "Renderizando…", "Reorder Masks": "Reordenar máscaras", "Reset": "Redefinir", + "Reset All": "Redefinir tudo", "Reset All Curves": "Redefinir todas as curvas", + "Reset All Keyboard Shortcuts": "Redefinir todos os atalhos de teclado", "Reset Before to Import State": "Redefinir Antes para o estado de importação", "Reset Control": "Redefinir controle", "Reset Crop": "Redefinir recorte", @@ -1154,6 +1178,7 @@ "Resize to": "Redimensionar para", "Resolution (ppi)": "Resolução (ppi)", "Restore": "Restaurar", + "Restore (no shortcut)": "Restaurar (sem atalho)", "Restore Library from Backup…": "Restaurar biblioteca do backup…", "Restore Version": "Restaurar versão", "Restore keeps their edits; Import as new deletes the old record first.": "Restaurar mantém as edições; Importar como novas apaga antes o registro antigo.", @@ -1207,6 +1232,7 @@ "Screen": "Tela", "Search": "Pesquisar", "Search Photos": "Pesquisar fotos", + "Search commands or keys": "Pesquisar comandos ou teclas", "Search…": "Pesquisar…", "Seasons": "Estações", "Second Window": "Segunda janela", @@ -1237,6 +1263,7 @@ "Set Develop Values": "Definir valores de revelação", "Set Flag": "Definir marcação", "Set Image Zoom and Pan": "Definir zoom e posição da imagem", + "Set Keyboard Shortcut": "Definir atalho de teclado", "Set Mask Adjustments": "Definir ajustes da máscara", "Set Point Curve": "Definir curva de pontos", "Set Profile": "Definir perfil", @@ -1286,6 +1313,7 @@ "Show Pins": "Mostrar pinos", "Show Source": "Mostrar origem", "Show Thumbnails": "Mostrar miniaturas", + "Show commands without a shortcut": "Mostrar comandos sem atalho", "Show destination gamut warning": "Mostrar aviso de gamut de destino", "Show display gamut warning": "Mostrar aviso de gamut da tela", "Show file names": "Mostrar nomes dos arquivos", @@ -1390,6 +1418,7 @@ "Text direction": "Direção do texto", "Texture": "Textura", "That includes the photos in the folders inside it.": "Isso inclui as fotos das pastas dentro dela.", + "That key can't be used as a shortcut": "Essa tecla não pode ser usada como atalho", "The SAM 3 model is installed: Object and Describe masks are ready.": "O modelo SAM 3 está instalado: as máscaras Objeto e Descrever estão prontas.", "The download didn't work:": "O download não funcionou:", "The number at the end of the original name (IMG_0042 → 0042)": "O número no fim do nome original (IMG_0042 → 0042)", @@ -1505,6 +1534,7 @@ "Yellow Hue": "Matiz do amarelo", "Yellow Luminance": "Luminância do amarelo", "Yellow Saturation": "Saturação do amarelo", + "Yellow label": "Rótulo amarelo", "Yes": "Sim", "You can close this: the download continues, and resumes if interrupted.": "Você pode fechar isto: o download continua e retoma se for interrompido.", "Your Name": "Seu nome", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index b74a1ac8..fa1294a8 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -96,6 +96,7 @@ "Redo {}": "Повторить {}", "Remove “{name}” and its {count} photo{} from the library?": "Удалить «{name}» и фото ({count}){:.0} из библиотеки?", "Remove “{}” from Favorites": "Убрать «{}» из избранного", + "Removed from {}": "Удалено из: {}", "Removed {what} from {name}": "{what}: удалено из {name}", "Rename {n} Photos…": "Переименовать фото: {n}…", "Rename “{name}”": "Переименовать «{name}»", @@ -104,6 +105,7 @@ "Replace {} with:": "Заменить {} на:", "Reset {name}": "Сбросить: {name}", "Reset {} Channel": "Сбросить канал: {}", + "Restore {}": "Восстановить {}", "Restore {} Photo{}": "Восстановить фото: {}{:.0}", "Saved export preset “{}”": "Пресет экспорта «{}» сохранён", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 6cb076db..0a0c9bad 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -124,6 +124,7 @@ "Blue Primary": "Основной синий", "Blue Saturation": "Насыщенность синего", "Blue channel": "Синий канал", + "Blue label": "Синяя метка", "Blue/Yellow Fringe": "Сине-жёлтая кайма", "Blurry photos below the score are flagged as rejects.": "Размытые фото с оценкой ниже порога помечаются как отклонённые.", "Boundary Warp": "Деформация границ", @@ -175,8 +176,11 @@ "Clear Reference": "Убрать эталон", "Clear all": "Очистить всё", "Clear form": "Очистить форму", + "Clear rating": "Снять оценку", + "Clear rating and advance": "Снять оценку и перейти дальше", "Cleared History": "История очищена", "Click Zoom Ratio": "Масштаб по щелчку", + "Click a shortcut, then press the new keys (Esc cancels).": "Щёлкните сочетание и нажмите новые клавиши (Esc — отмена).", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Щёлкните по тегу, чтобы вставить его. Примеры приведены для IMG_0042.CR3, снятого 14 января 2026 в 05:58:48.", "Click photos in the grid to toggle the keyword; Esc stops": "Щёлкайте по фото в сетке, чтобы переключать ключевое слово; Esc — остановить", "Click the photo to pick a colour · ⇧-click adds more": "Щёлкните по фото, чтобы выбрать цвет · ⇧+щелчок добавляет ещё", @@ -415,6 +419,7 @@ "Fit on Screen": "Вписать в экран", "Fit to width": "По ширине", "Fit visible": "Вписать видимое", + "Fixed keys": "Фиксированные клавиши", "Flag": "Флаг", "Flag as Pick": "Пометить как выбранное", "Flag as Reject": "Пометить как отклонённое", @@ -468,6 +473,7 @@ "Green Primary": "Основной зелёный", "Green Saturation": "Насыщенность зелёного", "Green channel": "Зелёный канал", + "Green label": "Зелёная метка", "Grid": "Сетка", "Grid Info": "Информация сетки", "Gritty Street": "Жёсткая улица", @@ -529,6 +535,8 @@ "Imported Photos": "Импортированные фото", "Importing": "Импорт", "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "При импорте источник сканируется на фото; папка не добавляется в «Локальное» (чтобы работать с папкой без импорта, используйте Локальное → Выбрать папку…).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "В сетке фото и квадратной сетке: пометить как выбранное и перейти дальше", + "In Photo Grid and Square Grid: expand or collapse the stack": "В сетке фото и квадратной сетке: развернуть или свернуть стопку", "In Recently Deleted": "В «Недавно удалённых»", "In library": "В библиотеке", "In memory — nothing is saved": "В памяти — ничего не сохраняется", @@ -724,6 +732,7 @@ "No filter presets yet": "Пресетов фильтров пока нет", "No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).", "No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.", + "No matching commands": "Нет подходящих команд", "No matching photos": "Нет подходящих фото", "No metadata found": "Метаданные не найдены", "No new presets": "Новых пресетов нет", @@ -826,6 +835,7 @@ "Presets & Profiles": "Пресеты и профили", "Presets (Shift+P)": "Пресеты (Shift+P)", "Presets ▾": "Пресеты ⌄", + "Press keys…": "Нажмите клавиши…", "Preview": "Просмотр", "Preview Build Progress": "Ход создания просмотров", "Preview only": "Только просмотр", @@ -864,6 +874,16 @@ "Radius": "Радиус", "Random": "Случайно", "Range": "Диапазон", + "Rate ★": "Оценка ★", + "Rate ★ and advance": "Оценка ★ и перейти дальше", + "Rate ★★": "Оценка ★★", + "Rate ★★ and advance": "Оценка ★★ и перейти дальше", + "Rate ★★★": "Оценка ★★★", + "Rate ★★★ and advance": "Оценка ★★★ и перейти дальше", + "Rate ★★★★": "Оценка ★★★★", + "Rate ★★★★ and advance": "Оценка ★★★★ и перейти дальше", + "Rate ★★★★★": "Оценка ★★★★★", + "Rate ★★★★★ and advance": "Оценка ★★★★★ и перейти дальше", "Rating": "Оценка", "Ratings & flags": "Оценки и флаги", "Raw default": "Стандарт RAW", @@ -885,6 +905,7 @@ "Red Primary": "Основной красный", "Red Saturation": "Насыщенность красного", "Red channel": "Красный канал", + "Red label": "Красная метка", "Red/Cyan Fringe": "Красно-голубая кайма", "Redo": "Повторить", "Reference": "Эталон", @@ -910,6 +931,7 @@ "Remove from Stack": "Убрать из стопки", "Remove from survey": "Убрать из обзора", "Remove location info": "Удалить сведения о местоположении", + "Remove shortcut": "Удалить сочетание", "Remove this filter": "Убрать этот фильтр", "Remove this group": "Убрать эту группу", "Remove this rule": "Убрать это условие", @@ -930,7 +952,9 @@ "Rendering": "Рендеринг", "Rendering…": "Рендеринг…", "Reset": "Сбросить", + "Reset All": "Сбросить всё", "Reset All Curves": "Сбросить все кривые", + "Reset All Keyboard Shortcuts": "Сбросить все сочетания клавиш", "Reset Before to Import State": "Сбросить «до» к состоянию при импорте", "Reset Control": "Сбросить элемент управления", "Reset Crop": "Сбросить кадрирование", @@ -944,6 +968,7 @@ "Resize to": "Изменить размер до", "Resolution (ppi)": "Разрешение (ppi)", "Restore": "Восстановить", + "Restore (no shortcut)": "Восстановить (без сочетания)", "Restore Library from Backup…": "Восстановить библиотеку из резервной копии…", "Restore Version": "Восстановить версию", "Retouch": "Ретушь", @@ -988,6 +1013,7 @@ "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.", "Screen": "Экран", "Search Photos": "Поиск фото", + "Search commands or keys": "Поиск команд или клавиш", "Search…": "Поиск…", "Seasons": "Времена года", "Second Window": "Второе окно", @@ -1012,6 +1038,7 @@ "Set Crop": "Задать кадрирование", "Set Develop Values": "Задать значения коррекции", "Set Flag": "Задать флаг", + "Set Keyboard Shortcut": "Задать сочетание клавиш", "Set Mask Adjustments": "Задать настройки маски", "Set Point Curve": "Задать точечную кривую", "Set Profile": "Задать профиль", @@ -1053,6 +1080,7 @@ "Show Pins": "Показать булавки", "Show Source": "Показать источник", "Show Thumbnails": "Показывать миниатюры", + "Show commands without a shortcut": "Показывать команды без сочетаний", "Show destination gamut warning": "Показывать предупреждение о выходе за гамму устройства вывода", "Show display gamut warning": "Показывать предупреждение о выходе за гамму дисплея", "Show file names": "Показывать имена файлов", @@ -1144,6 +1172,7 @@ "Text": "Текст", "Text direction": "Направление текста", "Texture": "Текстура", + "That key can't be used as a shortcut": "Эту клавишу нельзя использовать в сочетании", "The SAM 3 model is installed: Object and Describe masks are ready.": "Модель SAM 3 установлена: маски «Объект» и «Описать» готовы.", "The download didn't work:": "Загрузка не удалась:", "The original files, unchanged, each with an XMP sidecar holding its edits.": "Исходные файлы без изменений, у каждого — XMP-файл с его правками.", @@ -1243,6 +1272,7 @@ "Yellow Hue": "Тон жёлтого", "Yellow Luminance": "Яркость жёлтого", "Yellow Saturation": "Насыщенность жёлтого", + "Yellow label": "Жёлтая метка", "Yes": "Да", "You can close this: the download continues, and resumes if interrupted.": "Это окно можно закрыть: загрузка продолжится и возобновится после обрыва.", "Your library couldn't be opened": "Не удалось открыть вашу библиотеку", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index d342e30f..cbd77d12 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -53,10 +53,12 @@ "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", "Redo {}": "重做:{0}", "Remove “{}” from Favorites": "从收藏夹中移除“{0}”", + "Removed from {}": "已从 {} 移除", "Rename {n} Photos…": "重命名 {n} 张照片…", "Rename “{name}”": "重命名“{name}”", "Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}", "Reset {} Channel": "重置 {0} 通道", + "Restore {}": "恢复为 {}", "Restore {} Photo{}": "恢复 {0} 张照片{1:.0}", "Saved export preset “{}”": "已保存导出预设“{0}”", "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index c42e2abd..7e286b0c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -152,6 +152,7 @@ "Blue Primary": "蓝色原色", "Blue Saturation": "蓝色饱和度", "Blue channel": "蓝色通道", + "Blue label": "蓝色标签", "Blue/Yellow Fringe": "蓝/黄边缘", "Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。", "Boundary Warp": "边界变形", @@ -215,8 +216,11 @@ "Clear Reference": "清除参考照片", "Clear all": "全部清除", "Clear form": "清除表单", + "Clear rating": "清除星级", + "Clear rating and advance": "清除星级并前进", "Cleared History": "已清除历史记录", "Click Zoom Ratio": "单击缩放比例", + "Click a shortcut, then press the new keys (Esc cancels).": "点击快捷键,然后按下新按键(Esc 取消)。", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。", "Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束", "Click the object to select it · ⌥-click leaves a part out": "单击对象将其选中 · 按住 ⌥ 单击可排除某一部分", @@ -486,6 +490,7 @@ "First": "最早", "First commit": "首次提交", "Five stars": "五星", + "Fixed keys": "固定按键", "Flagged as Pick": "已标记为留用", "Flagged as Reject": "已标记为排除", "Focal Length": "焦距", @@ -495,6 +500,7 @@ "GPU rendering": "GPU 渲染", "Generated demo photo": "生成的演示照片", "Grab bag": "名单", + "Green label": "绿色标签", "Group name": "分组名称", "HDR Merge Preview": "HDR 合并预览", "HDR Panorama Merge Preview": "HDR 全景合并预览", @@ -509,6 +515,8 @@ "ISO speed": "ISO 感光度", "Import as new": "作为新照片导入", "Imported": "已导入", + "In Photo Grid and Square Grid: Flag as Pick and advance": "在照片网格和正方形网格中:标记为留用并前进", + "In Photo Grid and Square Grid: expand or collapse the stack": "在照片网格和正方形网格中:展开或折叠堆叠", "Info Overlay Off": "关闭信息叠加", "Info Overlay: Exposure": "信息叠加:曝光", "Info Overlay: File": "信息叠加:文件", @@ -550,6 +558,7 @@ "Nested group": "嵌套组", "New time": "新时间", "No contributor data was built into this copy.": "此版本未内置贡献者数据。", + "No matching commands": "没有匹配的命令", "No model credits were built into this copy.": "此版本未内置模型致谢信息。", "No named people in these photos": "这些照片中没有已命名的人物", "No named people yet": "还没有已命名的人物", @@ -572,6 +581,7 @@ "Pictures": "图片", "Prepare AI Masks": "准备 AI 蒙版", "Presets and profiles": "预设和配置文件", + "Press keys…": "请按键…", "Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG", "Proof copy created": "已创建校样副本", "Quit Anyway": "仍然退出", @@ -579,12 +589,23 @@ "RAW decoding": "RAW 解码", "Radial": "径向", "Random": "随机", + "Rate ★": "评为 ★", + "Rate ★ and advance": "评为 ★ 并前进", + "Rate ★★": "评为 ★★", + "Rate ★★ and advance": "评为 ★★ 并前进", + "Rate ★★★": "评为 ★★★", + "Rate ★★★ and advance": "评为 ★★★ 并前进", + "Rate ★★★★": "评为 ★★★★", + "Rate ★★★★ and advance": "评为 ★★★★ 并前进", + "Rate ★★★★★": "评为 ★★★★★", + "Rate ★★★★★ and advance": "评为 ★★★★★ 并前进", "Rating cleared": "已清除星级", "Raw": "RAW", "Read the SAM License": "阅读 SAM 许可证", "Real name": "真实姓名", "Recent": "最近使用", "Recent Keywords": "最近使用的关键字", + "Red label": "红色标签", "Refine": "细化", "Refine AI Mask Detail": "细化 AI 蒙版细节", "Refine Edges": "细化边缘", @@ -596,9 +617,12 @@ "Remove Face Box": "移除人脸框", "Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”", "Remove from Target Album": "从目标相册中移除", + "Remove shortcut": "移除快捷键", "Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)", "Rename Component": "重命名组件", "Reorder Masks": "重新排序蒙版", + "Reset All": "全部重置", + "Reset All Keyboard Shortcuts": "重置所有键盘快捷键", "Reshuffle": "重新随机排序", "File name": "文件名", "Fill Edges": "填充边缘", @@ -1144,6 +1168,7 @@ "Resize to": "调整大小为", "Resolution (ppi)": "分辨率 (ppi)", "Restore": "恢复", + "Restore (no shortcut)": "恢复(无快捷键)", "Restore Library from Backup…": "从备份恢复图库…", "Restore Version": "恢复版本", "Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。", @@ -1197,6 +1222,7 @@ "Screen": "屏幕", "Search": "搜索", "Search Photos": "搜索照片", + "Search commands or keys": "搜索命令或按键", "Search…": "搜索…", "Seasons": "四季", "Second Window": "第二个窗口", @@ -1227,6 +1253,7 @@ "Set Develop Values": "设置编辑参数", "Set Flag": "设置旗标", "Set Image Zoom and Pan": "设置图像缩放和平移", + "Set Keyboard Shortcut": "设置键盘快捷键", "Set Mask Adjustments": "设置蒙版调整", "Set Point Curve": "设置点曲线", "Set Profile": "设置配置文件", @@ -1276,6 +1303,7 @@ "Show Pins": "显示图钉", "Show Source": "显示来源", "Show Thumbnails": "显示缩略图", + "Show commands without a shortcut": "显示没有快捷键的命令", "Show destination gamut warning": "显示目标色域警告", "Show display gamut warning": "显示显示器色域警告", "Show file names": "显示文件名", @@ -1379,6 +1407,7 @@ "Text": "文字", "Text direction": "文字方向", "Texture": "纹理", + "That key can't be used as a shortcut": "该按键不能用作快捷键", "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安装:对象蒙版和描述蒙版可以使用了。", "The download didn't work:": "下载失败:", "The number at the end of the original name (IMG_0042 → 0042)": "原始名称末尾的数字(IMG_0042 → 0042)", @@ -1492,6 +1521,7 @@ "Yellow Hue": "黄色色相", "Yellow Luminance": "黄色明度", "Yellow Saturation": "黄色饱和度", + "Yellow label": "黄色标签", "Yes": "是", "You can close this: the download continues, and resumes if interrupted.": "可以关闭此窗口:下载会继续进行,中断后也会继续。", "Your Name": "你的名字", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 674a1810..a2f95cb1 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -63,11 +63,13 @@ "Reading photos… {} of {}": "正在讀取照片… {}/{}", "Redo {}": "重做 {}", "Remove “{}” from Favorites": "將「{}」從喜好項目移除", + "Removed from {}": "已從 {} 移除", "Rename {n} Photos…": "重新命名 {n} 張照片…", "Rename “{name}”": "重新命名「{name}」", "Rendering on the CPU: {why}": "使用 CPU 算繪:{why}", "Reset {name}": "重設{name}", "Reset {} Channel": "重設 {} 通道", + "Restore {}": "還原為 {}", "Restore {} Photo{}": "還原 {} 張照片{:.0}", "Saved export preset “{}”": "已儲存匯出預設集「{}」", "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index c097f4f6..a1c8c5f6 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -152,6 +152,7 @@ "Blue Primary": "藍色原色", "Blue Saturation": "藍色飽和度", "Blue channel": "藍色通道", + "Blue label": "藍色標籤", "Blue/Yellow Fringe": "藍/黃色邊緣", "Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。", "Boundary Warp": "邊界變形", @@ -215,8 +216,11 @@ "Clear Reference": "清除參考照片", "Clear all": "全部清除", "Clear form": "清除表單", + "Clear rating": "清除評分", + "Clear rating and advance": "清除評分並前進", "Cleared History": "已清除歷史紀錄", "Click Zoom Ratio": "點擊縮放比例", + "Click a shortcut, then press the new keys (Esc cancels).": "按一下快速鍵,然後按下新的按鍵(Esc 取消)。", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。", "Click photos in the grid to toggle the keyword; Esc stops": "點擊格狀檢視中的照片以加入或移除關鍵字;按 Esc 結束", "Click the object to select it · ⌥-click leaves a part out": "點擊物件即可選取 · 按住 ⌥ 點擊可排除某個部分", @@ -486,6 +490,7 @@ "First": "最早", "First commit": "首次提交", "Five stars": "五顆星", + "Fixed keys": "固定按鍵", "Flagged as Pick": "已標記為精選", "Flagged as Reject": "已標記為排除", "Focal Length": "焦距", @@ -495,6 +500,7 @@ "GPU rendering": "GPU 算繪", "Generated demo photo": "產生的示範照片", "Grab bag": "名單", + "Green label": "綠色標籤", "Group name": "群組名稱", "HDR Merge Preview": "HDR 合併預覽", "HDR Panorama Merge Preview": "HDR 全景合併預覽", @@ -509,6 +515,8 @@ "ISO speed": "ISO 感光度", "Import as new": "以新照片匯入", "Imported": "已匯入", + "In Photo Grid and Square Grid: Flag as Pick and advance": "在照片格狀檢視與方格檢視中:標記為精選並前進", + "In Photo Grid and Square Grid: expand or collapse the stack": "在照片格狀檢視與方格檢視中:展開或收合堆疊", "Info Overlay Off": "關閉資訊疊加", "Info Overlay: Exposure": "資訊疊加:曝光", "Info Overlay: File": "資訊疊加:檔案", @@ -550,6 +558,7 @@ "Nested group": "巢狀群組", "New time": "新時間", "No contributor data was built into this copy.": "此版本未內建貢獻者資料。", + "No matching commands": "沒有符合的命令", "No model credits were built into this copy.": "此版本未內建模型致謝資訊。", "No named people in these photos": "這些照片中沒有已命名的人物", "No named people yet": "尚無已命名的人物", @@ -572,6 +581,7 @@ "Pictures": "圖片", "Prepare AI Masks": "準備 AI 遮色片", "Presets and profiles": "預設集與描述檔", + "Press keys…": "請按鍵…", "Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG", "Proof copy created": "已建立校樣副本", "Quit Anyway": "仍要結束", @@ -579,12 +589,23 @@ "RAW decoding": "RAW 解碼", "Radial": "放射狀", "Random": "隨機", + "Rate ★": "評為 ★", + "Rate ★ and advance": "評為 ★ 並前進", + "Rate ★★": "評為 ★★", + "Rate ★★ and advance": "評為 ★★ 並前進", + "Rate ★★★": "評為 ★★★", + "Rate ★★★ and advance": "評為 ★★★ 並前進", + "Rate ★★★★": "評為 ★★★★", + "Rate ★★★★ and advance": "評為 ★★★★ 並前進", + "Rate ★★★★★": "評為 ★★★★★", + "Rate ★★★★★ and advance": "評為 ★★★★★ 並前進", "Rating cleared": "已清除評分", "Raw": "RAW", "Read the SAM License": "閱讀 SAM 授權", "Real name": "真實姓名", "Recent": "最近使用", "Recent Keywords": "最近使用的關鍵字", + "Red label": "紅色標籤", "Refine": "調整", "Refine AI Mask Detail": "調整 AI 遮色片細節", "Refine Edges": "調整邊緣", @@ -596,9 +617,12 @@ "Remove Face Box": "移除臉部框", "Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」", "Remove from Target Album": "從目標相簿移除", + "Remove shortcut": "移除快速鍵", "Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)", "Rename Component": "重新命名元件", "Reorder Masks": "重新排序遮色片", + "Reset All": "全部重設", + "Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵", "Reshuffle": "重新隨機排序", "File name": "檔案名稱", "Fill Edges": "填補邊緣", @@ -1144,6 +1168,7 @@ "Resize to": "調整尺寸為", "Resolution (ppi)": "解析度(ppi)", "Restore": "還原", + "Restore (no shortcut)": "還原(無快速鍵)", "Restore Library from Backup…": "從備份還原照片庫…", "Restore Version": "還原版本", "Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。", @@ -1197,6 +1222,7 @@ "Screen": "螢幕", "Search": "搜尋", "Search Photos": "搜尋照片", + "Search commands or keys": "搜尋命令或按鍵", "Search…": "搜尋…", "Seasons": "四季", "Second Window": "第二視窗", @@ -1227,6 +1253,7 @@ "Set Develop Values": "設定顯像參數", "Set Flag": "設定旗標", "Set Image Zoom and Pan": "設定影像縮放與平移", + "Set Keyboard Shortcut": "設定鍵盤快速鍵", "Set Mask Adjustments": "設定遮色片調整", "Set Point Curve": "設定點曲線", "Set Profile": "設定描述檔", @@ -1276,6 +1303,7 @@ "Show Pins": "顯示控制點", "Show Source": "顯示來源", "Show Thumbnails": "顯示縮圖", + "Show commands without a shortcut": "顯示沒有快速鍵的命令", "Show destination gamut warning": "顯示目標色域警告", "Show display gamut warning": "顯示螢幕色域警告", "Show file names": "顯示檔案名稱", @@ -1379,6 +1407,7 @@ "Text": "文字", "Text direction": "文字方向", "Texture": "紋理", + "That key can't be used as a shortcut": "該按鍵無法用作快速鍵", "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安裝:物件遮色片與描述遮色片已可使用。", "The download didn't work:": "下載失敗:", "The number at the end of the original name (IMG_0042 → 0042)": "原始名稱結尾的數字(IMG_0042 → 0042)", @@ -1492,6 +1521,7 @@ "Yellow Hue": "黃色色相", "Yellow Luminance": "黃色明度", "Yellow Saturation": "黃色飽和度", + "Yellow label": "黃色標籤", "Yes": "是", "You can close this: the download continues, and resumes if interrupted.": "可以關閉此視窗:下載會繼續進行,中斷後也會接續下載。", "Your Name": "你的名字", diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index d74fbc72..eef65ef8 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -83,7 +83,13 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: if changed { name = name.strong(); } - ui.label(name).on_hover_text(b.id); + // keys whose command changes in the Library grids (see `shortcuts::handle`) + let hover = match b.id { + "panel.presets" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: Flag as Pick and advance")), + "view.softProof" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: expand or collapse the stack")), + _ => b.id.to_string(), + }; + ui.label(name).on_hover_text(hover); let recording = app.recording_shortcut.as_deref() == Some(b.id); let text = if recording { RichText::new(crate::i18n::tr("Press keys…")).color(egui::Color32::WHITE) @@ -106,7 +112,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: apply(app, ui.ctx(), serde_json::json!({"id": b.id, "shortcut": null})); } let r = ui.add_enabled(changed, egui::Button::new("↺").small()).on_hover_text(match b.default { - Some(d) => format!("Restore {}", menu_text(d, mac)), + Some(d) => crate::i18n::tr_format!("Restore {}", menu_text(d, mac)), None => crate::i18n::tr("Restore (no shortcut)").to_string(), }); register(ui.ctx(), format!("button:shortcutReset-{}", b.id), r.rect); @@ -180,7 +186,7 @@ fn apply(app: &mut LightcraftApp, ctx: &egui::Context, params: serde_json::Value .map(|id| shortcuts::find_bindable(id).map_or(id, |b| crate::i18n::tr(b.label)).to_string()) .collect(); if !lost.is_empty() { - app.toast_for(ctx, format!("Removed from {}", lost.join(", ")), 3.0); + app.toast_for(ctx, crate::i18n::tr_format!("Removed from {}", lost.join(", ")), 3.0); } } Err(e) => app.toast_for(ctx, e, 3.0), From efe7cad73c2504910d5f0481136aa48c00724cf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:36:22 +0000 Subject: [PATCH 19/25] Follow-up to #218: Lightroom catalog import translated The progress, result and menu strings of the Lightroom catalog import in the ja, pt-br, zh-hans, zh-hant, de and ru catalogs. The formats catalogs held English copies of the seven new format messages; they are translated, and German and Russian (which the PR predates) get them too, so the formats build check passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- crates/ui-egui/locales/de-formats.json | 7 +++++++ crates/ui-egui/locales/de.json | 11 +++++++++++ crates/ui-egui/locales/ja-formats.json | 14 +++++++------- crates/ui-egui/locales/ja.json | 11 +++++++++++ crates/ui-egui/locales/pt-br-formats.json | 14 +++++++------- crates/ui-egui/locales/pt-br.json | 11 +++++++++++ crates/ui-egui/locales/ru-formats.json | 7 +++++++ crates/ui-egui/locales/ru.json | 11 +++++++++++ crates/ui-egui/locales/zh-hans-formats.json | 14 +++++++------- crates/ui-egui/locales/zh-hans.json | 11 +++++++++++ crates/ui-egui/locales/zh-hant-formats.json | 14 +++++++------- crates/ui-egui/locales/zh-hant.json | 11 +++++++++++ 12 files changed, 108 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index a72e9d79..4bd4965a 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -69,6 +69,12 @@ "Label Set: {name}": "Farbmarkierungssatz: {name}", "LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}", "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-Katalog geprüft: {photos} Fotos, {collections} Sammlungen, {missing} fehlen", + "Lightroom import complete: {imported} photos added": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt, {failed} fehlgeschlagen", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt; Index-Warnung: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom-Import fehlgeschlagen: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom-Prüfung fehlgeschlagen: {e}{warning}", "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.", "Matches: {}": "Treffer: {}", "Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%", @@ -86,6 +92,7 @@ "Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos", "Quick Develop: {name}": "Schnellentwicklung: {name}", "Rated {}": "Bewertet mit {}", + "Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}", "Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}", "Reading photos… {} of {}": "Fotos werden gelesen… {} von {}", "Redo {}": "Wiederholen: {}", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 41b6e079..3d34f87c 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -59,6 +59,7 @@ "Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden", "Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.", "Adding": "Wird hinzugefügt", + "Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …", "Advanced search…": "Erweiterte Suche…", "After": "Nachher", "After 30 days": "Nach 30 Tagen", @@ -664,6 +665,8 @@ "Image": "Bild", "Import": "Importieren", "Import Date": "Importdatum", + "Import Lightroom Catalog": "Lightroom-Katalog importieren", + "Import Lightroom Catalog…": "Lightroom-Katalog importieren …", "Import Photos": "Fotos importieren", "Import Photos…": "Fotos importieren…", "Import Point Curve Presets": "Punktkurvenvorgaben importieren", @@ -683,6 +686,7 @@ "Imported": "Importiert", "Imported Photos": "Importierte Fotos", "Importing": "Wird importiert", + "Importing Lightroom Catalog": "Lightroom-Katalog wird importiert", "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Beim Import wird die Quelle nach Fotos durchsucht; der Ordner wird nicht zu „Lokal“ hinzugefügt (verwende Lokal → Ordner durchsuchen…, um ohne Import in einem Ordner zu arbeiten).", "In Photo Grid and Square Grid: Flag as Pick and advance": "In Fotoraster und quadratischem Raster: als ausgewählt markieren und weiter", "In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen", @@ -701,7 +705,9 @@ "Info Overlay: File": "Infoüberlagerung: Datei", "Info overlay": "Infoüberlagerung", "Insert at the cursor": "An Cursorposition einfügen", + "Inspect Lightroom Catalog": "Lightroom-Katalog prüfen", "Inspect Photo": "Foto untersuchen", + "Inspecting Lightroom Catalog": "Lightroom-Katalog wird geprüft", "Instant ’70s": "Sofortbild der 70er", "Interface": "Oberfläche", "Interface language": "Sprache der Oberfläche", @@ -768,6 +774,9 @@ "LightCraft on GitHub": "LightCraft auf GitHub", "LightCraft website": "LightCraft-Website", "LightCraft — Second Window": "LightCraft — zweites Fenster", + "Lightroom Classic Catalog": "Lightroom Classic-Katalog", + "Lightroom import cancelled": "Lightroom-Import abgebrochen", + "Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen", "Lights": "Helle Mitteltöne", "Limit file size (KB, 0 = off)": "Dateigröße begrenzen (KB, 0 = aus)", "Line delta": "Zeilendifferenz", @@ -1123,6 +1132,7 @@ "Read mode": "Lesemodus", "Read the SAM License": "SAM-Lizenz lesen", "Reading": "Wird gelesen", + "Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …", "Reading folder…": "Ordner wird gelesen…", "Real name": "Echter Name", "Recent": "Zuletzt verwendet", @@ -1268,6 +1278,7 @@ "Save as set": "Als Satz speichern", "Save as…": "Speichern unter…", "Save preset": "Vorgabe speichern", + "Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …", "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine", "Scale": "Skalierung", "Scan failed": "Durchsuchen fehlgeschlagen", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 33adde1e..c99850f5 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -50,7 +50,7 @@ "Proof Preview · {}": "校正プレビュー・{}", "Quick Develop · {n} photos": "クイック現像・{n}枚", "Rated {}": "評価:{}", - "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", + "Reading Lightroom catalog… {done} of {total}": "Lightroom カタログを読み取り中… {done} / {total}", "Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚", "Redo {}": "やり直す:{}", "Remove “{}” from Favorites": "「{}」をお気に入りから削除", @@ -117,12 +117,12 @@ "Build Smart Previews ({scope})": "スマートプレビューを作成({scope})", "Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})", "Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})", - "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", - "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", - "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", - "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", - "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", - "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom カタログを確認しました:写真 {photos} 枚、コレクション {collections} 件、見つからないファイル {missing} 件", + "Lightroom import complete: {imported} photos added": "Lightroom の読み込みが完了しました:{imported} 枚の写真を追加", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom の読み込みが完了しました:{imported} 枚を追加、{failed} 枚が失敗", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom の読み込みが完了しました:{imported} 枚を追加。インデックスの警告:{warning}", + "Lightroom import failed: {e}{warning}": "Lightroom の読み込みに失敗しました:{e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom の確認に失敗しました:{e}{warning}", "Label Set: {name}": "ラベルセット:{name}", "Quick Develop: {name}": "クイック現像: {name}", "Reset {name}": "{name}をリセット", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 1e8f11c2..5757fc15 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -45,6 +45,7 @@ "Add/Subtract/Intersect Mask": "マスクを追加/減算/交差", "Added to photos you import that don't already have it.": "読み込む写真に、この情報がない場合だけ追加します", "Adding": "追加中", + "Adding Lightroom photos…": "Lightroom の写真を追加中…", "After": "編集後", "After 30 days": "30日後", "After 7 days": "7日後", @@ -507,10 +508,13 @@ "Hours": "時間", "ISO Speed": "ISO感度", "ISO speed": "ISO感度", + "Import Lightroom Catalog": "Lightroom カタログを読み込む", + "Import Lightroom Catalog…": "Lightroom カタログを読み込む…", "Import Point Curve Presets…": "ポイントカーブのプリセットを読み込む…", "Import Profiles": "プロファイルを読み込む", "Import as new": "新規として読み込む", "Imported": "読み込み済み", + "Importing Lightroom Catalog": "Lightroom カタログを読み込み中", "In Photo Grid and Square Grid: Flag as Pick and advance": "写真グリッドと正方形グリッドでは:採用フラグを付けて次へ", "In Photo Grid and Square Grid: expand or collapse the stack": "写真グリッドと正方形グリッドでは:スタックを展開または折りたたむ", "Increase Brush Feather": "ブラシのぼかしを大きく", @@ -519,7 +523,9 @@ "Info Overlay: Exposure": "情報オーバーレイ:露出", "Info Overlay: File": "情報オーバーレイ:ファイル", "Insert at the cursor": "カーソル位置に挿入", + "Inspect Lightroom Catalog": "Lightroom カタログを確認", "Inspect Photo": "写真を調べる", + "Inspecting Lightroom Catalog": "Lightroom カタログを確認中", "Instant ’70s": "インスタント’70s", "Invert Component": "構成要素を反転", "JPEG (Large)": "JPEG(大)", @@ -540,6 +546,9 @@ "Library saved": "ライブラリを保存しました", "Licence:": "ライセンス:", "LightCraft could not start": "LightCraftを起動できませんでした", + "Lightroom Classic Catalog": "Lightroom Classic カタログ", + "Lightroom import cancelled": "Lightroom の読み込みをキャンセルしました", + "Lightroom inspection cancelled": "Lightroom の確認をキャンセルしました", "Line delta": "行数の増減", "Linear": "リニア", "Lines +/−": "行数 +/−", @@ -653,6 +662,7 @@ "Rating cleared": "評価を解除しました", "Raw": "RAW", "Read the SAM License": "SAMライセンスを読む", + "Reading Lightroom catalog…": "Lightroom カタログを読み取り中…", "Real name": "本名", "Recent": "最近使用", "Recent Keywords": "最近使用したキーワード", @@ -1154,6 +1164,7 @@ "Save as Smart Album…": "スマートアルバムとして保存…", "Save as set": "セットとして保存", "Save preset": "プリセットを保存", + "Saving Lightroom import index…": "Lightroom 読み込みインデックスを保存中…", "Scale": "拡大率", "Scan failed": "スキャンに失敗しました", "Scanning": "検索中", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 2a59467f..40fc4aea 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -45,12 +45,12 @@ "Imported {n} preset{}": "Importadas {n} predefinição{:.0}", "Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível", "Imported {} photo{}": "Importadas {} foto{:.0}", - "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", - "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", - "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", - "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", - "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", - "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo do Lightroom inspecionado: {photos} fotos, {collections} coleções, {missing} ausentes", + "Lightroom import complete: {imported} photos added": "Importação do Lightroom concluída: {imported} fotos adicionadas", + "Lightroom import complete: {imported} photos added, {failed} failed": "Importação do Lightroom concluída: {imported} fotos adicionadas, {failed} com falha", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Importação do Lightroom concluída: {imported} fotos adicionadas; aviso do índice: {warning}", + "Lightroom import failed: {e}{warning}": "Falha na importação do Lightroom: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Falha na inspeção do Lightroom: {e}{warning}", "Label Set: {name}": "Conjunto de rótulos: {name}", "LightCraft could not open its window: {e}": "O LightCraft não conseguiu abrir sua janela: {e}", "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}", @@ -68,7 +68,7 @@ "Quick Develop · {n} photos": "Revelação rápida · {n} fotos", "Quick Develop: {name}": "Revelação rápida: {name}", "Rated {}": "Avaliada {}", - "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", + "Reading Lightroom catalog… {done} of {total}": "Lendo catálogo do Lightroom… {done} de {total}", "Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}", "Reading photos… {} of {}": "Lendo fotos… {} de {}", "Redo {}": "Refazer {}", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 06df4290..5dc089b2 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -46,6 +46,7 @@ "Add/Subtract/Intersect Mask": "Adicionar/Subtrair/Interseção de máscara", "Added to photos you import that don't already have it.": "Adicionado às fotos importadas que ainda não têm isso.", "Adding": "Adicionando", + "Adding Lightroom photos…": "Adicionando fotos do Lightroom…", "Advanced search…": "Busca avançada…", "After": "Depois", "After 30 days": "Após 30 dias", @@ -637,6 +638,8 @@ "Image": "Imagem", "Import": "Importar", "Import Date": "Data de importação", + "Import Lightroom Catalog": "Importar catálogo do Lightroom", + "Import Lightroom Catalog…": "Importar catálogo do Lightroom…", "Import Photos": "Importar fotos", "Import Photos…": "Importar fotos…", "Import Point Curve Presets": "Importar predefinições de curva de pontos", @@ -652,6 +655,7 @@ "Imported": "Importados", "Imported Photos": "Fotos importadas", "Importing": "Importando", + "Importing Lightroom Catalog": "Importando catálogo do Lightroom", "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "A importação examina a origem em busca de fotos; ela não adiciona a pasta a Local (use Local → Procurar pasta… para trabalhar em uma pasta sem importar).", "In Photo Grid and Square Grid: Flag as Pick and advance": "Na Grade de fotos e na Grade quadrada: marcar como seleção e avançar", "In Photo Grid and Square Grid: expand or collapse the stack": "Na Grade de fotos e na Grade quadrada: expandir ou recolher a pilha", @@ -670,7 +674,9 @@ "Info Overlay: File": "Sobreposição de informações: arquivo", "Info overlay": "Sobreposição de informações", "Insert at the cursor": "Inserir no cursor", + "Inspect Lightroom Catalog": "Inspecionar catálogo do Lightroom", "Inspect Photo": "Inspecionar foto", + "Inspecting Lightroom Catalog": "Inspecionando catálogo do Lightroom", "Instant ’70s": "Instantâneo anos 70", "Interface": "Interface", "Interface language": "Idioma da interface", @@ -735,6 +741,9 @@ "LightCraft on GitHub": "LightCraft no GitHub", "LightCraft website": "Site do LightCraft", "LightCraft — Second Window": "LightCraft — segunda janela", + "Lightroom Classic Catalog": "Catálogo do Lightroom Classic", + "Lightroom import cancelled": "Importação do Lightroom cancelada", + "Lightroom inspection cancelled": "Inspeção do Lightroom cancelada", "Lights": "Luzes", "Limit file size (KB, 0 = off)": "Limitar tamanho do arquivo (KB, 0 = desativado)", "Line delta": "Diferença de linhas", @@ -1083,6 +1092,7 @@ "Read mode": "Modo de leitura", "Read the SAM License": "Ler a licença do SAM", "Reading": "Lendo", + "Reading Lightroom catalog…": "Lendo catálogo do Lightroom…", "Reading folder…": "Lendo pasta…", "Real name": "Nome real", "Recent": "Recentes", @@ -1225,6 +1235,7 @@ "Save as set": "Salvar como conjunto", "Save as…": "Salvar como…", "Save preset": "Salvar predefinição", + "Saving Lightroom import index…": "Salvando índice de importação do Lightroom…", "Scale": "Escala", "Scan failed": "Falha na verificação", "Scanning": "Examinando", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index fa1294a8..2beb4a5b 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -73,6 +73,12 @@ "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft пока не умеет декодировать этот вариант RAW ({}). Вы редактируете встроенный в файл JPEG-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.", "LightCraft could not open its window: {e}": "LightCraft не удалось открыть окно: {e}", "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не удалось открыть окно: {e}\n\nПодробности в файле журнала: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Каталог Lightroom проверен: фото — {photos}, коллекций — {collections}, отсутствует — {missing}", + "Lightroom import complete: {imported} photos added": "Импорт из Lightroom завершён: добавлено фото — {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Импорт из Lightroom завершён: добавлено фото — {imported}, ошибок — {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Импорт из Lightroom завершён: добавлено фото — {imported}; предупреждение индекса: {warning}", + "Lightroom import failed: {e}{warning}": "Ошибка импорта из Lightroom: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Ошибка проверки Lightroom: {e}{warning}", "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.", "Matches: {}": "Совпадения: {}", "Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%", @@ -91,6 +97,7 @@ "Quick Develop · {n} photos": "Быстрая коррекция · фото: {n}", "Quick Develop: {name}": "Быстрая коррекция: {name}", "Rated {}": "Оценка: {}", + "Reading Lightroom catalog… {done} of {total}": "Чтение каталога Lightroom… {done} из {total}", "Reading photos… {done} of {total}": "Чтение фото… {done} из {total}", "Reading photos… {} of {}": "Чтение фото… {} из {}", "Redo {}": "Повторить {}", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 0a0c9bad..d6f1157a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -29,6 +29,7 @@ "Add/Subtract/Intersect Mask": "Добавить/вычесть/пересечь маску", "Added to photos you import that don't already have it.": "Добавляется к импортируемым фото, у которых этого ещё нет.", "Adding": "Добавление", + "Adding Lightroom photos…": "Добавление фото из Lightroom…", "Advanced search…": "Расширенный поиск…", "After": "После", "After 30 days": "Через 30 дней", @@ -521,6 +522,8 @@ "Image": "Изображение", "Import": "Импорт", "Import Date": "Дата импорта", + "Import Lightroom Catalog": "Импорт каталога Lightroom", + "Import Lightroom Catalog…": "Импорт каталога Lightroom…", "Import Photos": "Импорт фото", "Import Photos…": "Импорт фото…", "Import Point Curve Presets": "Импортировать пресеты точечной кривой", @@ -534,6 +537,7 @@ "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Импортируйте фото через Файл → Импорт фото… (Cmd+Shift+I) или перетащите их сюда", "Imported Photos": "Импортированные фото", "Importing": "Импорт", + "Importing Lightroom Catalog": "Импорт каталога Lightroom", "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "При импорте источник сканируется на фото; папка не добавляется в «Локальное» (чтобы работать с папкой без импорта, используйте Локальное → Выбрать папку…).", "In Photo Grid and Square Grid: Flag as Pick and advance": "В сетке фото и квадратной сетке: пометить как выбранное и перейти дальше", "In Photo Grid and Square Grid: expand or collapse the stack": "В сетке фото и квадратной сетке: развернуть или свернуть стопку", @@ -548,7 +552,9 @@ "Info": "Информация", "Info (I)": "Информация (I)", "Info overlay": "Информационная накладка", + "Inspect Lightroom Catalog": "Проверить каталог Lightroom", "Inspect Photo": "Просмотр фото", + "Inspecting Lightroom Catalog": "Проверка каталога Lightroom", "Instant ’70s": "Мгновенный стиль 70-х", "Interface": "Интерфейс", "Interface language": "Язык интерфейса", @@ -604,6 +610,9 @@ "LightCraft on GitHub": "LightCraft на GitHub", "LightCraft website": "Сайт LightCraft", "LightCraft — Second Window": "LightCraft — второе окно", + "Lightroom Classic Catalog": "Каталог Lightroom Classic", + "Lightroom import cancelled": "Импорт из Lightroom отменён", + "Lightroom inspection cancelled": "Проверка Lightroom отменена", "Lights": "Светлые тона", "Limit file size (KB, 0 = off)": "Ограничить размер файла (КБ, 0 = выкл.)", "Linear Gradient": "Линейный градиент", @@ -895,6 +904,7 @@ "Read mode": "Режим чтения", "Read the SAM License": "Прочитать лицензию SAM", "Reading": "Чтение", + "Reading Lightroom catalog…": "Чтение каталога Lightroom…", "Reading folder…": "Чтение папки…", "Recently Added": "Недавно добавленные", "Recently Deleted": "Недавно удалённые", @@ -1008,6 +1018,7 @@ "Save as set": "Сохранить как набор", "Save as…": "Сохранить как…", "Save preset": "Сохранить пресет", + "Saving Lightroom import index…": "Сохранение индекса импорта Lightroom…", "Scale": "Масштаб", "Scanning": "Сканирование", "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 906d9037..bf7a692c 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -50,7 +50,7 @@ "Proof Preview · {}": "校样预览 · {0}", "Quick Develop · {n} photos": "快速编辑 · {n} 张照片", "Rated {}": "已评级 {0}", - "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", + "Reading Lightroom catalog… {done} of {total}": "正在读取 Lightroom 目录… {done} / {total}", "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", "Redo {}": "重做:{0}", "Remove “{}” from Favorites": "从收藏夹中移除“{0}”", @@ -117,12 +117,12 @@ "Build Smart Previews ({scope})": "生成智能预览({scope})", "Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})", "Discard Smart Previews ({scope})": "丢弃智能预览({scope})", - "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", - "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", - "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", - "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", - "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", - "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已检查 Lightroom 目录:{photos} 张照片,{collections} 个收藏夹,{missing} 个缺失", + "Lightroom import complete: {imported} photos added": "Lightroom 导入完成:已添加 {imported} 张照片", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 导入完成:已添加 {imported} 张照片,{failed} 张失败", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 导入完成:已添加 {imported} 张照片;索引警告:{warning}", + "Lightroom import failed: {e}{warning}": "Lightroom 导入失败:{e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom 检查失败:{e}{warning}", "Label Set: {name}": "标签集:{name}", "Quick Develop: {name}": "快速编辑:{name}", "Reset {name}": "重置{name}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 7e286b0c..b71df8c7 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -46,6 +46,7 @@ "Add/Subtract/Intersect Mask": "添加/减去/相交蒙版", "Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。", "Adding": "正在添加", + "Adding Lightroom photos…": "正在添加 Lightroom 照片…", "Advanced search…": "高级搜索…", "After": "修改后", "After 30 days": "30 天后", @@ -513,14 +514,19 @@ "Hours": "小时", "ISO Speed": "ISO 感光度", "ISO speed": "ISO 感光度", + "Import Lightroom Catalog": "导入 Lightroom 目录", + "Import Lightroom Catalog…": "导入 Lightroom 目录…", "Import as new": "作为新照片导入", "Imported": "已导入", + "Importing Lightroom Catalog": "正在导入 Lightroom 目录", "In Photo Grid and Square Grid: Flag as Pick and advance": "在照片网格和正方形网格中:标记为留用并前进", "In Photo Grid and Square Grid: expand or collapse the stack": "在照片网格和正方形网格中:展开或折叠堆叠", "Info Overlay Off": "关闭信息叠加", "Info Overlay: Exposure": "信息叠加:曝光", "Info Overlay: File": "信息叠加:文件", "Insert at the cursor": "在光标处插入", + "Inspect Lightroom Catalog": "检查 Lightroom 目录", + "Inspecting Lightroom Catalog": "正在检查 Lightroom 目录", "Invert Component": "反相组件", "Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区", "Label": "标签", @@ -532,6 +538,9 @@ "Library saved": "图库已保存", "Licence:": "许可证:", "LightCraft could not start": "LightCraft 无法启动", + "Lightroom Classic Catalog": "Lightroom Classic 目录", + "Lightroom import cancelled": "已取消 Lightroom 导入", + "Lightroom inspection cancelled": "已取消 Lightroom 检查", "Line delta": "行数差", "Linear": "线性", "Lines +/−": "行数 +/−", @@ -602,6 +611,7 @@ "Rating cleared": "已清除星级", "Raw": "RAW", "Read the SAM License": "阅读 SAM 许可证", + "Reading Lightroom catalog…": "正在读取 Lightroom 目录…", "Real name": "真实姓名", "Recent": "最近使用", "Recent Keywords": "最近使用的关键字", @@ -1215,6 +1225,7 @@ "Save as set": "保存为集", "Save as…": "另存为…", "Save preset": "保存预设", + "Saving Lightroom import index…": "正在保存 Lightroom 导入索引…", "Scale": "缩放", "Scan failed": "扫描失败", "Scanning": "正在扫描", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index a51e1749..5de019f8 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -42,12 +42,12 @@ "Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}", "Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}", "Imported {} photo{}": "已匯入 {} 張照片{:.0}", - "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing", - "Lightroom import complete: {imported} photos added": "Lightroom import complete: {imported} photos added", - "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom import complete: {imported} photos added, {failed} failed", - "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom import complete: {imported} photos added; index warning: {warning}", - "Lightroom import failed: {e}{warning}": "Lightroom import failed: {e}{warning}", - "Lightroom inspection failed: {e}{warning}": "Lightroom inspection failed: {e}{warning}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已檢查 Lightroom 目錄:{photos} 張相片,{collections} 個收藏集,{missing} 個遺失", + "Lightroom import complete: {imported} photos added": "Lightroom 匯入完成:已加入 {imported} 張相片", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 匯入完成:已加入 {imported} 張相片,{failed} 張失敗", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 匯入完成:已加入 {imported} 張相片;索引警告:{warning}", + "Lightroom import failed: {e}{warning}": "Lightroom 匯入失敗:{e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom 檢查失敗:{e}{warning}", "Label Set: {name}": "標籤集:{name}", "LightCraft could not open its window: {e}": "LightCraft 無法開啟視窗:{e}", "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}", @@ -65,7 +65,7 @@ "Quick Develop · {n} photos": "快速顯像 · {n} 張照片", "Quick Develop: {name}": "快速顯像:{name}", "Rated {}": "評分:{}", - "Reading Lightroom catalog… {done} of {total}": "Reading Lightroom catalog… {done} of {total}", + "Reading Lightroom catalog… {done} of {total}": "正在讀取 Lightroom 目錄… {done} / {total}", "Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}", "Reading photos… {} of {}": "正在讀取照片… {}/{}", "Redo {}": "重做 {}", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index a1c8c5f6..53d7b717 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -46,6 +46,7 @@ "Add/Subtract/Intersect Mask": "新增/減去/交集遮色片", "Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。", "Adding": "正在新增", + "Adding Lightroom photos…": "正在加入 Lightroom 相片…", "Advanced search…": "進階搜尋…", "After": "編輯後", "After 30 days": "30 天後", @@ -513,14 +514,19 @@ "Hours": "小時", "ISO Speed": "ISO 感光度", "ISO speed": "ISO 感光度", + "Import Lightroom Catalog": "匯入 Lightroom 目錄", + "Import Lightroom Catalog…": "匯入 Lightroom 目錄…", "Import as new": "以新照片匯入", "Imported": "已匯入", + "Importing Lightroom Catalog": "正在匯入 Lightroom 目錄", "In Photo Grid and Square Grid: Flag as Pick and advance": "在照片格狀檢視與方格檢視中:標記為精選並前進", "In Photo Grid and Square Grid: expand or collapse the stack": "在照片格狀檢視與方格檢視中:展開或收合堆疊", "Info Overlay Off": "關閉資訊疊加", "Info Overlay: Exposure": "資訊疊加:曝光", "Info Overlay: File": "資訊疊加:檔案", "Insert at the cursor": "在游標處插入", + "Inspect Lightroom Catalog": "檢查 Lightroom 目錄", + "Inspecting Lightroom Catalog": "正在檢查 Lightroom 目錄", "Invert Component": "反轉元件", "Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群", "Label": "標籤", @@ -532,6 +538,9 @@ "Library saved": "照片庫已儲存", "Licence:": "授權:", "LightCraft could not start": "LightCraft 無法啟動", + "Lightroom Classic Catalog": "Lightroom Classic 目錄", + "Lightroom import cancelled": "已取消 Lightroom 匯入", + "Lightroom inspection cancelled": "已取消 Lightroom 檢查", "Line delta": "行數差", "Linear": "線性", "Lines +/−": "行數 +/−", @@ -602,6 +611,7 @@ "Rating cleared": "已清除評分", "Raw": "RAW", "Read the SAM License": "閱讀 SAM 授權", + "Reading Lightroom catalog…": "正在讀取 Lightroom 目錄…", "Real name": "真實姓名", "Recent": "最近使用", "Recent Keywords": "最近使用的關鍵字", @@ -1215,6 +1225,7 @@ "Save as set": "儲存為關鍵字集", "Save as…": "另存新檔…", "Save preset": "儲存預設集", + "Saving Lightroom import index…": "正在儲存 Lightroom 匯入索引…", "Scale": "縮放", "Scan failed": "掃描失敗", "Scanning": "正在掃描", From 86deb5b0046f53976aad7ed266cd0c97504bffe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:42:01 +0000 Subject: [PATCH 20/25] Fix #367: import copies and verifies files on several threads Import -> Copy (and Move, and Copy as DNG) handled one file after another on the import worker: copy, fsync, read back and hash, optionally decode and re-encode the raw as DNG. Every other CPU-heavy step of an import was already parallel (the probes, up to 8 threads), so with the probes cached by the review dialog's scan the transfer phase ran on one core. ImportJob::prepare_files now plans each file in order, as before (duplicates by path and content, sidecars, rename sequence numbers, folders, Kept notes) and collects the copies/moves, which then run on up to 8 threads (std::thread::scope; sequential on wasm32). Outcomes keep their places, so results, order, names and failure messages are those of a one-by-one import: - a file whose content matches a transfer still pending, or whose name could collide with one (same folder, same name family: IMG.jpg, IMG-1.jpg, img.dng), waits until that one has finished; - cancel stops before the next file; transfers not started leave no item; - a panic in one transfer fails that file instead of the batch; - Copy as DNG holds a memory-gate permit (6x the file size) while it runs. Copy verification and crash safety are unchanged (same copy_new / place). The desktop's import worker prepares batches of 2 x workers files (8..16, lightcraft_engine::import::batch_size) instead of 8, so a batch's slowest file holds back fewer threads; the browser build keeps 8. Measured (4-core shared VM, 200 procedural 3.7 MB JPEGs = 754 MB, warm page cache, throwaway benchmark not committed): - probe: ~80 ms CPU per file is the JPEG decode (DCT-scaled to 64 px), 1.2 ms the content hash; the dialog's scan: 4.4 s, 3.6 cores busy. - Copy, probes cached: copy+fsync+verify 1.59 s at 0.43 cores before, 0.94 s at 0.81 cores after. Copy, not cached: 6.45 s -> 5.51 s. - Add, probes cached: 2 ms prepare; nothing to parallelize. - commit of a batch on the UI thread (journal append + fsync): ~4 ms, flat for libraries of 0, 5k, 20k and 50k photos (~0.7 s for 1300). Copy as DNG could not be measured (no raw corpus here); it is the most CPU-heavy per-file step and was sequential. Test: copies_run_side_by_side_with_the_same_outcome (copies under the test fault Slow run on more than one thread; names, order and in-batch duplicates match a one-by-one copy; the outcome assertions also pass on the old code, the thread assertion fails there). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- crates/engine/src/import.rs | 295 ++++++++++++++++++++----- crates/engine/src/import_move.rs | 23 ++ crates/engine/src/tests_import_move.rs | 67 ++++++ crates/ui-egui/src/headless.rs | 11 +- crates/ui-egui/src/import.rs | 8 +- 5 files changed, 340 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 5df94988..5ab462a1 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -365,7 +365,7 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress }; #[cfg(not(target_arch = "wasm32"))] { - let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1)); + let n = workers(paths.len()); if n > 1 { let mut results: Vec>> = vec![None; paths.len()]; let next = std::sync::atomic::AtomicUsize::new(0); @@ -404,18 +404,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress .collect() } -/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…; -/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, -/// synced and checked against `probe_hash`, the content hash the probe computed — or compared -/// byte for byte with the source when there is none); a bad one is removed and the photo -/// reported as failed. -fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result { - let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); +/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the +/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and +/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte +/// with the source when there is none); a bad one is removed and the photo reported as failed. +fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result { let name = name .map(str::to_string) .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse); - crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string()) + crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string()) } /// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network @@ -699,8 +697,9 @@ impl ImportJob { } /// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip - /// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the - /// catalog changes. Stops before the next file once `cancel` is set. + /// duplicates by content, read sidecars, copy or place (Move) the files — several at once, + /// with the names and outcomes of one after another. Nothing in the catalog changes. Stops + /// before the next file once `cancel` is set. pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared { let files = self.expand(paths); self.prepare_files(files, cancel) @@ -750,24 +749,39 @@ impl ImportJob { let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); + // Files are planned one by one, in order (duplicates, sidecars, names, folders); their + // copies or moves — the slow part, reading and writing every byte and verifying it — are + // collected and run on several threads (issue #367), then their outcomes take their places. + let mut plan = Plan::default(); for (path, info) in todo.into_iter().zip(probed) { if cancel.load(Relaxed) { break; } - self.done.fetch_add(1, Relaxed); let mut info = match info { Ok(i) => i, Err(e) => { log::warn!("import {path}: {e}"); - out.items.push(PreparedItem::Failed(path, e)); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Failed(path, e))); continue; } }; + // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds + if !opts.local + && let Some(h) = &info.content_hash + && plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h)) + { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; + } + } if let Some(h) = &info.content_hash && !opts.local && let Some(existing) = self.by_hash.get(h) { - out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) })); continue; } // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files @@ -786,7 +800,6 @@ impl ImportJob { if info.captured.is_none() { info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); } - let mut placed = None; let stored = match (mode, &self.copy_root) { (ImportMode::Copy | ImportMode::Move, Some(root)) if !crate::import_move::inside(Path::new(&path), root) @@ -803,37 +816,35 @@ impl ImportJob { n }); let folders = opts.organize.folders(&q); - if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { - let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); - let name = name.unwrap_or(own); - match crate::import_move::place(Path::new(&path), &dir, &name) { - Ok(pl) => { - let dst = pl.dst.to_string_lossy().to_string(); - placed = Some(pl); - dst - } - Err(e) => { - out.items.push(PreparedItem::Failed(path, e)); - continue; - } + let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); + // the name this file gets depends on what a pending transfer of a like-named file + // into the same folder ends up with: that one goes first + let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() }); + let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named)); + if plan.transfers.iter().any(|t| t.family == family) { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; } + } + let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { + How::Place { name: name.unwrap_or(own) } } else { if mode == ImportMode::Move { let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; - out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })); + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); } - match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) { - Ok(p) => p, - Err(e) => { - out.items.push(PreparedItem::Failed(path, e)); - continue; - } - } - } + How::Copy { name } + }; + // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) + let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG"); + plan.slots.push(Slot::Transfer); + plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family }); + continue; } (ImportMode::Move, _) => { let reason = "already inside the destination or the library: added where it is"; - out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })); + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); path.clone() } _ => path.clone(), @@ -841,27 +852,199 @@ impl ImportJob { if let Some(h) = &info.content_hash { self.by_hash.insert(h.clone(), None); } - // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) - let mut stored = stored; - if opts.convert_dng - && mode == ImportMode::Copy - && stored != path - && info.kind == MediaKind::Raw - && !info.format.eq_ignore_ascii_case("DNG") - { - match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) { - Ok(dng) => { - let _ = std::fs::remove_file(&stored); - stored = dng; - info.format = "DNG".into(); - } - Err(e) => log::warn!("import {path}: copy as DNG: {e}"), - } - } - out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None })))); } + self.run_plan(&mut plan, &mut out, cancel); out } + + /// Run the transfers `plan` collected (on several threads) and append its items to `out` in + /// file order. Transfers not started because `cancel` was set leave no item. + fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) { + let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter(); + for slot in std::mem::take(&mut plan.slots) { + let item = match slot { + Slot::Item(it) => it, + Slot::Transfer => match done.next().flatten() { + Some(it) => it, + None => continue, + }, + }; + if let PreparedItem::Ready(r) = &item + && let Some(h) = &r.info.content_hash + { + self.by_hash.insert(h.clone(), None); + } + out.items.push(item); + } + } +} + +/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). +#[derive(Default)] +struct Plan { + slots: Vec, + transfers: Vec, +} + +enum Slot { + Item(PreparedItem), + /// The outcome of the next of [`Plan::transfers`]. + Transfer, +} + +/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]). +struct Transfer { + path: String, + info: ProbeInfo, + sidecar: Option, + dir: PathBuf, + how: How, + /// Copy as DNG. + dng: bool, + /// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's + /// names, so they never run at the same time. + family: (String, String), +} + +enum How { + /// A verified copy, under `name` (default: the file's own). + Copy { name: Option }, + /// Move: placed (linked or copied) without touching the source. + Place { name: String }, +} + +/// The names a file called `name` could end up with, as one key: the transfers number a taken name +/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`, +/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems). +fn name_family(name: &str) -> String { + let mut stem = match name.rsplit_once('.') { + Some((s, _)) if !s.is_empty() => s, + _ => name, + }; + while let Some((head, tail)) = stem.rsplit_once('-') + && !head.is_empty() + && !tail.is_empty() + && tail.bytes().all(|b| b.is_ascii_digit()) + { + stem = head; + } + stem.to_lowercase() +} + +/// Threads for `jobs` file reads (probes, copies) at once. +#[cfg(not(target_arch = "wasm32"))] +fn workers(jobs: usize) -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) +} + +/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]): +/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few +/// enough that each batch soon joins the catalog. +pub fn batch_size() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + workers(usize::MAX).saturating_mul(2).clamp(8, 16) + } + #[cfg(target_arch = "wasm32")] + { + 8 + } +} + +/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one +/// not started because `cancel` was set. `done` counts each one finished. +fn run_transfers( + transfers: Vec, + file_bytes: Option<&crate::merge::ByteReader>, + cancel: &std::sync::atomic::AtomicBool, + done: &std::sync::atomic::AtomicUsize, +) -> Vec> { + use std::sync::atomic::Ordering::Relaxed; + let one = |t: Transfer| { + if cancel.load(Relaxed) { + return None; + } + let path = t.path.clone(); + // last-resort guard: a panic is this file's failure, not the import's + let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e)); + done.fetch_add(1, Relaxed); + Some(item) + }; + #[cfg(not(target_arch = "wasm32"))] + { + let n = workers(transfers.len()); + if n > 1 { + let mut results: Vec> = std::iter::repeat_with(|| None).take(transfers.len()).collect(); + let queue = std::sync::Mutex::new(transfers.into_iter().enumerate()); + let out = std::sync::Mutex::new(&mut results); + // (the tests' injected faults hold for the workers too) + #[cfg(test)] + let fault = crate::import_move::injected_fault(); + let work = &|| { + #[cfg(test)] + crate::import_move::inject(fault); + loop { + let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); + let Some((i, t)) = next else { break }; + let r = one(t); + if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { + *slot = r; + } + } + }; + std::thread::scope(|sc| { + let handles: Vec<_> = + (0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect(); + if handles.is_empty() { + // no thread to be had: copy here, one after another + work(); + } + for h in handles { + if h.join().is_err() { + log::error!("import: a copy worker stopped unexpectedly"); + } + } + }); + return results; + } + } + transfers.into_iter().map(one).collect() +} + +/// Copy or move one planned file (and Copy as DNG); its item. +fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem { + let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t; + let mut placed = None; + let mut stored = match how { + How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) { + Ok(pl) => { + let dst = pl.dst.to_string_lossy().to_string(); + placed = Some(pl); + dst + } + Err(e) => return PreparedItem::Failed(path, e), + }, + How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) { + Ok(p) => p, + Err(e) => return PreparedItem::Failed(path, e), + }, + }; + if dng { + // decoding the raw twice and encoding it takes several times the file's size in memory + let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6); + let _permit = crate::memory::work_gate().acquire(weight); + match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) { + Ok(dng) => { + let _ = std::fs::remove_file(&stored); + stored = dng; + info.format = "DNG".into(); + } + Err(e) => log::warn!("import {path}: copy as DNG: {e}"), + } + } + PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) } /// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index 3465eaa7..db442136 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -56,6 +56,9 @@ pub(crate) enum Fault { Corrupt, /// Removing the source fails (as on a read-only card). FailRemove, + /// Each copy takes a little longer and notes the thread it ran on ([`copy_threads`]), to see + /// an import's copies run side by side. + Slow, } #[cfg(test)] @@ -69,6 +72,21 @@ pub(crate) fn inject(f: Fault) { FAULT.with(|c| c.set(f)); } +/// The calling thread's injected fault (tests only), for the workers it starts to inherit. +#[cfg(test)] +pub(crate) fn injected_fault() -> Fault { + FAULT.with(|c| c.get()) +} + +/// The threads copies made under [`Fault::Slow`] ran on (tests only). +#[cfg(test)] +static COPY_THREADS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +pub(crate) fn copy_threads() -> Vec { + COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() +} + fn injected(f: Fault) -> bool { #[cfg(test)] { @@ -284,6 +302,11 @@ pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option> 20); let _ = std::fs::remove_dir_all(&base); } + +/// Issue #367: Import → Copy wrote and verified one file after another on one thread. The copies +/// now run side by side, with the names, order, duplicates and reports a one-by-one copy gives: +/// like-named files take -1, -2… in file order (also against a file already there and a literal +/// `IMG_1-1`), and a file with the content of one copied earlier in the batch is its duplicate +/// (also while that copy is still under way). +#[test] +fn copies_run_side_by_side_with_the_same_outcome() { + let base = temp_dir("parallel-copy"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a/IMG_1.png"), 1); + write_png(&card.join("a/IMG_2.png"), 2); + write_png(&card.join("b/IMG_1.png"), 3); + write_png(&card.join("b/IMG_1-1.png"), 4); + write_png(&card.join("c/IMG_1.png"), 5); + for i in 0..8u8 { + write_png(&card.join(format!("d/p{i}.png")), 10 + i); + } + // the content of a file whose copy is still under way + std::fs::create_dir_all(card.join("e")).unwrap(); + std::fs::copy(card.join("d/p3.png"), card.join("e/dup.png")).unwrap(); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::write(dest.join("IMG_2.png"), b"someone else's file").unwrap(); + + let mut s = session(); + inject(Fault::Slow); + let r = s + .execute( + "library.import", + &json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}), + ) + .unwrap(); + inject(Fault::None); + let threads: std::collections::HashSet<_> = crate::import_move::copy_threads().into_iter().collect(); + if std::thread::available_parallelism().map_or(1, |n| n.get()) > 1 { + assert!(threads.len() > 1, "copies ran on {} thread(s)", threads.len()); + } + + assert_eq!(len(&r, "imported"), 13, "{r}"); + assert_eq!(len(&r, "failed"), 0, "{r}"); + assert_eq!(len(&r, "duplicates"), 1, "{r}"); + assert_eq!(r["duplicates"][0]["reason"], "content"); + assert!(r["duplicates"][0]["path"].as_str().unwrap().ends_with("dup.png"), "{r}"); + // the order of a one-by-one copy (files in name order), each verified + let expect = [ + ("a/IMG_1.png", "IMG_1.png"), + ("a/IMG_2.png", "IMG_2-1.png"), + ("b/IMG_1-1.png", "IMG_1-1.png"), + ("b/IMG_1.png", "IMG_1-2.png"), + ("c/IMG_1.png", "IMG_1-3.png"), + ]; + for (src, dst) in expect { + assert_eq!(std::fs::read(dest.join(dst)).unwrap(), std::fs::read(card.join(src)).unwrap(), "{src} → {dst}"); + } + for i in 0..8 { + assert_eq!(std::fs::read(dest.join(format!("p{i}.png"))).unwrap(), std::fs::read(card.join(format!("d/p{i}.png"))).unwrap()); + } + assert_eq!(std::fs::read(dest.join("IMG_2.png")).unwrap(), b"someone else's file"); + assert_eq!(files_under(&dest).len(), 14, "{:?}", files_under(&dest)); + // the duplicate names the photo copied from the same content (d/p3); ids follow the file order + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}"); + assert_eq!(r["duplicates"][0]["existing"], ids[8], "{r}"); + let first = s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap(); + assert_eq!(first.file_name, "IMG_1.png"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 787e0d1c..4411c35a 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1635,8 +1635,9 @@ mod tests { let _ = std::fs::remove_dir_all(&base); let (src, dest) = (base.join("card"), base.join("out")); std::fs::create_dir_all(&src).unwrap(); - for i in 0..10u8 { - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] }; + // more files than a batch holds (see `batch_size`) + for i in 0..20u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap(); } @@ -1651,7 +1652,7 @@ mod tests { h.settle(SETTLE); h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.candidates.len(), 10); + assert_eq!(opts.candidates.len(), 20); for id in ["button:importCopy", "button:importDest"] { let r = h.request("ui.clickWidget", json!({"id": id}), t); assert_eq!(r["ok"], true, "{id}: {r}"); @@ -1670,8 +1671,8 @@ mod tests { assert!(h.app.import.is_none(), "finished"); let mut names: Vec = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); names.sort(); - let want: Vec = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect(); - assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH); + let want: Vec = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect(); + assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size()); let _ = std::fs::remove_dir_all(&base); } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 0e330722..143947d5 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -18,8 +18,10 @@ use crate::render::Slot; use crate::theme::Tokens; use crate::widgets::register; -/// Files per batch (each batch joins the catalog as it is ready, so the progress window updates). -pub(crate) const BATCH: usize = 8; +/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the +/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. +#[cfg(target_arch = "wasm32")] +const BATCH: usize = 8; /// The review dialog's first size (points). It can be resized; the photo grid takes the height. pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; @@ -273,7 +275,7 @@ impl ImportRun { let ctx = ctx.clone(); let work = move || { let files = job.expand(&queue); - for chunk in files.chunks(BATCH) { + for chunk in files.chunks(lightcraft_engine::import::batch_size()) { if cancel.load(Ordering::Relaxed) { break; } From d8efa6a147e8b539f30f0b08e1d6b60db845cfed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:48:31 +0000 Subject: [PATCH 21/25] Spanish for the keymap editor (#373) and the Lightroom catalog import (#218) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- crates/ui-egui/locales/es-formats.json | 9 ++++++ crates/ui-egui/locales/es.json | 41 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 320df67d..ccf95bd2 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -69,6 +69,12 @@ "Label Set: {name}": "Conjunto de etiquetas: {name}", "LightCraft could not open its window: {e}": "LightCraft no pudo abrir su ventana: {e}", "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft no pudo abrir su ventana: {e}\n\nLos detalles están en el archivo de registro: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo de Lightroom inspeccionado: {photos} fotos, {collections} colecciones, {missing} no encontradas", + "Lightroom import complete: {imported} photos added": "Importación de Lightroom completada: {imported} fotos añadidas", + "Lightroom import complete: {imported} photos added, {failed} failed": "Importación de Lightroom completada: {imported} fotos añadidas, {failed} con errores", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Importación de Lightroom completada: {imported} fotos añadidas; aviso del índice: {warning}", + "Lightroom import failed: {e}{warning}": "Error al importar de Lightroom: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Error al inspeccionar Lightroom: {e}{warning}", "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fuentes: {} (OFL). Iconos: originales.", "Matches: {}": "Coincidencias: {}", "Merging… {stage} {:.0}%": "Combinando… {stage} {:.0}%", @@ -86,11 +92,13 @@ "Quick Develop · {n} photos": "Revelado rápido · {n} fotos", "Quick Develop: {name}": "Revelado rápido: {name}", "Rated {}": "Valorada con {}", + "Reading Lightroom catalog… {done} of {total}": "Leyendo catálogo de Lightroom… {done} de {total}", "Reading photos… {done} of {total}": "Leyendo fotos… {done} de {total}", "Reading photos… {} of {}": "Leyendo fotos… {} de {}", "Redo {}": "Rehacer {}", "Remove “{name}” and its {count} photo{} from the library?": "¿Quitar “{name}” y sus {count} foto{} de la biblioteca?", "Remove “{}” from Favorites": "Quitar “{}” de Favoritos", + "Removed from {}": "Quitado de {}", "Removed {what} from {name}": "{what} quitado de {name}", "Rename {n} Photos…": "Cambiar nombre de {n} fotos…", "Rename “{name}”": "Cambiar nombre de “{name}”", @@ -99,6 +107,7 @@ "Replace {} with:": "Reemplazar {} por:", "Reset {name}": "Restablecer {name}", "Reset {} Channel": "Restablecer canal {}", + "Restore {}": "Restaurar {}", "Restore {} Photo{}": "Restaurar {} foto{}", "Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index a4eeffe1..73f53a9e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -44,6 +44,7 @@ "Add/Subtract/Intersect Mask": "Añadir/restar/intersecar máscara", "Added to photos you import that don't already have it.": "Se añade a las fotos importadas que aún no lo tienen.", "Adding": "Añadiendo", + "Adding Lightroom photos…": "Añadiendo fotos de Lightroom…", "Advanced search…": "Búsqueda avanzada…", "After": "Después", "After 30 days": "Después de 30 días", @@ -148,6 +149,7 @@ "Blue Primary": "Azul primario", "Blue Saturation": "Saturación azul", "Blue channel": "Canal azul", + "Blue label": "Etiqueta azul", "Blue/Yellow Fringe": "Franja azul/amarilla", "Blurry photos below the score are flagged as rejects.": "Las fotos borrosas por debajo de la puntuación se marcan como rechazadas.", "Boundary Warp": "Deformación del contorno", @@ -209,8 +211,11 @@ "Clear Reference": "Borrar referencia", "Clear all": "Borrar todo", "Clear form": "Borrar formulario", + "Clear rating": "Borrar valoración", + "Clear rating and advance": "Borrar valoración y avanzar", "Cleared History": "Historial borrado", "Click Zoom Ratio": "Proporción de zoom al hacer clic", + "Click a shortcut, then press the new keys (Esc cancels).": "Hacer clic en un método abreviado y pulsar las nuevas teclas (Esc cancela).", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Hacer clic en una etiqueta para insertarla. Los ejemplos corresponden a IMG_0042.CR3, tomada el 14 ene 2026, 05:58:48.", "Click photos in the grid to toggle the keyword; Esc stops": "Hacer clic en las fotos de la cuadrícula para activar o desactivar la palabra clave; Esc para terminar", "Click the object to select it · ⌥-click leaves a part out": "Hacer clic en el objeto para seleccionarlo · ⌥-clic excluye una parte", @@ -503,6 +508,7 @@ "Fit to width": "Ajustar al ancho", "Fit visible": "Ajustar a lo visible", "Five stars": "Cinco estrellas", + "Fixed keys": "Teclas fijas", "Flag": "Marca", "Flag as Pick": "Marcar como seleccionada", "Flag as Reject": "Marcar como rechazada", @@ -565,6 +571,7 @@ "Green Primary": "Verde primario", "Green Saturation": "Saturación verde", "Green channel": "Canal verde", + "Green label": "Etiqueta verde", "Grid": "Cuadrícula", "Grid Info": "Información de la cuadrícula", "Gritty Street": "Calle cruda", @@ -625,6 +632,8 @@ "Image": "Imagen", "Import": "Importar", "Import Date": "Fecha de importación", + "Import Lightroom Catalog": "Importar catálogo de Lightroom", + "Import Lightroom Catalog…": "Importar catálogo de Lightroom…", "Import Photos": "Importar fotos", "Import Photos…": "Importar fotos…", "Import Point Curve Presets": "Importar ajustes preestablecidos de curva de puntos", @@ -640,7 +649,10 @@ "Imported": "Importadas", "Imported Photos": "Fotos importadas", "Importing": "Importando", + "Importing Lightroom Catalog": "Importando catálogo de Lightroom", "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "La importación busca fotos en el origen; no añade la carpeta a Local (Local → Explorar carpeta… permite trabajar en una carpeta sin importar).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "En Cuadrícula de fotos y Cuadrícula de cuadrados: marcar como seleccionada y avanzar", + "In Photo Grid and Square Grid: expand or collapse the stack": "En Cuadrícula de fotos y Cuadrícula de cuadrados: expandir o contraer la pila", "In Recently Deleted": "En Eliminadas recientemente", "In library": "En la biblioteca", "In memory — nothing is saved": "En memoria: no se guarda nada", @@ -656,7 +668,9 @@ "Info Overlay: File": "Superposición de información: archivo", "Info overlay": "Superposición de información", "Insert at the cursor": "Insertar en el cursor", + "Inspect Lightroom Catalog": "Inspeccionar catálogo de Lightroom", "Inspect Photo": "Inspeccionar foto", + "Inspecting Lightroom Catalog": "Inspeccionando catálogo de Lightroom", "Install the SAM 3 Model": "Instalar el modelo SAM 3", "Instant ’70s": "Instantánea de los 70", "Interface": "Interfaz", @@ -721,6 +735,9 @@ "LightCraft on GitHub": "LightCraft en GitHub", "LightCraft website": "Sitio web de LightCraft", "LightCraft — Second Window": "LightCraft: segunda ventana", + "Lightroom Classic Catalog": "Catálogo de Lightroom Classic", + "Lightroom import cancelled": "Importación de Lightroom cancelada", + "Lightroom inspection cancelled": "Inspección de Lightroom cancelada", "Lights": "Claros", "Limit file size (KB, 0 = off)": "Limitar tamaño de archivo (KB, 0 = desactivado)", "Line delta": "Diferencia de líneas", @@ -876,6 +893,7 @@ "No filter presets yet": "Aún no hay ajustes preestablecidos de filtro", "No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).", "No masks yet. Choose a mask type above.": "Aún no hay máscaras. Elegir un tipo de máscara arriba.", + "No matching commands": "Ningún comando coincide", "No matching photos": "No hay fotos coincidentes", "No metadata found": "No se encontraron metadatos", "No model credits were built into this copy.": "Esta copia no incluye créditos de modelos.", @@ -999,6 +1017,7 @@ "Presets (Shift+P)": "Ajustes preestablecidos (Mayús+P)", "Presets and profiles": "Ajustes preestablecidos y perfiles", "Presets ▾": "Ajustes preestablecidos ▾", + "Press keys…": "Pulsar teclas…", "Preview": "Vista previa", "Preview Build Progress": "Progreso de generación de vistas previas", "Preview only": "Solo vista previa", @@ -1042,6 +1061,16 @@ "Radius": "Radio", "Random": "Aleatorio", "Range": "Rango", + "Rate ★": "Valorar con ★", + "Rate ★ and advance": "Valorar con ★ y avanzar", + "Rate ★★": "Valorar con ★★", + "Rate ★★ and advance": "Valorar con ★★ y avanzar", + "Rate ★★★": "Valorar con ★★★", + "Rate ★★★ and advance": "Valorar con ★★★ y avanzar", + "Rate ★★★★": "Valorar con ★★★★", + "Rate ★★★★ and advance": "Valorar con ★★★★ y avanzar", + "Rate ★★★★★": "Valorar con ★★★★★", + "Rate ★★★★★ and advance": "Valorar con ★★★★★ y avanzar", "Rating": "Valoración", "Rating cleared": "Valoración eliminada", "Ratings & flags": "Valoraciones y marcas", @@ -1055,6 +1084,7 @@ "Read mode": "Modo de lectura", "Read the SAM License": "Leer la licencia de SAM", "Reading": "Leyendo", + "Reading Lightroom catalog…": "Leyendo catálogo de Lightroom…", "Reading folder…": "Leyendo carpeta…", "Real name": "Nombre real", "Recent": "Recientes", @@ -1068,6 +1098,7 @@ "Red Primary": "Rojo primario", "Red Saturation": "Saturación roja", "Red channel": "Canal rojo", + "Red label": "Etiqueta roja", "Red/Cyan Fringe": "Franja roja/cian", "Redo": "Rehacer", "Reference": "Referencia", @@ -1108,6 +1139,7 @@ "Remove from Target Album": "Quitar del álbum de destino", "Remove from survey": "Quitar de Encuesta", "Remove location info": "Quitar información de ubicación", + "Remove shortcut": "Quitar método abreviado", "Remove this face box (undo with Edit ▸ Undo)": "Quitar este recuadro de cara (deshacer con Editar ▸ Deshacer)", "Remove this filter": "Quitar este filtro", "Remove this group": "Quitar este grupo", @@ -1131,7 +1163,9 @@ "Rendering…": "Renderizando…", "Reorder Masks": "Reordenar máscaras", "Reset": "Restablecer", + "Reset All": "Restablecer todo", "Reset All Curves": "Restablecer todas las curvas", + "Reset All Keyboard Shortcuts": "Restablecer todos los métodos abreviados de teclado", "Reset Before to Import State": "Restablecer Antes al estado de importación", "Reset Control": "Restablecer control", "Reset Crop": "Restablecer recorte", @@ -1146,6 +1180,7 @@ "Resize to": "Cambiar tamaño a", "Resolution (ppi)": "Resolución (ppp)", "Restore": "Restaurar", + "Restore (no shortcut)": "Restaurar (sin método abreviado)", "Restore Library from Backup…": "Restaurar biblioteca desde copia de seguridad…", "Restore Version": "Restaurar versión", "Restore keeps their edits; Import as new deletes the old record first.": "Restaurar conserva sus ediciones; Importar como nuevas elimina antes el registro anterior.", @@ -1192,6 +1227,7 @@ "Save as set": "Guardar como conjunto", "Save as…": "Guardar como…", "Save preset": "Guardar ajuste preestablecido", + "Saving Lightroom import index…": "Guardando índice de importación de Lightroom…", "Scale": "Escala", "Scan failed": "Error en la exploración", "Scanning": "Explorando", @@ -1199,6 +1235,7 @@ "Screen": "Pantalla", "Search": "Buscar", "Search Photos": "Buscar fotos", + "Search commands or keys": "Buscar comandos o teclas", "Search…": "Buscar…", "Seasons": "Estaciones", "Second Window": "Segunda ventana", @@ -1229,6 +1266,7 @@ "Set Develop Values": "Definir valores de revelado", "Set Flag": "Definir marca", "Set Image Zoom and Pan": "Definir zoom y desplazamiento de la imagen", + "Set Keyboard Shortcut": "Definir método abreviado de teclado", "Set Mask Adjustments": "Definir ajustes de máscara", "Set Point Curve": "Definir curva de puntos", "Set Profile": "Definir perfil", @@ -1278,6 +1316,7 @@ "Show Pins": "Mostrar chinchetas", "Show Source": "Mostrar origen", "Show Thumbnails": "Mostrar miniaturas", + "Show commands without a shortcut": "Mostrar comandos sin método abreviado", "Show destination gamut warning": "Mostrar aviso de gama de destino", "Show display gamut warning": "Mostrar aviso de gama de la pantalla", "Show file names": "Mostrar nombres de archivo", @@ -1382,6 +1421,7 @@ "Text direction": "Dirección del texto", "Texture": "Textura", "That includes the photos in the folders inside it.": "Esto incluye las fotos de las carpetas que contiene.", + "That key can't be used as a shortcut": "Esa tecla no se puede usar como método abreviado", "The SAM 3 model is installed: Object and Describe masks are ready.": "El modelo SAM 3 está instalado: las máscaras Objeto y Describir están listas.", "The download didn't work:": "La descarga no funcionó:", "The number at the end of the original name (IMG_0042 → 0042)": "El número al final del nombre original (IMG_0042 → 0042)", @@ -1497,6 +1537,7 @@ "Yellow Hue": "Tono amarillo", "Yellow Luminance": "Luminancia amarilla", "Yellow Saturation": "Saturación amarilla", + "Yellow label": "Etiqueta amarilla", "Yes": "Sí", "You can close this: the download continues, and resumes if interrupted.": "Esta ventana se puede cerrar: la descarga continúa y se reanuda si se interrumpe.", "Your Name": "Nombre y apellidos", From 9b0d0fd05f283900cc3f71d3256924b7f26b35eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:50:04 +0000 Subject: [PATCH 22/25] Parity summary regenerated (cargo xtask parity --write) Main's summary block had a duplicated pair of total lines left from an earlier merge; the regenerated one counts #373 and #218's rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- docs/parity.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 2fcdb99a..165f64c8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -26,8 +26,8 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 41 | 2 | 5 | 1 | 27/28 (96%) | 13/14 (93%) | -| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) | +| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | +| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | @@ -46,14 +46,11 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | -| **Total** | 388 | 35 | 86 | 37 | 193/200 (97%) | 139/149 (93%) | +| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | +| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | +| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 509 in-scope rows — P0 98.2% of 200 · P1 95.6% of 149 · P2 41.6% of 160. -| Lightroom Classic extras | 32 | 10 | 39 | 9 | — | 22/23 (96%) | -| **Total** | 390 | 34 | 86 | 37 | 194/200 (97%) | 140/150 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 510 in-scope rows — P0 98.5% of 200 · P1 95.7% of 150 · P2 41.6% of 160. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160. ## Top gaps From 4af709995f05b90c115c89305e71f8287e21dd35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:52:35 +0000 Subject: [PATCH 23/25] Fix #344: the import review checks or unchecks a file type at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With several file types among the candidates, the Import Photos review shows a toggle per type (ARW · 120, JPG · 120): unchecking JPG leaves the JPEGs kept beside the raws out of the import, checking a type checks its importable files (duplicates stay unchecked). JPEG/JPG and TIFF/TIF are one type; a file whose probe named no format goes by its extension. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- crates/ui-egui/locales/de.json | 3 ++ crates/ui-egui/locales/es.json | 3 ++ crates/ui-egui/locales/ja.json | 3 ++ crates/ui-egui/locales/pt-br.json | 3 ++ crates/ui-egui/locales/ru.json | 3 ++ crates/ui-egui/locales/zh-hans.json | 3 ++ crates/ui-egui/locales/zh-hant.json | 3 ++ crates/ui-egui/src/import.rs | 80 +++++++++++++++++++++++++++++ docs/parity.md | 2 +- docs/whats-new.md | 2 + 10 files changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index ed685f81..49712c7b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -208,6 +208,7 @@ "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Unzulässige Zeichen in Dateinamen (/ \\ : * ? \" < > |) werden zu -; ein vorhandener Name erhält -1, -2….", "Check All": "Alle auswählen", "Check Output Path": "Ausgabepfad prüfen", + "Check the files of this type": "Dateien dieses Typs auswählen", "Checking names…": "Namen werden geprüft…", "Choose Another Library…": "Andere Bibliothek auswählen…", "Choose Edit Settings to Copy": "Zu kopierende Bearbeitungseinstellungen auswählen", @@ -511,6 +512,7 @@ "File Size": "Dateigröße", "File Type": "Dateityp", "File name": "Dateiname", + "File types:": "Dateitypen:", "Filename": "Dateiname", "Fill": "Ausfüllen", "Fill Edges": "Ränder füllen", @@ -1485,6 +1487,7 @@ "Turn on Include subfolders, or pick another folder under Local": "Aktiviere „Unterordner einbeziehen“ oder wähle unter „Lokal“ einen anderen Ordner", "Type": "Typ", "Uncheck All": "Alle abwählen", + "Uncheck the files of this type": "Auswahl der Dateien dieses Typs aufheben", "Undo": "Rückgängig", "Unedited": "Unbearbeitet", "Unedited raws": "Unbearbeitete RAW-Fotos", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index a4eeffe1..794877ef 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -188,6 +188,7 @@ "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Los caracteres no permitidos en nombres de archivo (/ \\ : * ? \" < > |) se convierten en -; un nombre existente recibe -1, -2….", "Check All": "Marcar todo", "Check Output Path": "Comprobar ruta de salida", + "Check the files of this type": "Marcar los archivos de este tipo", "Checking names…": "Comprobando nombres…", "Choose Another Library…": "Elegir otra biblioteca…", "Choose Edit Settings to Copy": "Elegir ajustes de edición para copiar", @@ -478,6 +479,7 @@ "File Size": "Tamaño del archivo", "File Type": "Tipo de archivo", "File name": "Nombre de archivo", + "File types:": "Tipos de archivo:", "Filename": "Nombre de archivo", "Fill": "Rellenar", "Fill Edges": "Rellenar bordes", @@ -1421,6 +1423,7 @@ "Turn on Include subfolders, or pick another folder under Local": "Activar Incluir subcarpetas o elegir otra carpeta en Local", "Type": "Tipo", "Uncheck All": "Desmarcar todo", + "Uncheck the files of this type": "Desmarcar los archivos de este tipo", "Undo": "Deshacer", "Unedited": "Sin editar", "Unedited raws": "RAW sin editar", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index cbde1b82..0a631276 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -190,6 +190,7 @@ "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "ファイル名に使えない文字(/ \\ : * ? \" < > |)は - に置き換え、既存の名前と重なる場合は -1、-2… を付けます", "Check All": "すべて選択", "Check Output Path": "書き出し先のパスを確認", + "Check the files of this type": "この種類のファイルをすべてチェック", "Checking names…": "名前を確認中…", "Choose Edit Settings to Copy": "コピーする編集設定を選択", "Choose Edit Settings to Copy…": "コピーする編集設定を選択…", @@ -458,6 +459,7 @@ "File Path": "ファイルの場所", "File Size": "ファイルサイズ", "File Type": "ファイルの種類", + "File types:": "ファイルの種類:", "Filename": "ファイル名", "Fill": "画面いっぱい", "Film": "フィルム", @@ -1345,6 +1347,7 @@ "Turn Off": "無効にする", "Turn on Include subfolders, or pick another folder under Local": "サブフォルダを含める設定を有効にするか、ローカルから別のフォルダを選択してください", "Uncheck All": "すべて選択解除", + "Uncheck the files of this type": "この種類のファイルのチェックをすべて外す", "Undo": "取り消す", "Unedited": "未編集", "Unedited raws": "未編集のRAW", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9ae6bfe3..b5dfc254 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -195,6 +195,7 @@ "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Caracteres não permitidos em nomes de arquivo (/ \\ : * ? \" < > |) viram -; um nome já existente recebe -1, -2….", "Check All": "Marcar todos", "Check Output Path": "Verificar caminho de saída", + "Check the files of this type": "Marcar os arquivos deste tipo", "Checking names…": "Verificando nomes…", "Choose Another Library…": "Escolher outra biblioteca…", "Choose Edit Settings to Copy": "Escolher ajustes de edição a copiar", @@ -485,6 +486,7 @@ "File Size": "Tamanho do arquivo", "File Type": "Tipo de arquivo", "File name": "Nome do arquivo", + "File types:": "Tipos de arquivo:", "Filename": "Nome do arquivo", "Fill": "Preencher", "Fill Edges": "Preencher bordas", @@ -1431,6 +1433,7 @@ "Turn on Include subfolders, or pick another folder under Local": "Ative Incluir subpastas ou escolha outra pasta em Local", "Type": "Tipo", "Uncheck All": "Desmarcar tudo", + "Uncheck the files of this type": "Desmarcar os arquivos deste tipo", "Undo": "Desfazer", "Unedited": "Não editada", "Unedited raws": "RAWs não editados", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 61d35e48..ef0e2995 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -155,6 +155,7 @@ "Catalog Statistics": "Статистика каталога", "Check All": "Отметить все", "Check Output Path": "Проверить путь вывода", + "Check the files of this type": "Отметить файлы этого типа", "Choose Another Library…": "Выбрать другую библиотеку…", "Choose Edit Settings to Copy": "Выбрать настройки для копирования", "Choose Edit Settings to Copy…": "Выбрать настройки для копирования…", @@ -395,6 +396,7 @@ "File Path": "Путь к файлу", "File Size": "Размер файла", "File name": "Имя файла", + "File types:": "Типы файлов:", "Fill Edges": "Заполнить края", "Film": "Плёнка", "Filmstrip": "Лента", @@ -1177,6 +1179,7 @@ "Turn on Include subfolders, or pick another folder under Local": "Включите «Включая вложенные папки» или выберите другую папку в «Локальном»", "Type": "Тип", "Uncheck All": "Снять все отметки", + "Uncheck the files of this type": "Снять отметку с файлов этого типа", "Undo": "Отменить", "Unedited": "Без правок", "Unflag": "Снять флаг", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index fe2e8672..226f38f0 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -194,6 +194,7 @@ "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "文件名中不允许的字符(/ \\ : * ? \" < > |)会替换为 -;与现有名称重复时添加 -1、-2…。", "Check All": "全部选中", "Check Output Path": "检查输出路径", + "Check the files of this type": "勾选此类型的所有文件", "Checking names…": "正在检查名称…", "Choose Another Library…": "选择其他图库…", "Choose Edit Settings to Copy": "选择要复制的编辑设置", @@ -481,6 +482,7 @@ "File Path": "文件路径", "File Size": "文件大小", "File Type": "文件类型", + "File types:": "文件类型:", "Filename": "文件名", "Fill": "填满", "First": "最早", @@ -1418,6 +1420,7 @@ "Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹", "Type": "类型", "Uncheck All": "全部取消选中", + "Uncheck the files of this type": "取消勾选此类型的所有文件", "Undo": "撤销", "Unedited": "未编辑", "Unedited raws": "未编辑的 RAW", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 4a1050a0..2056534a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -194,6 +194,7 @@ "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "檔名中不允許的字元(/ \\ : * ? \" < > |)會改為 -;與現有名稱重複時加上 -1、-2…。", "Check All": "全部勾選", "Check Output Path": "檢驗輸出路徑", + "Check the files of this type": "勾選此類型的所有檔案", "Checking names…": "正在檢查名稱…", "Choose Another Library…": "選擇其他照片庫…", "Choose Edit Settings to Copy": "選擇要複製的編輯設定", @@ -481,6 +482,7 @@ "File Path": "檔案路徑", "File Size": "檔案大小", "File Type": "檔案類型", + "File types:": "檔案類型:", "Filename": "檔名", "Fill": "填滿", "First": "最早", @@ -1418,6 +1420,7 @@ "Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾", "Type": "類型", "Uncheck All": "全部取消勾選", + "Uncheck the files of this type": "取消勾選此類型的所有檔案", "Undo": "復原", "Unedited": "未編輯", "Unedited raws": "未編輯的 RAW", diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 0e330722..1fe85290 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -87,6 +87,22 @@ pub struct ImportDialog { pub last_clicked: Option, } +/// A candidate's file type, as the review groups them: its format, or its extension when the +/// probe named none (`JPEG` and `JPG` are one type). +fn file_type(c: &ImportCandidate) -> String { + let f = if c.format.trim().is_empty() { + std::path::Path::new(&c.path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default() + } else { + c.format.clone() + }; + match f.trim().to_uppercase().as_str() { + "JPEG" => "JPG".into(), + "TIFF" => "TIF".into(), + "" => "?".into(), + other => other.to_string(), + } +} + impl ImportDialog { pub fn new(candidates: Vec) -> Self { let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); @@ -95,6 +111,29 @@ impl ImportDialog { pub fn importable(&self, i: usize) -> bool { self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty())) } + /// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each. + pub fn file_types(&self) -> Vec<(String, usize)> { + let mut types: std::collections::BTreeMap = Default::default(); + for c in &self.candidates { + *types.entry(file_type(c)).or_default() += 1; + } + types.into_iter().collect() + } + /// Any file of type `ty` is checked. + pub fn type_checked(&self, ty: &str) -> bool { + self.candidates.iter().zip(&self.checked).any(|(c, on)| *on && file_type(c) == ty) + } + /// Check (the importable ones) or uncheck every file of type `ty`: the raws only, say, and not + /// the JPEGs kept beside them (issue #344). + pub fn check_type(&mut self, ty: &str, on: bool) { + for i in 0..self.candidates.len() { + let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty); + let importable = self.importable(i); + if same && let Some(c) = self.checked.get_mut(i) { + *c = on && importable; + } + } + } /// Candidate `i` is a file whose photo is in Recently Deleted. pub fn is_trashed(&self, i: usize) -> bool { self.trashed.get(i).copied().unwrap_or(false) @@ -819,6 +858,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } }); }); + // with more than one file type: a toggle per type checks or unchecks all of its files + let types = d.file_types(); + if types.len() > 1 { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim)); + for (ty, count) in &types { + let on = d.type_checked(ty); + let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on) + .on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" })); + if r.clicked() { + d.check_type(ty, !on); + } + } + }); + } // candidate grid let cell = 116.0; let avail = ui.available_width(); @@ -1390,6 +1444,32 @@ mod tests { ImportDialog::new(candidates) } + /// A toggle per file type: the raws only, without the JPEGs beside them (issue #344). + #[test] + fn file_types_check_and_uncheck_together() { + let mut candidates: Vec = + ["a.ARW", "a.JPG", "b.arw", "b.jpeg", "c.png"].iter().map(|p| ImportCandidate { path: p.to_string(), ..Default::default() }).collect(); + candidates[2].format = "ARW".into(); + candidates[3].format = "JPEG".into(); + candidates[2].duplicate = Some("content".into()); + let mut d = ImportDialog::new(candidates); + assert_eq!(d.file_types(), [("ARW".to_string(), 2), ("JPG".to_string(), 2), ("PNG".to_string(), 1)]); + assert!(d.type_checked("JPG")); + d.check_type("JPG", false); + assert_eq!(d.checked, [true, false, false, false, true]); + assert!(!d.type_checked("JPG")); + d.check_type("PNG", false); + d.check_type("ARW", true); + // the duplicate stays unchecked: it isn't importable + assert_eq!(d.checked, [true, false, false, false, false]); + d.check_type("JPG", true); + assert_eq!(d.checked, [true, true, false, true, false]); + assert_eq!(d.selected_paths(), ["a.ARW", "a.JPG", "b.jpeg"]); + // an unknown type changes nothing + d.check_type("CR3", false); + assert_eq!(d.checked, [true, true, false, true, false]); + } + #[test] fn click_toggles_one_candidate() { let mut d = dialog(); diff --git a/docs/parity.md b/docs/parity.md index b807e48d..ca7b8ebb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -127,7 +127,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) | diff --git a/docs/whats-new.md b/docs/whats-new.md index fc5f2f04..dd12ca7c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -47,6 +47,8 @@ (with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted. - The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks or unchecks a range of photos (issue #338). +- When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`): + import only the raws and leave the JPEGs beside them (issue #344). ### Languages - The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English, From 1e6f0e28e19d4f1b478b8f9bbe8197f51f37f297 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:53:11 +0000 Subject: [PATCH 24/25] Batch fix: the Lightroom catalog dialog filter goes through add_filter_nocase #218's catalog picker called rfd's add_filter, which #362's guard test (no_dialog_bypasses_the_helper) forbids: on Linux the filter would hide CATALOG.LRCAT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- apps/lightcraft/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 37b17f37..3ad15bb5 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -286,7 +286,7 @@ fn services() -> Services { pick_lightroom_catalog: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) - .add_filter(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) })), From 41e67e1edd778d70e03353fb34b76350fec0ddb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:56:35 +0000 Subject: [PATCH 25/25] Batch fix: the CLI snapshot test settles before its screenshots Under load (the full workspace suite) the photo grid was captured before its thumbnails rendered, darker than the export dialog dimmed over them, and the dimming comparison failed; it passes alone. Each screenshot now follows ui.settle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- apps/lightcraft-cli/tests/cli.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 21fa6310..d1b178e4 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -167,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { std::fs::write( &script, format!( - "# comment\n{}\n{}\n{}\n{}\n", + "# comment\n{}\n{}\n{}\n{}\n{}\n{}\n", json!({"method": "ui.set", "params": {"view": "photoGrid"}}), + // (settled: under load a grid shot taken before its thumbnails is darker than the + // dimmed export dialog over them, and the comparison below fails) + json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}), json!({"method": "ui.screenshot"}), json!({"method": "engine.execute", "params": {"command": "dialog.export"}}), + json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}), json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}), ), ) @@ -181,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { .unwrap(); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); - assert_eq!(replies.len(), 4); + assert_eq!(replies.len(), 6); assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}"); for (p, dimmed) in [(&a, false), (&b, true)] { let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();