diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 82e2bcd2..156ac667 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -46,6 +46,7 @@ mod lightroom_archive; pub mod lightroom_catalog; pub mod lightroom_folder; pub mod lightroom_job; +mod lightroom_smart; mod lightroom_sqlite; pub mod logging; pub mod media; diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index 0dceb421..ac2eee14 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -523,6 +523,7 @@ mod tests { collections: Vec::new(), members: Vec::new(), collection_content: Vec::new(), + stacks: Vec::new(), warnings: Vec::new(), } } @@ -545,6 +546,7 @@ mod tests { 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"))])], + stacks: Vec::new(), warnings: vec!["one warning".into()], } } diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 2519057a..2e4eda02 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -154,6 +154,9 @@ pub struct CatalogImport { pub collections: Vec, pub members: Vec, pub collection_content: Vec, + /// `AgLibraryFolderStackImage` rows (`stack`, `image`, `position`, `collapsed`). + #[serde(default)] + pub stacks: Vec, pub warnings: Vec, } @@ -368,8 +371,8 @@ pub fn read(path: &Path) -> Result { } 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); + // 14 catalog tables plus one validation stage; ImportJob adds one unit per original path. + total.store(15, Ordering::Relaxed); done.store(0, Ordering::Relaxed); ensure_read_active(cancel)?; let before = std::fs::metadata(path).map_err(|e| e.to_string())?; @@ -474,6 +477,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, collections: optional_table("AgLibraryCollection", &mut warnings)?, members: optional_table("AgLibraryCollectionImage", &mut warnings)?, collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?, + stacks: optional_table("AgLibraryFolderStackImage", &mut warnings)?, warnings, }; for image in table("Adobe_images", true)? { @@ -846,6 +850,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex for members in grouped_members.values_mut() { members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); } + let smart_content: HashMap = data + .collection_content + .iter() + .filter(|r| text(r, "owningModule") == "ag.library.smart_collection") + .map(|r| (number(r, "collection"), text(r, "content"))) + .collect(); + let (mut smart_albums, mut static_smart, mut smart_kept) = (0usize, 0usize, 0usize); let mut ordered: Vec<_> = data.collections.iter().collect(); ordered.sort_by_key(|r| { let mut depth = 0; @@ -861,43 +872,112 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex 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 { - 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 is_smart = text(collection, "creationId").contains("smart"); + let rules = if is_smart { + Some(match smart_content.get(&number(collection, "id_local")) { + Some(c) => crate::lightroom_smart::smart_rules(c), + None => Err("smart rules missing".into()), + }) + } else { + None + }; + let members: Vec = grouped_members + .get(&number(collection, "id_local")) + .into_iter() + .flatten() + .filter_map(|r| ids.get(&number(r, "image")).copied()) + .collect(); + // Reimport branches on the existing album's kind: its name/hierarchy and (for a smart album) + // its possibly user-edited rules are kept; a regular album only gains new members. + let existing = s.catalog.album(id).map(|a| (a.folder, a.is_smart(), a.photos.clone())); + match existing { + Some((true, _, _)) => {} + Some((false, true, _)) => { + if is_smart { + smart_albums += 1; + smart_kept += 1; + } + } + Some((false, false, old)) => { + if let Some(rules) = &rules { + static_smart += 1; + match rules { + Ok(_) => warnings.push(format!( + "{}: an earlier import created a static album; it stays static (delete it and reimport to get a smart album)", + album.name + )), + Err(why) => warnings.push(format!("{}: {why}; imported current membership (rules kept in archive)", album.name)), + } + } + let mut photos = old; let mut seen: HashSet<_> = photos.iter().copied().collect(); - for photo in album.photos { + for photo in members { if seen.insert(photo) { photos.push(photo); } } ops.push(Op::SetAlbumPhotos { id, photos }); } - } else { - ops.push(Op::AddAlbum { album }); + None => { + match rules { + Some(Ok(rules)) => { + album.smart = Some(Box::new(lightcraft_catalog::Filter { rule_set: Some(rules), ..Default::default() })); + smart_albums += 1; + } + Some(Err(why)) => { + warnings.push(format!("{}: {why}; imported current membership (rules kept in archive)", album.name)); + static_smart += 1; + } + None => {} + } + if !album.folder && !album.is_smart() { + album.photos = members; + } + ops.push(Op::AddAlbum { album }); + } } } + let stacks = stack_ops(s, &data, &ids, &mut ops, &mut warnings); if !ops.is_empty() { s.commit("Import Lightroom Catalog", Op::Batch { ops })?; } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"smartAlbums":smart_albums,"smartAsStatic":static_smart,"smartKept":smart_kept,"stacks":stacks,"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } +/// Lightroom folder stacks → stacks, top (position 1) first. Photos already stacked in the library +/// keep their stack; a stack that would have fewer than two photos is skipped. +fn stack_ops(s: &mut crate::Session, data: &CatalogImport, ids: &HashMap, ops: &mut Vec, warnings: &mut Vec) -> usize { + let mut groups: BTreeMap> = BTreeMap::new(); + for r in &data.stacks { + let Some(photo) = ids.get(&number(r, "image")).copied() else { continue }; + let position = r.get("position").and_then(Value::as_f64).filter(|p| p.is_finite()).unwrap_or(f64::MAX); + groups.entry(number(r, "stack")).or_default().push((position, photo, number(r, "collapsed") != 0)); + } + let mut taken: HashSet = HashSet::new(); + let mut made = 0; + for (stack, mut members) in groups { + members.sort_by(|a, b| a.0.total_cmp(&b.0)); + let collapsed = members.iter().any(|m| m.2); + let stacked = members.iter().any(|m| s.catalog.stack_of(m.1).is_some()); + let photos: Vec = members.into_iter().map(|m| m.1).filter(|p| s.catalog.stack_of(*p).is_none() && taken.insert(*p)).collect(); + if stacked && photos.len() < 2 { + continue; // already stacked by an earlier import + } + if photos.len() < 2 { + warnings.push(format!("stack {stack}: fewer than two photos left to stack; skipped")); + continue; + } + let id = s.catalog.alloc_stack_id(); + ops.push(Op::AddStack { stack: lightcraft_catalog::Stack { id, photos, collapsed } }); + made += 1; + } + made +} + #[cfg(test)] mod tests { use super::*; @@ -935,6 +1015,7 @@ mod tests { row(json!({"collection":10,"image":1,"positionInCollection":"b"})), ], collection_content: Vec::new(), + stacks: Vec::new(), warnings: Vec::new(), } } @@ -966,6 +1047,39 @@ mod tests { assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact } #[test] + fn smart_collections_become_smart_albums_and_stacks_are_kept() { + let mut s = crate::Session::new(); + let mut data = sample(); + data.collections.push(row(json!({"id_local":11,"name":"Best","creationId":"com.adobe.ag.library.smart_collection"}))); + data.collections.push(row(json!({"id_local":12,"name":"Odd","creationId":"com.adobe.ag.library.smart_collection"}))); + data.members.push(row(json!({"collection":12,"image":1}))); + data.collection_content = vec![ + row(json!({"collection":11,"owningModule":"ag.library.smart_collection", + "content":"s = { { criteria = \"rating\", operation = \">=\", value = 4 }, combine = \"intersect\" }"})), + row(json!({"collection":12,"owningModule":"ag.library.smart_collection", + "content":"s = { { criteria = \"collection\", operation = \"all\", value = \"x\" } }"})), + ]; + data.stacks = + vec![row(json!({"stack":5,"image":1,"position":2.0,"collapsed":1})), row(json!({"stack":5,"image":2,"position":1.0,"collapsed":1}))]; + let r = apply(&mut s, data.clone(), false).unwrap(); + assert_eq!(r["smartAlbums"], 1); + assert_eq!(r["smartAsStatic"], 1); + assert_eq!(r["stacks"], 1); + let best = s.catalog.albums().find(|a| a.name == "Best").unwrap(); + assert!(best.is_smart()); + let odd = s.catalog.albums().find(|a| a.name == "Odd").unwrap(); + assert!(!odd.is_smart() && odd.photos.len() == 1); + let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap().id; + let stack = s.catalog.stacks().next().unwrap(); + assert_eq!(stack.photos.first(), Some(©)); + assert!(stack.collapsed); + // Reimport neither duplicates the stack nor warns about it. + let r = apply(&mut s, data, false).unwrap(); + assert_eq!(r["stacks"], 0); + assert_eq!(s.catalog.stacks().count(), 1); + assert!(!r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("stack"))), "{}", r["warnings"]); + } + #[test] fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { let mut s = crate::Session::new(); let data = sample(); @@ -1227,6 +1341,61 @@ mod tests { } } + fn smart_sample(content: &str) -> CatalogImport { + let mut data = sample(); + data.collections.push(row(json!({"id_local":11,"name":"Best","creationId":"com.adobe.ag.library.smart_collection"}))); + data.members.push(row(json!({"collection":11,"image":1}))); + data.collection_content = vec![row(json!({"collection":11,"owningModule":"ag.library.smart_collection","content":content}))]; + data + } + const SUPPORTED: &str = "s = { { criteria = \"rating\", operation = \">=\", value = 4 }, combine = \"intersect\" }"; + const UNSUPPORTED: &str = "s = { { criteria = \"collection\", operation = \"all\", value = \"x\" } }"; + #[test] + fn reimport_keeps_an_earlier_static_album_static_and_merges_its_membership() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport_keeps_an_earlie-{}", std::process::id())); + let mut s = crate::Session::new(); + s.open_library(&dir, false).unwrap(); + let r = apply(&mut s, smart_sample(UNSUPPORTED), false).unwrap(); + assert_eq!((r["smartAlbums"].as_u64(), r["smartAsStatic"].as_u64()), (Some(0), Some(1))); + let albums = s.catalog.albums().count(); + let mut data = smart_sample(SUPPORTED); + data.members.push(row(json!({"collection":11,"image":2}))); + let r = apply(&mut s, data.clone(), false).unwrap(); + assert_eq!((r["smartAlbums"].as_u64(), r["smartAsStatic"].as_u64(), r["smartKept"].as_u64()), (Some(0), Some(1), Some(0))); + assert!( + r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("Best:") && w.contains("stays static"))), + "{}", + r["warnings"] + ); + let best = s.catalog.albums().find(|a| a.name == "Best").unwrap(); + assert!(!best.is_smart()); + assert_eq!(best.photos.len(), 2, "new membership merged"); + // Repeating the import creates no duplicates. + apply(&mut s, data, false).unwrap(); + assert_eq!(s.catalog.albums().count(), albums); + assert_eq!(s.catalog.albums().find(|a| a.name == "Best").unwrap().photos.len(), 2); + s.close_library().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn reimport_preserves_user_edited_smart_rules() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport_preserves_user_-{}", std::process::id())); + let mut s = crate::Session::new(); + s.open_library(&dir, false).unwrap(); + let r = apply(&mut s, smart_sample(SUPPORTED), false).unwrap(); + assert_eq!(r["smartAlbums"], 1); + let albums = s.catalog.albums().count(); + let id = s.catalog.albums().find(|a| a.name == "Best").unwrap().id; + let edited = lightcraft_catalog::Filter { rating: 2, ..Default::default() }; + s.commit("Edit rules", Op::SetAlbumRules { id, rules: Box::new(edited.clone()) }).unwrap(); + let r = apply(&mut s, smart_sample(SUPPORTED), false).unwrap(); + assert_eq!((r["smartAlbums"].as_u64(), r["smartAsStatic"].as_u64(), r["smartKept"].as_u64()), (Some(1), Some(0), Some(1))); + let best = s.catalog.album(id).unwrap(); + assert_eq!(best.smart.as_deref().map(|f| f.rating), Some(2)); + assert_eq!(s.catalog.albums().count(), albums); + s.close_library().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } #[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())); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index ffd6fc18..3ad92c38 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -332,6 +332,7 @@ mod tests { collections: Vec::new(), members: Vec::new(), collection_content: Vec::new(), + stacks: Vec::new(), warnings: Vec::new(), } } @@ -375,6 +376,7 @@ mod tests { collections: Vec::new(), members: Vec::new(), collection_content: Vec::new(), + stacks: Vec::new(), warnings: Vec::new(), }, normal: Prepared::default(), diff --git a/crates/engine/src/lightroom_smart.rs b/crates/engine/src/lightroom_smart.rs new file mode 100644 index 00000000..fbde7eac --- /dev/null +++ b/crates/engine/src/lightroom_smart.rs @@ -0,0 +1,232 @@ +//! Lightroom Classic smart-collection rules → smart-album rules, where they can be expressed. +//! +//! A smart collection's rules are a Lua table in `AgLibraryCollectionContent.content` +//! (`owningModule = "ag.library.smart_collection"`): positional criteria tables +//! `{ criteria = "rating", operation = ">=", value = 3 }` (or nested groups with their own +//! `combine`) and a `combine` of `"intersect"` (all), `"union"` (any) or `"exclude"` (none). +//! Anything we can't express exactly returns `Err` with the reason; the caller then keeps the +//! collection's current membership as a regular album and archives the original rules. +//! +//! Sources: own design, from the structure of the Lua literal as described in public prose +//! write-ups of the catalog format; no Adobe code or data. + +use lightcraft_catalog::{Match, Rule, RuleSet}; +use serde_json::{Value, json}; + +use crate::preset_import::{Lua, parse_lua}; + +const MAX_DEPTH: usize = 16; + +/// The rule set a smart collection's `content` describes, or why it can't be expressed. +pub(crate) fn smart_rules(content: &str) -> Result { + let root = parse_lua(content)?; + group(&root, 0) +} + +fn group(t: &Lua, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err("smart rules nest too deeply".into()); + } + let Lua::Table(items, _) = t else { return Err("smart rules are not a table".into()) }; + let mode = match t.get("combine").and_then(Lua::str).unwrap_or("intersect") { + "intersect" => Match::All, + "union" => Match::Any, + "exclude" => Match::None, + other => return Err(format!("unknown combine `{other}`")), + }; + let mut rules = Vec::new(); + for item in items { + if item.get("criteria").is_some() { + rules.push(criterion(item)?); + } else { + rules.push(Rule::Group { group: group(item, depth + 1)? }); + } + } + if rules.is_empty() { + return Err("smart collection has no rules".into()); + } + Ok(RuleSet { mode, rules }) +} + +fn num(v: Option<&Lua>) -> Option { + match v { + Some(Lua::Num(n)) if n.is_finite() => Some(*n), + Some(Lua::Str(s)) => s.trim().parse().ok().filter(|n: &f64| n.is_finite()), + _ => None, + } +} + +fn field(field: &str, op: &str, value: Value) -> Rule { + Rule::Field { field: field.into(), op: op.into(), value } +} + +fn number_op(op: &str) -> Option<&'static str> { + Some(match op { + "==" => "is", + "!=" => "isNot", + ">=" => "gte", + "<=" => "lte", + ">" => "gt", + "<" => "lt", + _ => return None, + }) +} + +fn criterion(c: &Lua) -> Result { + let name = c.get("criteria").and_then(Lua::str).unwrap_or_default(); + let op = c.get("operation").and_then(Lua::str).unwrap_or_default(); + let value = c.get("value"); + let unsupported = || format!("rule `{name} {op}` has no equivalent"); + match name { + "rating" | "isoSpeedRating" | "focalLength" | "aperture" => { + let target = match name { + "rating" => "rating", + "isoSpeedRating" => "iso", + "focalLength" => "focalLength", + _ => "aperture", + }; + let n = num(value).ok_or_else(unsupported)?; + if op == "in" { + let hi = num(c.get("value2")).ok_or_else(unsupported)?; + return Ok(field(target, "between", json!([n, hi]))); + } + let op = number_op(op).ok_or_else(unsupported)?; + Ok(field(target, op, json!(n))) + } + "pick" => { + let flag = match num(value).map(|n| n as i64) { + Some(1) => "pick", + Some(-1) => "reject", + Some(0) => "none", + _ => return Err(unsupported()), + }; + match op { + "==" => Ok(field("flag", "is", json!(flag))), + "!=" => Ok(field("flag", "isNot", json!(flag))), + _ => Err(unsupported()), + } + } + "labelColor" => { + let label = value.and_then(Lua::str).map(str::to_ascii_lowercase).ok_or_else(unsupported)?; + if !["red", "yellow", "green", "blue", "purple", "none"].contains(&label.as_str()) { + return Err(unsupported()); + } + match op { + "==" => Ok(field("label", "is", json!(label))), + "!=" => Ok(field("label", "isNot", json!(label))), + _ => Err(unsupported()), + } + } + "hasAdjustments" => { + let yes = match value { + Some(Lua::Bool(b)) => *b, + Some(Lua::Num(n)) => *n != 0.0, + None => true, + _ => return Err(unsupported()), + }; + match op { + "==" | "" => Ok(field("edited", "is", json!(yes))), + "!=" => Ok(field("edited", "is", json!(!yes))), + _ => Err(unsupported()), + } + } + _ => { + let target = match name { + "keywords" => "keywords", + "filename" => "fileName", + "camera" => "camera", + "lens" => "lens", + "title" => "title", + "caption" => "caption", + "copyname" | "copyName" => "copyName", + "creator" => "creator", + "copyright" => "copyright", + "city" => "city", + "country" => "country", + "location" => "location", + "state" => "state", + "fileFormat" => "format", + _ => return Err(unsupported()), + }; + text_rule(target, op, value.and_then(Lua::str).unwrap_or_default()).ok_or_else(unsupported) + } + } +} + +fn text_rule(target: &str, op: &str, value: &str) -> Option { + let words: Vec<&str> = value.split_whitespace().collect(); + let keywords = target == "keywords"; + let rule = match op { + "empty" => field(target, "isEmpty", Value::Null), + "notEmpty" => field(target, "isNotEmpty", Value::Null), + _ if words.is_empty() => return None, + // Every word must appear: what "contains" already means here. + "all" | "words" => field(target, "contains", json!(value)), + "any" if words.len() == 1 => field(target, "contains", json!(value)), + "any" => Rule::Group { group: RuleSet { mode: Match::Any, rules: words.iter().map(|w| field(target, "contains", json!(w))).collect() } }, + "noneOf" => { + Rule::Group { group: RuleSet { mode: Match::All, rules: words.iter().map(|w| field(target, "notContains", json!(w))).collect() } } + } + "beginsWith" => field(target, "startsWith", json!(value)), + "endsWith" if !keywords => field(target, "endsWith", json!(value)), + "==" => field(target, "is", json!(value)), + "!=" if !keywords => field(target, "isNot", json!(value)), + _ => return None, + }; + Some(rule) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_common_rules_and_groups() { + let set = smart_rules( + r#"s = { { criteria = "rating", operation = ">=", value = 3, value2 = 0 }, + { criteria = "pick", operation = "==", value = 1 }, + { criteria = "keywords", operation = "any", value = "italy france" }, + { { criteria = "labelColor", operation = "==", value = "red" }, + { criteria = "isoSpeedRating", operation = "in", value = 100, value2 = 800 }, + combine = "union" }, + combine = "intersect" }"#, + ) + .unwrap(); + assert_eq!(set.mode, Match::All); + assert_eq!(set.rules.len(), 4); + assert_eq!(set.rules[0], field("rating", "gte", json!(3.0))); + assert_eq!(set.rules[1], field("flag", "is", json!("pick"))); + let Rule::Group { group } = &set.rules[2] else { panic!() }; + assert_eq!(group.mode, Match::Any); + let Rule::Group { group } = &set.rules[3] else { panic!() }; + assert_eq!(group.rules[1], field("iso", "between", json!([100.0, 800.0]))); + let cat = lightcraft_catalog::Catalog::default(); + let mut tmp = cat; + let id = tmp.alloc_album_id(); + let mut album = lightcraft_catalog::Album::new(id, "s"); + album.smart = Some(Box::new(lightcraft_catalog::Filter { rule_set: Some(set), ..Default::default() })); + assert!(tmp.album_filter_problem(album.smart.as_deref().unwrap(), None).is_none()); + } + + #[test] + fn unexpressible_rules_are_reported_not_guessed() { + for bad in [ + r#"s = { { criteria = "collection", operation = "all", value = "x" }, combine = "intersect" }"#, + r#"s = { { criteria = "rating", operation = "~", value = 3 } }"#, + r#"s = { combine = "intersect" }"#, + r#"s = { { criteria = "rating", operation = ">=", value = "abc" } }"#, + "not lua at all {", + ] { + assert!(smart_rules(bad).is_err(), "{bad}"); + } + } + + #[test] + fn deep_nesting_is_bounded() { + let mut s = String::from("{ criteria = \"rating\", operation = \">=\", value = 1 }"); + for _ in 0..40 { + s = format!("{{ {s} }}"); + } + assert!(smart_rules(&s).is_err()); + } +} diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index ef6b4943..a5737264 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -535,6 +535,7 @@ mod tests { collections: Vec::new(), members: Vec::new(), collection_content: Vec::new(), + stacks: Vec::new(), warnings: vec!["warning".into()], }); assert_eq!(report["photos"], 0); diff --git a/docs/gaps.md b/docs/gaps.md index 0d250311..fcecacd5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -30,6 +30,8 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; smart collections with unmappable rules (collection, date, metadata-status criteria) stay static (smart rules and stacks now import) | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | @@ -84,6 +86,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | | 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | | 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | +| 2026-10-12 | minor | Gap 13 narrowed: smart collections and stacks now import from `.lrcat` | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 1f9deeea..f16dbcde 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -16,8 +16,8 @@ lightcraft-cli run --library "LightCraft Library" library.importLightroom path=" ``` 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 +hierarchical keywords, regular collections/collection sets, smart collections, folder stacks, +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; any saved recovery archives remain available. @@ -45,8 +45,17 @@ models, some masking/retouch fields and process-version algorithms are not repro fields are reported. Lightroom's `ProcessVersion` is not mapped: newly imported photos are on LightCraft's latest rendering process, and photos already in the library keep theirs ([process-versions.md](process-versions.md)). 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. +and original smart-collection rules. Smart collections become smart albums when every rule has an exact equivalent: rating, pick +flag, color label, has-adjustments, ISO, focal length and aperture comparisons; text rules on +keywords, filename, camera, lens, title, caption, creator, copyright, location fields and file +format (contains all / any / none, begins/ends with, is, empty); and nested match-all / any / +none groups. Any other rule (collection, dates, metadata status, …) keeps the collection as a +regular album with its current membership, and the report names the rule. On reimport an album keeps +its kind: an existing smart album keeps its current (possibly edited) rules, and a regular album +made by an earlier import stays regular and gains new members, with a warning (delete it and +reimport to get a smart album). The report counts albums by their resulting kind: +`smartAlbums`, `smartAsStatic`, `smartKept` (existing smart albums left as they were) and `stacks`. Folder stacks become stacks in Lightroom's order +and collapsed state; photos already in a stack keep it, so reimport doesn't duplicate them. Archived history and snapshots are source data, not native LightCraft history yet. A Custom white balance on a raw LightCraft develops relative to its as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvin: the catalog keeps no as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4305fb64..46027c12 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -691,7 +691,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, 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, smart collections as smart albums when every rule has an equivalent (rating, pick, label, edited, ISO/focal length/aperture, keyword/filename/camera/lens/title/caption/location text rules, nested all/any/none groups; otherwise current membership with the reason reported), folder stacks (order and collapsed state), virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | @@ -808,6 +808,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | | 2026-10-11 | patch | LR-LIB-SMARTALBUM: sidebar counts key on the library revision, so develop edits recount them only while a smart album reads develop state (issue #1057); status unchanged | | 2026-10-11 | minor | MENU-HELP-REPORTBUG: Help ▸ Report a Bug… writes a report file and opens a pre-filled issue (issue #1015) | +| 2026-10-12 | minor | LRC-LIB-CATALOG-IMPORT: smart collections import as smart albums where every rule maps; Lightroom folder stacks import as stacks | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |