# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/parity-checklist.md
This commit is contained in:
bflatastic 2026-10-13 12:28:48 +07:00
commit 5f81dfcdde
9 changed files with 446 additions and 26 deletions

View File

@ -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;

View File

@ -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()],
}
}

View File

@ -154,6 +154,9 @@ pub struct CatalogImport {
pub collections: Vec<Row>,
pub members: Vec<Row>,
pub collection_content: Vec<Row>,
/// `AgLibraryFolderStackImage` rows (`stack`, `image`, `position`, `collapsed`).
#[serde(default)]
pub stacks: Vec<Row>,
pub warnings: Vec<String>,
}
@ -368,8 +371,8 @@ pub fn read(path: &Path) -> Result<CatalogImport, String> {
}
pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result<CatalogImport, String> {
// 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<i64, &str> = 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<PhotoId> = 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<i64, PhotoId>, ops: &mut Vec<Op>, warnings: &mut Vec<String>) -> usize {
let mut groups: BTreeMap<i64, Vec<(f64, PhotoId, bool)>> = 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<PhotoId> = 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<PhotoId> = 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(&copy));
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()));

View File

@ -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(),

View File

@ -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<RuleSet, String> {
let root = parse_lua(content)?;
group(&root, 0)
}
fn group(t: &Lua, depth: usize) -> Result<RuleSet, String> {
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<f64> {
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<Rule, String> {
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<Rule> {
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());
}
}

View File

@ -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);

View File

@ -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 |

View File

@ -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

View File

@ -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:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, 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 |