Merge PR #1014
# Conflicts: # crates/pipeline/src/spots.rs # docs/gaps.md # docs/parity-checklist.md
This commit is contained in:
commit
5f81dfcdde
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(©));
|
||||
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()));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue