Files
lightcraft/crates/engine/src/lightroom_catalog.rs
T
aiongg c1c9f94094 Merge PR #842: Read large Lightroom catalogs by page
crates/engine/src/lightroom_catalog.rs: test helpers chained; native_catalog_rows takes #842's last_page argument, native_catalog_with_last_table_page builds on it
docs/parity-checklist.md: LRC-LIB-CATALOG-IMPORT keeps both texts
2026-10-21 14:53:52 +00:00

1917 lines
92 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Direct, read-only Lightroom Classic catalog import. SQLite pages are read in Rust;
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue};
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
use lightcraft_geom::{Affine, Orientation, Point, Rect};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
type Row = BTreeMap<String, Value>;
const COMMAND: &str = "library.importLightroom";
/// Largest catalog database or WAL read into memory (4 GiB). The whole file is held in RAM, so the cap stays finite.
const MAX_FILE: u64 = 1 << 32;
fn error(why: impl Into<String>) -> crate::EngineError {
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: why.into() }
}
fn text<'a>(r: &'a Row, k: &str) -> &'a str {
r.get(k).and_then(Value::as_str).unwrap_or("")
}
fn number(r: &Row, k: &str) -> i64 {
r.get(k).and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) as i64
}
pub(crate) fn number_for_job(r: &Row, k: &str) -> i64 {
number(r, k)
}
fn key(path: &str) -> String {
let p = path.replace('\\', "/");
if cfg!(windows) { p.to_lowercase() } else { p }
}
fn source_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } => Some(path),
_ => None,
}
}
struct Reader {
db: Database,
tables: Vec<LiveTable>,
}
impl Reader {
fn table(&self, name: &str, required: bool) -> Result<Vec<Row>, String> {
let Some(t) = self.tables.iter().find(|t| t.name == name) else {
return if required { Err(format!("unsupported Lightroom catalog: missing {name}")) } else { Ok(Vec::new()) };
};
let columns = t.column_names.as_ref().ok_or_else(|| format!("cannot read {name} columns"))?;
let rows = self.db.read_table(t.rootpage, columns.len()).map_err(|e| format!("{name}: {e:?}"))?;
if rows.len() > 1_000_000 {
return Err(format!("{name}: more than one million records"));
}
Ok(rows
.into_iter()
.map(|r| {
columns
.iter()
.cloned()
.zip(r.values.into_iter().map(|v| match v {
SqlValue::Null => Some(Value::Null),
SqlValue::Integer(n) => Some(json!(n)),
SqlValue::Real(n) => Some(json!(n)),
SqlValue::Text(s) => Some(json!(s)),
// Opaque Lightroom payloads never become JSON byte arrays.
SqlValue::Blob(_) => None,
}))
.filter_map(|(column, value)| value.map(|value| (column, value)))
.collect()
})
.collect())
}
fn optional_table(&self, name: &str, warnings: &mut Vec<String>) -> Result<Vec<Row>, String> {
match self.table(name, false) {
Ok(rows) => Ok(rows),
Err(error) => {
warnings.push(format!("skipping optional Lightroom table {name}: {error}"));
Ok(Vec::new())
}
}
}
fn xmp_table(&self) -> Result<(Vec<Row>, Vec<String>), String> {
let Some(t) = self.tables.iter().find(|t| t.name == "Adobe_AdditionalMetadata") else { return Ok((Vec::new(), Vec::new())) };
let columns = t.column_names.as_ref().ok_or_else(|| "cannot read Adobe_AdditionalMetadata columns".to_string())?;
let rows = self.db.read_table(t.rootpage, columns.len()).map_err(|e| format!("Adobe_AdditionalMetadata: {e:?}"))?;
if rows.len() > 1_000_000 {
return Err("Adobe_AdditionalMetadata: more than one million records".into());
}
let mut warnings = Vec::new();
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let image = row
.values
.iter()
.zip(columns)
.find_map(|(value, column)| {
(column == "image").then_some(match value {
SqlValue::Integer(n) => *n,
_ => 0,
})
})
.unwrap_or(0);
let mut mapped = Row::new();
for (column, value) in columns.iter().zip(row.values) {
let value = match value {
SqlValue::Null => Some(Value::Null),
SqlValue::Integer(n) => Some(json!(n)),
SqlValue::Real(n) => Some(json!(n)),
SqlValue::Text(s) => Some(json!(s)),
SqlValue::Blob(bytes) if column == "xmp" => match decode_xmp_bytes(&bytes) {
Ok(packet) => Some(Value::String(packet)),
Err(error) => {
warnings.push(format!("image {image}: {error}"));
None
}
},
SqlValue::Blob(_) => None,
};
if let Some(value) = value {
mapped.insert(column.clone(), value);
}
}
out.push(mapped);
}
Ok((out, warnings))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CatalogPhoto {
pub source_id: i64,
pub uuid: String,
pub path: String,
pub image: Row,
pub settings: String,
pub xmp: String,
pub keywords: Vec<String>,
pub history: Vec<Row>,
pub snapshots: Vec<Row>,
/// Whether `path` may be looked at (derived when the catalog is read; not archived).
#[serde(skip)]
pub original: Original,
}
/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
/// as the document ([`crate::doc_path::resolve`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Original {
/// `path` is a local file path, or one on the network share the catalog is on.
Local,
/// `path` is not a path on this computer (a catalog made on another system): the photo is
/// kept as an offline record for later relinking, and the path is not looked at.
#[default]
NotHere,
/// `path` is refused (why): it is on another computer or a device, or its folder or file name
/// is an absolute path. The photo is reported missing and not added to the library.
Refused(String),
}
impl CatalogPhoto {
/// Whether the original is a file here; only a [`Original::Local`] path is looked at.
pub fn original_is_file(&self) -> bool {
self.original == Original::Local && Path::new(&self.path).is_file()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CatalogImport {
pub source: String,
pub photos: Vec<CatalogPhoto>,
pub collections: Vec<Row>,
pub members: Vec<Row>,
pub collection_content: Vec<Row>,
pub warnings: Vec<String>,
/// Catalog images left out because their records are incomplete; reported as data (not text)
/// so each front end can say so in its own language.
#[serde(default)]
pub skipped: Vec<SkippedImage>,
}
/// A catalog image that was not imported, and why.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkippedImage {
/// The image's `Adobe_images.id_local`.
pub image: i64,
pub reason: SkipReason,
}
/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SkipReason {
/// Its `AgLibraryFile` record is gone.
MissingFile,
/// Its file's `AgLibraryFolder` record is gone.
MissingFolder,
/// Its folder's `AgLibraryRootFolder` record is gone.
MissingRoot,
/// Its file name or root folder path is empty.
IncompletePath,
/// A virtual copy whose master was skipped.
MasterSkipped,
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub(crate) struct ImportIndex {
pub(crate) photos: BTreeMap<String, u64>,
pub(crate) collections: BTreeMap<String, u64>,
}
pub(crate) fn load_index(path: Option<&Path>) -> crate::Result<ImportIndex> {
match path {
Some(path) => {
let file = match std::fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ImportIndex::default()),
Err(e) => return Err(error(e.to_string())),
};
if file.metadata().map_err(|e| error(e.to_string()))?.len() > 16 * 1024 * 1024 {
return Err(error("Lightroom import index exceeds 16 MiB"));
}
let mut bytes = Vec::new();
use std::io::Read;
file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes).map_err(|e| error(e.to_string()))?;
if bytes.len() > 16 * 1024 * 1024 {
return Err(error("Lightroom import index exceeds 16 MiB"));
}
serde_json::from_slice(&bytes).map_err(|e| error(format!("invalid Lightroom import index: {e}")))
}
None => Ok(ImportIndex::default()),
}
}
pub(crate) struct AppliedImport {
pub(crate) report: Value,
pub(crate) index: ImportIndex,
}
fn identity(data: &CatalogImport, photo: &CatalogPhoto) -> String {
if photo.uuid.is_empty() { format!("{}:{}", key(&data.source), photo.source_id) } else { photo.uuid.clone() }
}
fn validate(data: &CatalogImport) -> Result<(), String> {
let photos: HashMap<_, _> = data.photos.iter().map(|p| (p.source_id, p)).collect();
if photos.len() != data.photos.len() {
return Err("duplicate source photo ids".into());
}
for photo in &data.photos {
let master = number(&photo.image, "masterImage");
if master != 0
&& (master == photo.source_id
|| !photos.contains_key(&master)
|| photos.get(&master).is_some_and(|p| number(&p.image, "masterImage") != 0))
{
return Err(format!("invalid virtual copy master for {}", photo.source_id));
}
}
let collections: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), r)).collect();
for id in collections.keys() {
let mut seen = HashSet::new();
let mut at = *id;
while let Some(r) = collections.get(&at) {
if seen.len() >= 64 || !seen.insert(at) {
return Err("cyclic or overly deep collection hierarchy".into());
}
at = number(r, "parent");
if at == 0 {
break;
}
}
}
Ok(())
}
pub(crate) fn validate_for_job(data: &CatalogImport) -> Result<(), String> {
validate(data)
}
fn bytes(path: &Path) -> Result<Vec<u8>, String> {
use std::io::Read;
let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let len = f.metadata().map_err(|e| e.to_string())?.len();
if len > MAX_FILE {
return Err("catalog or WAL exceeds 4 GiB import limit".into());
}
let mut out = Vec::new();
// Reserve the exact size up front: growing by doubling would briefly need about twice the catalog's size.
let wanted = usize::try_from(len).map_err(|_| "catalog is too large for this platform".to_string())?;
out.try_reserve_exact(wanted).map_err(|_| "not enough memory to read the catalog".to_string())?;
f.take(MAX_FILE + 1).read_to_end(&mut out).map_err(|e| e.to_string())?;
if out.len() as u64 > MAX_FILE {
return Err("catalog grew beyond import limit".into());
}
Ok(out)
}
fn xmp(v: Option<&Value>) -> Result<String, String> {
match v {
Some(Value::String(s)) => Ok(s.clone()),
Some(Value::Array(a)) if !a.is_empty() => {
let b: Vec<u8> = a.iter().filter_map(|n| n.as_u64().and_then(|n| u8::try_from(n).ok())).collect();
let size = b.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_be_bytes).ok_or("truncated XMP header")?;
if size > 16 << 20 {
return Err("XMP packet exceeds 16 MiB".into());
}
let compressed = b.get(4..).ok_or("truncated XMP")?;
let decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(compressed, 16 << 20).map_err(|_| "invalid compressed XMP")?;
if decoded.len() != size as usize {
return Err("XMP packet length mismatch".into());
}
String::from_utf8(decoded).map_err(|e| e.to_string())
}
_ => Ok(String::new()),
}
}
fn decode_xmp_bytes(bytes: &[u8]) -> Result<String, String> {
let size = bytes.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_be_bytes).ok_or("truncated XMP header")?;
if size > 16 << 20 {
return Err("XMP packet exceeds 16 MiB".into());
}
let decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(bytes.get(4..).ok_or("truncated XMP")?, 16 << 20)
.map_err(|_| "invalid compressed XMP")?;
if decoded.len() != size as usize {
return Err("XMP packet length mismatch".into());
}
String::from_utf8(decoded).map_err(|e| e.to_string())
}
fn be_word(bytes: &[u8], at: usize) -> Result<u32, String> {
let word = bytes.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or("truncated WAL")?;
Ok(u32::from_be_bytes(word))
}
fn wal_checksum(bytes: &[u8], big: bool, mut sum: (u32, u32)) -> (u32, u32) {
for pair in bytes.as_chunks::<8>().0 {
let first = [pair[0], pair[1], pair[2], pair[3]];
let second = [pair[4], pair[5], pair[6], pair[7]];
let (first, second) =
if big { (u32::from_be_bytes(first), u32::from_be_bytes(second)) } else { (u32::from_le_bytes(first), u32::from_le_bytes(second)) };
sum.0 = sum.0.wrapping_add(first).wrapping_add(sum.1);
sum.1 = sum.1.wrapping_add(second).wrapping_add(sum.0);
}
sum
}
// SQLite file format §4: validate the live checksum chain in constant space, then overlay
// only its last committed prefix. The forensic timeline materializes EVERY historical
// database snapshot; it must never be used to validate a large, actively used catalog.
fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
if wal.is_empty() {
return Ok(0);
}
let header = wal.get(..32).ok_or("truncated WAL header")?;
let magic = be_word(header, 0)?;
if !matches!(magic, 0x377f0682 | 0x377f0683) || be_word(header, 4)? != 3_007_000 {
return Err("invalid WAL header".into());
}
let size = main.get(16..18).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes).ok_or("truncated SQLite header")?;
let size = if size == 1 { 65536 } else { u32::from(size) };
if !(512..=65536).contains(&size) || !size.is_power_of_two() || be_word(header, 8)? != size {
return Err("WAL page size mismatch".into());
}
let big = magic == 0x377f0683;
let mut sum = wal_checksum(&header[..24], big, (0, 0));
if sum != (be_word(header, 24)?, be_word(header, 28)?) {
return Err("invalid WAL header checksum".into());
}
let salt = (be_word(header, 16)?, be_word(header, 20)?);
let mut committed = 0;
let stride = size as usize + 24;
for (i, frame) in wal[32..].chunks_exact(stride).enumerate() {
if be_word(frame, 0)? == 0 || salt != (be_word(frame, 8)?, be_word(frame, 12)?) {
break;
}
sum = wal_checksum(&frame[..8], big, sum);
sum = wal_checksum(&frame[24..], big, sum);
if sum != (be_word(frame, 16)?, be_word(frame, 20)?) {
break;
}
if be_word(frame, 4)? != 0 {
committed = 32 + (i + 1) * stride;
}
}
Ok(committed)
}
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
/// looked at only when [`crate::doc_path::resolve`] follows them.
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
return None;
}
crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
}
/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
/// place of the root folder.
fn is_absolute_part(part: &str) -> bool {
let bytes = part.as_bytes();
part.starts_with(['/', '\\'])
|| (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
|| Path::new(part).is_absolute()
}
/// The path of an original under `root` and whether it may be looked at, resolved with the
/// catalog at `catalog` as the document.
fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
if is_absolute_part(folder) || is_absolute_part(name) {
return (stored, Original::Refused("its folder or file name is an absolute path".into()));
}
match crate::doc_path::resolve(&stored, Some(catalog)) {
Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
}
}
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
pub fn read(path: &Path) -> Result<CatalogImport, String> {
let cancel = AtomicBool::new(false);
let total = AtomicUsize::new(0);
let done = AtomicUsize::new(0);
read_with_progress(path, &cancel, &total, &done)
}
/// Read a Lightroom Classic catalog for import, counting the tables read in `total` / `done` and
/// stopping when `cancel` is set. Images whose catalog records are incomplete are left out and
/// listed in [`CatalogImport::skipped`] instead of failing the read.
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);
done.store(0, Ordering::Relaxed);
ensure_read_active(cancel)?;
let before = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_path = PathBuf::from(format!("{}-wal", path.display()));
let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
let main = FileSource::open(path)?;
let wal = match std::fs::metadata(&wal_path) {
Ok(_) => bytes(&wal_path)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.to_string()),
};
let wal_len = committed_wal_len(main.header(), &wal)?;
ensure_read_active(cancel)?;
let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
let after = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after {
return Err("catalog changed during reading; close Lightroom and retry".into());
}
let reader = Reader { tables: db.live_tables()?, db };
let mut warnings = Vec::new();
let table = |name: &str, required: bool| -> Result<Vec<Row>, String> {
ensure_read_active(cancel)?;
let rows = reader.table(name, required)?;
done.fetch_add(1, Ordering::Relaxed);
Ok(rows)
};
let optional_table = |name: &str, warnings: &mut Vec<String>| -> Result<Vec<Row>, String> {
ensure_read_active(cancel)?;
let rows = reader.optional_table(name, warnings)?;
done.fetch_add(1, Ordering::Relaxed);
Ok(rows)
};
let roots: HashMap<_, _> = table("AgLibraryRootFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect();
let folders: HashMap<_, _> = table("AgLibraryFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect();
let files: HashMap<_, _> = table("AgLibraryFile", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect();
let develops: HashMap<_, _> = optional_table("Adobe_imageDevelopSettings", &mut warnings)?
.into_iter()
.map(|r| (number(&r, "image"), text(&r, "text").to_string()))
.collect();
ensure_read_active(cancel)?;
let (metadata_rows, metadata_warnings) = match reader.xmp_table() {
Ok(result) => result,
Err(error) => {
warnings.push(format!("skipping optional Lightroom table Adobe_AdditionalMetadata: {error}"));
(Vec::new(), Vec::new())
}
};
warnings.extend(metadata_warnings);
done.fetch_add(1, Ordering::Relaxed);
let metadata: HashMap<_, _> = metadata_rows.into_iter().map(|r| (number(&r, "image"), r)).collect();
let keyword_rows: HashMap<_, _> = optional_table("AgLibraryKeyword", &mut warnings)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect();
let mut keywords: HashMap<i64, Vec<String>> = HashMap::new();
for r in optional_table("AgLibraryKeywordImage", &mut warnings)? {
ensure_read_active(cancel)?;
let mut parts = Vec::new();
let mut at = number(&r, "tag");
let mut seen = HashSet::new();
while at != 0 {
if parts.len() >= 64 || !seen.insert(at) {
return Err("cyclic or overly deep keyword hierarchy".into());
}
let Some(k) = keyword_rows.get(&at) else { break };
let name = text(k, "name");
if !name.is_empty() {
parts.push(name.to_string());
}
at = number(k, "parent");
}
parts.reverse();
if !parts.is_empty() {
keywords.entry(number(&r, "image")).or_default().push(parts.join("|"));
}
}
let group = |rows: Vec<Row>| {
let mut out: HashMap<i64, Vec<Row>> = HashMap::new();
for r in rows {
out.entry(number(&r, "image")).or_default().push(r);
}
out
};
let mut root_paths = HashMap::new();
let mut refused_roots = HashSet::new();
let mut root_ids: Vec<_> = roots.keys().copied().collect();
root_ids.sort_unstable();
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
let absolute = text(root, "absolutePath");
let resolved = match relocated_root(absolute, text(root, "relativePathFromCatalog"), path) {
Some(found) => {
let found = found.to_string_lossy().to_string();
warnings.push(format!("root folder {}: {absolute} not found; using {found} (relative to the catalog)", text(root, "name")));
found
}
None => absolute.to_string(),
};
if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
warnings
.push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
refused_roots.insert(id);
}
root_paths.insert(id, resolved);
}
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
let mut snapshots = group(optional_table("Adobe_libraryImageDevelopSnapshot", &mut warnings)?);
let mut out = CatalogImport {
source: path.to_string_lossy().into(),
photos: Vec::new(),
collections: optional_table("AgLibraryCollection", &mut warnings)?,
members: optional_table("AgLibraryCollectionImage", &mut warnings)?,
collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?,
warnings,
skipped: Vec::new(),
};
// images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves
// these behind and ignores them); they and their virtual copies are skipped and reported
// instead of failing the import
let mut orphans = HashSet::new();
for image in table("Adobe_images", true)? {
ensure_read_active(cancel)?;
let id = number(&image, "id_local");
let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| {
let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?;
let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?;
Ok((file, folder, root))
});
let (file, folder, root) = match record {
Ok(found) => found,
Err(reason) => {
out.skipped.push(SkippedImage { image: id, reason });
orphans.insert(id);
continue;
}
};
let root_id = number(root, "id_local");
let root_path = root_paths.get(&root_id).map_or("", String::as_str);
let folder_path = text(folder, "pathFromRoot");
let name = text(file, "idx_filename");
if root_path.is_empty() || name.is_empty() {
out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath });
orphans.insert(id);
continue;
}
let (photo_path, original) = original_path(root_path, folder_path, name, path);
if let Original::Refused(why) = &original
&& !refused_roots.contains(&root_id)
{
out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
}
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
Ok(s) => s,
Err(e) => {
out.warnings.push(format!("image {id}: {e}"));
String::new()
}
};
out.photos.push(CatalogPhoto {
source_id: id,
uuid: text(&image, "id_global").into(),
path: photo_path,
image,
settings: develops.get(&id).cloned().unwrap_or_default(),
xmp: packet,
keywords: keywords.remove(&id).unwrap_or_default(),
history: history.remove(&id).unwrap_or_default(),
snapshots: snapshots.remove(&id).unwrap_or_default(),
original,
});
}
drop_copies_of(&mut out, &orphans);
refuse_non_media(&mut out);
ensure_read_active(cancel)?;
reader.db.ensure_unchanged()?;
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
if wal_before != wal_after {
return Err("catalog changed during reading; close Lightroom and retry".into());
}
Ok(out)
}
/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has
/// no original of its own.
fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet<i64>) {
if masters.is_empty() {
return;
}
let mut skipped = Vec::new();
data.photos.retain(|p| {
let master = number(&p.image, "masterImage");
// 0 marks a master, never a copy
if master == 0 || !masters.contains(&master) {
return true;
}
skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped });
false
});
data.skipped.extend(skipped);
}
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
/// import's warnings.
///
/// The catalog decides these paths, and a record it leaves behind is one click from the system
/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
///
/// Called where the catalog is read and again where its photos become records, so neither route
/// can keep one; the second pass finds nothing left to say.
pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
// masters first: a virtual copy may come before its master in the table
let refused: HashSet<i64> = data
.photos
.iter()
.filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
.map(|p| p.source_id)
.collect();
if refused.is_empty() {
return;
}
let mut warnings = Vec::new();
data.photos.retain(|p| {
let master = number(&p.image, "masterImage");
// `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
// take every master of the catalog with it
if master != 0 {
if !refused.contains(&master) {
return true;
}
warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
return false;
}
if !refused.contains(&p.source_id) {
return true;
}
warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
false
});
data.warnings.extend(warnings);
}
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
}
fn mapped_settings(
text: &str,
target: crate::crs::Target,
aspect: f64,
orientation: Orientation,
stored_aspect: f64,
) -> Result<(Value, Vec<String>), String> {
let mut root = crate::preset_import::parse_lua(text)?;
let deferred = strip_lua_sentinels(&mut root);
let (mut props, values) = crate::preset_import::lua_settings_props(&root)?;
// A catalog's develop settings carry the crop edges and angle but no `HasCrop` (XMP has it):
// any crop field means the photo is cropped.
const CROP: [&str; 5] = ["crs:CropLeft", "crs:CropTop", "crs:CropRight", "crs:CropBottom", "crs:CropAngle"];
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
props.insert("crs:HasCrop".into(), vec!["True".into()]);
}
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
catalog_crop(&mut partial, orientation, stored_aspect);
if deferred > 0 {
unknown.push("Deferred Lightroom adjustments (-999999)".into());
}
Ok((partial, unknown))
}
/// Lightroom's `Adobe_images.orientation`: where the stored image's top-left and top-right corners
/// land in the displayed photo, naming the displayed corners A, B, C, D clockwise from the top left
/// (`AB` upright, `BC` a quarter turn clockwise, `DA` a quarter turn counter-clockwise, `BA` mirrored).
fn lightroom_orientation(code: &str) -> Option<Orientation> {
const ALL: [Orientation; 8] = [
Orientation::Normal,
Orientation::FlipH,
Orientation::Rotate180,
Orientation::FlipV,
Orientation::Transpose,
Orientation::Rotate90,
Orientation::Transverse,
Orientation::Rotate270,
];
let corner = |o: Orientation, x: f64, y: f64| match o.map(x, y, 1.0, 1.0) {
(u, v) if u < 0.5 && v < 0.5 => 'A',
(_, v) if v < 0.5 => 'B',
(u, _) if u >= 0.5 => 'C',
_ => 'D',
};
ALL.into_iter().find(|&o| code.chars().eq([corner(o, 0.0, 0.0), corner(o, 1.0, 0.0)]))
}
/// Lightroom's crop in LightCraft's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`)
/// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and
/// straightening), normalized to its size, plus a straighten angle that turns the other way to
/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its
/// centre, normalized to the oriented image's size, so the corners go through the orientation and
/// then the rotation; `stored_aspect` (width / height of the stored image) matters once the
/// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops
/// in Lightroom's exports within 1%.
fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f64) {
let Some(geometry) = partial.pointer_mut("/crop/geometry").and_then(Value::as_object_mut) else { return };
let edge = |k: &str| geometry.get("rect").and_then(|r| r.get(k)).and_then(Value::as_f64);
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (edge("x0"), edge("y0"), edge("x1"), edge("y1")) else { return };
let lightroom_angle = geometry.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
if orientation == Orientation::Normal && lightroom_angle == 0.0 {
return;
}
let w = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { 1.0 };
let (ow, oh) = if orientation.swaps_axes() { (1.0, w) } else { (w, 1.0) };
// a mirror turns the straighten the other way too
let angle = if orientation.to_parts().0 { lightroom_angle } else { -lightroom_angle };
let to_rotated = Affine::rotate_about(angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
let corner = |x: f64, y: f64| {
let (u, v) = orientation.map(x * w, y, w, 1.0);
to_rotated.apply(Point::new(u, v))
};
let r = Rect::from_points(corner(x0, y0), corner(x1, y1));
geometry.insert("rect".into(), json!({"x0": r.x0 / ow, "y0": r.y0 / oh, "x1": r.x1 / ow, "y1": r.y1 / oh}));
geometry.insert("angle".into(), json!(angle));
}
fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize {
use crate::preset_import::Lua;
match value {
Lua::Num(n) if *n == -999999.0 => {
*value = Lua::Nil;
1
}
Lua::Table(array, fields) => {
let mut removed = array.iter_mut().map(strip_lua_sentinels).sum::<usize>();
fields.retain_mut(|(_, value)| {
removed += strip_lua_sentinels(value);
!matches!(value, Lua::Nil)
});
removed
}
_ => 0,
}
}
fn strip_xmp_sentinels(value: &mut Value) {
match value {
Value::Object(fields) => fields.retain(|_, value| {
if value.as_f64() == Some(-999999.0) {
return false;
}
strip_xmp_sentinels(value);
true
}),
Value::Array(values) => {
if values.iter().any(|v| v.as_f64() == Some(-999999.0)) {
values.clear();
} else {
for v in values {
strip_xmp_sentinels(v);
}
}
}
_ => {}
}
}
/// Import into the current library. Existing edited records are preserved unless explicitly
/// requested; missing originals remain catalogued for later relinking. The source catalog is read-only.
pub fn import(s: &mut crate::Session, path: &Path, update_existing: bool) -> crate::Result<Value> {
let mut job = crate::lightroom_job::LightroomJob::new(s, path.to_path_buf(), update_existing)?;
let cancel = AtomicBool::new(false);
let prepared = job.prepare(&cancel)?;
let completion = crate::lightroom_job::commit_prepared(s, prepared)?;
if s.library.as_ref().is_some_and(|library| library.on_disk) {
s.persist()?;
}
completion.finalization.finish()?;
Ok(completion.report)
}
#[cfg(test)]
fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> crate::Result<Value> {
let archive_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Interop"));
let index_path = archive_dir.as_ref().map(|p| p.join("lightroom-index.json"));
let mut index = load_index(index_path.as_deref())?;
let archive_path = archive_dir.as_ref().map(|dir| crate::lightroom_archive::store(dir, &data).map_err(error)).transpose()?;
let undo0 = s.undo.len();
let existing: HashMap<_, _> =
s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect();
let paths: Vec<_> = data
.photos
.iter()
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
.map(|p| p.path.clone())
.collect();
let missing_sources: HashSet<String> =
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
let applied =
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
if let Some(path) = index_path {
s.persist()?;
let bytes = serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?;
lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))?;
}
Ok(applied.report)
}
/// Apply prepared normal-file results and Lightroom metadata in one owner-thread operation.
/// This function does no filesystem work; callers finish its returned index separately.
pub(crate) struct ApplyPrepared<'a> {
pub(crate) update_existing: bool,
pub(crate) files: crate::import::ImportReport,
pub(crate) index: &'a mut ImportIndex,
pub(crate) archive_path: Option<PathBuf>,
pub(crate) missing_sources: &'a HashSet<String>,
pub(crate) undo0: usize,
}
/// Turn the read catalog into library records in one undo step and return the import report
/// (added, missing, failed, warnings and skipped images).
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
// nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
refuse_non_media(&mut data);
validate(&data).map_err(error)?;
let mut warnings = data.warnings.clone();
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
let mut by_path: HashMap<_, _> =
s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect();
let mut ids = HashMap::new();
let mut ops = Vec::new();
let now = (s.clock)();
let mut missing = Vec::new();
// Normal import counts probed originals; catalog import also adds offline records,
// distinct originals with identical bytes, and virtual copies.
let mut imported = files.imported.len();
let mut promoted = HashSet::new();
let mut unmapped = BTreeMap::new();
let mut preserved = 0;
// Masters precede their virtual copies.
let mut photos: Vec<_> = data.photos.iter().collect();
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
let mut planned: HashMap<PhotoId, Photo> = HashMap::new();
for src in photos {
let master = number(&src.image, "masterImage");
if master == 0 && missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
// A refused path must not become a library record the app would look at later; its
// virtual copies go with it.
if refused.contains(&src.source_id) || refused.contains(&master) {
continue;
}
let indexed = index
.photos
.get(&identity(&data, src))
.copied()
.map(PhotoId)
.filter(|id| s.catalog.photo(*id).is_some_and(|p| source_path(p).is_some_and(|path| key(path) == key(&src.path))));
let existing_id = indexed.or_else(|| if master == 0 { by_path.get(&key(&src.path)).copied() } else { None });
let mut p = if let Some(id) = existing_id.and_then(|id| s.catalog.photo(id)) {
(**id).clone()
} else if master != 0 {
let master_id = ids.get(&master).copied().ok_or_else(|| error(format!("virtual copy {}: missing master", src.source_id)))?;
let mut p = planned
.get(&master_id)
.cloned()
.or_else(|| s.catalog.photo(master_id).map(|p| (**p).clone()))
.ok_or_else(|| error("virtual copy master is unavailable"))?;
p.id = s.catalog.alloc_photo_id();
p.copy_of = Some(master_id);
p.copy_name = Some(text(&src.image, "copyName").to_string());
p
} else if let Some(duplicate) = duplicates.get(&key(&src.path)).and_then(|id| s.catalog.photo(*id)) {
// Catalogs can contain distinct originals with identical bytes and independent edits.
let mut p = (**duplicate).clone();
p.id = s.catalog.alloc_photo_id();
p.source = Source::File { path: src.path.clone() };
p.file_name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().into()).unwrap_or_else(|| src.path.clone());
p
} else {
let id = s.catalog.alloc_photo_id();
let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone());
let mut p = Photo::new(
id,
Source::File { path: src.path.clone() },
&name,
text(&src.image, "fileFormat"),
number(&src.image, "fileWidth").max(0) as u32,
number(&src.image, "fileHeight").max(0) as u32,
&now,
);
if ["RAW", "DNG", "ARW", "CR2", "CR3", "NEF", "NRW", "RAF", "ORF", "RW2", "PEF"].contains(&p.format.to_uppercase().as_str()) {
p.kind = lightcraft_catalog::MediaKind::Raw;
}
p
};
ids.insert(src.source_id, p.id);
index.photos.insert(identity(&data, src), p.id.0);
let keep_develop = existing_id.is_some() && p.edited.is_some() && !update_existing;
let previous_develop = (p.develop.clone(), p.edited.clone());
if keep_develop {
preserved += 1;
}
if !src.xmp.is_empty() {
match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) {
Ok(mut sc) => {
if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop {
strip_xmp_sentinels(partial);
}
crate::sidecar::merge_into(&mut p, &sc, &now);
}
Err(e) => warnings.push(format!("image {} XMP: {e}", src.source_id)),
}
}
p.rating = number(&src.image, "rating").clamp(0, 5) as u8;
p.flag = match number(&src.image, "pick") {
1 => Flag::Pick,
-1 => Flag::Reject,
_ => Flag::None,
};
p.label = s.catalog.label_from_name(text(&src.image, "colorLabels"));
if !text(&src.image, "captureTime").is_empty() {
p.captured = Some(text(&src.image, "captureTime").into());
}
if !src.keywords.is_empty() {
p.meta.keywords = src.keywords.clone();
}
if !src.settings.is_empty() && !keep_develop {
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
match mapped_settings(
&src.settings,
crate::crs::Target::for_photo(&p),
p.width.max(1) as f64 / p.height.max(1) as f64,
orientation,
stored_aspect,
) {
Ok((partial, unknown)) => {
p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0));
p.edited = Some(now.clone());
if !unknown.is_empty() {
unmapped.insert(src.source_id, unknown);
}
}
Err(e) => warnings.push(format!("image {} settings: {e}", src.source_id)),
}
}
if keep_develop {
(p.develop, p.edited) = previous_develop;
}
// Missing files bypass normal-file preparation, including its Local promotion.
// Import still joins the library, and a virtual copy must inherit that membership.
let was_local = p.local;
p.local = false;
let photo_id = p.id;
// Only virtual-copy masters need a second owned photo while operations are staged.
// Keeping every photo here doubled the new-record working set on large catalogs.
if copy_masters.contains(&src.source_id) {
planned.insert(photo_id, p.clone());
}
if s.catalog.photo(p.id).is_some() {
if was_local && promoted.insert(p.id) {
imported += 1;
ops.push(Op::SetLocal { id: p.id, local: false });
}
ops.extend([
Op::SetRating { id: p.id, rating: p.rating },
Op::SetFlag { id: p.id, flag: p.flag },
Op::SetLabel { id: p.id, label: p.label },
Op::SetMeta { id: p.id, meta: Box::new(p.meta.clone()) },
Op::SetCaptured { id: p.id, captured: p.captured.clone() },
]);
ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() });
} else {
imported += 1;
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
by_path.entry(key(&src.path)).or_insert(photo_id);
}
let mut albums = HashMap::new();
for collection in &data.collections {
if number(collection, "systemOnly") != 0 {
continue;
}
let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local"));
let id = index
.collections
.get(&collection_key)
.copied()
.map(lightcraft_catalog::AlbumId)
.filter(|id| s.catalog.album(*id).is_some())
.unwrap_or_else(|| s.catalog.alloc_album_id());
index.collections.insert(collection_key, id.0);
albums.insert(number(collection, "id_local"), id);
}
let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect();
let mut grouped_members: HashMap<i64, Vec<&Row>> = HashMap::new();
for member in &data.members {
grouped_members.entry(number(member, "collection")).or_default().push(member);
}
for members in grouped_members.values_mut() {
members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection")));
}
let mut ordered: Vec<_> = data.collections.iter().collect();
ordered.sort_by_key(|r| {
let mut depth = 0;
let mut at = number(r, "parent");
while at != 0 && depth < 64 {
depth += 1;
at = parents.get(&at).copied().unwrap_or(0);
}
depth
});
for collection in ordered {
let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue };
let mut album = Album::new(id, text(collection, "name"));
album.parent = albums.get(&number(collection, "parent")).copied();
album.folder = text(collection, "creationId").contains("group");
if text(collection, "creationId").contains("smart") {
warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name));
}
if !album.folder {
album.photos = grouped_members
.get(&number(collection, "id_local"))
.into_iter()
.flatten()
.filter_map(|r| ids.get(&number(r, "image")).copied())
.collect();
}
if s.catalog.album(id).is_some() {
// Reimport preserves the user's album name/hierarchy, merges only new membership.
if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) {
let mut photos = old.photos.clone();
let mut seen: HashSet<_> = photos.iter().copied().collect();
for photo in album.photos {
if seen.insert(photo) {
photos.push(photo);
}
}
ops.push(Op::SetAlbumPhotos { id, photos });
}
} else {
ops.push(Op::AddAlbum { album });
}
}
if !ops.is_empty() {
s.commit("Import Lightroom Catalog", Op::Batch { ops })?;
}
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
Ok(AppliedImport {
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
index: index.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_geom::CropGeometry;
fn row(v: Value) -> Row {
serde_json::from_value(v).unwrap()
}
fn sample() -> CatalogImport {
let mut photo = CatalogPhoto {
source_id: 1,
uuid: "test-master".into(),
path: "/missing/test.ARW".into(),
image: row(json!({"id_local":1,"rating":5,"pick":1,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})),
settings: "s = { Exposure2012 = 1.25, Contrast2012 = 17, CameraProfile = \"Example\" }".into(),
xmp: String::new(),
keywords: vec!["Travel|Goa".into()],
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
snapshots: Vec::new(),
original: Original::Local,
};
let mut copy = photo.clone();
copy.source_id = 2;
copy.uuid = "test-copy".into();
copy.image = row(json!({"id_local":2,"masterImage":1,"copyName":"Mono","rating":2,"pick":-1}));
copy.settings = "s = { ConvertToGrayscale = true, Exposure2012 = -0.5 }".into();
photo.image.insert("captureTime".into(), json!("2024-01-16T12:34:56"));
CatalogImport {
source: "test.lrcat".into(),
photos: vec![copy, photo],
collections: vec![
row(json!({"id_local":9,"name":"Travel","creationId":"collection.group"})),
row(json!({"id_local":10,"parent":9,"name":"Keepers","creationId":"collection"})),
],
members: vec![
row(json!({"collection":10,"image":2,"positionInCollection":"a"})),
row(json!({"collection":10,"image":1,"positionInCollection":"b"})),
],
collection_content: Vec::new(),
warnings: Vec::new(),
skipped: Vec::new(),
}
}
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
/// copies go with it; the catalog's real photos still import.
#[test]
fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
let mut data = sample();
let mut exe = data.photos[1].clone();
exe.source_id = 3;
exe.uuid = "payload".into();
exe.path = "/Users/sky/Downloads/payload.exe".into();
exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
let mut exe_copy = exe.clone();
exe_copy.source_id = 4;
exe_copy.uuid = "payload-copy".into();
exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
// the copy comes before its master, as a catalog's table order may have it
data.photos.insert(0, exe_copy);
data.photos.push(exe);
for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
let mut one = data.clone();
one.photos.retain(|p| p.source_id < 3);
let mut planted = one.photos[1].clone();
planted.source_id = 5;
planted.uuid = "planted".into();
planted.path = bad.into();
planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
one.photos.push(planted);
let mut s = crate::Session::new();
let r = apply(&mut s, one, false).unwrap();
assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
}
let mut s = crate::Session::new();
let r = apply(&mut s, data, false).unwrap();
assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
let warnings = r["warnings"].to_string();
assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
}
#[test]
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
let mut s = crate::Session::new();
let data = sample();
let master_id = s.catalog.alloc_photo_id();
let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before");
master.local = true;
s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap();
s.undo.clear();
let before = s.catalog.photo(master_id).unwrap().clone();
let mut index = ImportIndex::default();
let missing_sources = [key("/missing/test.ARW")].into_iter().collect();
let mut import = |s: &mut crate::Session| {
let undo0 = s.undo.len();
apply_prepared(
s,
data.clone(),
ApplyPrepared {
update_existing: false,
files: Default::default(),
index: &mut index,
archive_path: None,
missing_sources: &missing_sources,
undo0,
},
)
.unwrap()
.report
};
let first = import(&mut s);
assert_eq!(first["mapping"]["1"], master_id.0);
assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos");
assert_eq!(first["missing"], json!(["/missing/test.ARW"]));
assert_eq!(s.catalog.len(), 2);
assert!(s.catalog.photos().all(|photo| !photo.local));
assert_eq!(s.undo.len(), 1);
let second = import(&mut s);
assert_eq!(second["imported"], 0);
assert_eq!(second["mapping"], first["mapping"]);
s.undo_step().unwrap();
s.undo_step().unwrap();
assert_eq!(s.catalog.len(), 1);
assert_eq!(s.catalog.photo(master_id), Some(&before));
assert!(s.catalog.albums().next().is_none());
let third = import(&mut s);
assert_eq!(third["imported"], 2);
assert!(s.catalog.photos().all(|photo| !photo.local));
}
#[test]
fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() {
let mut s = crate::Session::new();
let data = sample();
let r = apply(&mut s, data.clone(), false).unwrap();
assert_eq!(r["photos"], 2);
assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added");
assert_eq!(r["missing"].as_array().unwrap().len(), 1);
let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap();
assert_eq!(master.rating, 5);
assert_eq!(master.flag, Flag::Pick);
assert_eq!(master.meta.keywords, vec!["Travel|Goa"]);
assert_eq!(master.develop.light.exposure, 1.25);
let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap();
assert_eq!(copy.copy_of, Some(master.id));
assert_eq!(copy.rating, 2);
assert_eq!(copy.flag, Flag::Reject);
assert_eq!(copy.develop.light.exposure, -0.5);
let album = s.catalog.albums().find(|a| a.name == "Keepers").unwrap();
assert_eq!(album.photos, vec![copy.id, master.id]);
assert!(album.parent.is_some());
assert!(r["unmapped"]["1"].as_array().unwrap().iter().any(|v| v.as_str() == Some("CameraProfile")));
assert_eq!(s.undo.len(), 1);
s.undo_step().unwrap();
assert!(s.catalog.is_empty());
assert!(s.catalog.albums().next().is_none());
assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact
}
#[test]
fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() {
let mut s = crate::Session::new();
let data = sample();
apply(&mut s, data.clone(), false).unwrap();
let id = s.catalog.photos().find(|p| p.copy_of.is_none()).unwrap().id;
s.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal edit").unwrap();
let r = apply(&mut s, data.clone(), false).unwrap();
assert!(r["preservedExistingEdits"].as_u64().unwrap() > 0);
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.0);
let before = s.catalog.to_snapshot();
let mut cyclic = data;
cyclic.collections[0].insert("parent".into(), json!(10));
assert!(apply(&mut s, cyclic, false).is_err());
assert_eq!(s.catalog.to_snapshot(), before);
assert!(
mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
.is_err()
);
}
#[test]
fn compressed_xmp_is_bounded_and_settings_are_data_only() {
let packet = b"<x:xmpmeta>example</x:xmpmeta>";
let mut encoded = (packet.len() as u32).to_be_bytes().to_vec();
encoded.extend(miniz_oxide::deflate::compress_to_vec_zlib(packet, 6));
assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet));
encoded[0] = 0xff;
assert!(xmp(Some(&json!(encoded))).is_err());
let (partial, _) = mapped_settings(
"s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }",
crate::crs::Target::RawAbsolute,
1.5,
Orientation::Normal,
1.5,
)
.unwrap();
assert_eq!(partial["light"]["exposure"], 0.75);
}
#[test]
fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() {
// Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed
// relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin
let settings =
"s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }";
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap();
assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}");
assert_eq!(partial["light"]["exposure"], 0.5);
assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}");
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
assert_eq!(
(partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()),
(Some("custom"), Some(3578.0), Some(-5.0))
);
assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}");
}
#[test]
fn catalog_crops_without_has_crop_are_imported() {
// Lightroom Classic catalogs store crop edges and angle but never `HasCrop`
let (partial, unknown) = mapped_settings(
"s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }",
crate::crs::Target::RawAbsolute,
1.5,
Orientation::Normal,
1.5,
)
.unwrap();
let crop = &partial["crop"]["geometry"];
assert_eq!(crop["rect"]["x0"], 0.042702);
assert_eq!(crop["rect"]["y0"], 0.1);
assert_eq!(crop["rect"]["x1"], 0.957298);
assert_eq!(crop["rect"]["y1"], 1.0);
assert_eq!(crop["angle"], 0.0);
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
// no crop fields: the crop stays untouched
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
assert!(partial.get("crop").is_none());
}
#[test]
fn lightroom_orientation_codes() {
// the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8)
assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal));
assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90));
assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270));
assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180));
assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH));
assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV));
assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose));
assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse));
for code in ["", "A", "ABC", "AC", "ab", "XY"] {
assert_eq!(lightroom_orientation(code), None, "{code:?}");
}
}
#[test]
fn catalog_crops_follow_the_photo_orientation() {
let crop = |o: Orientation| {
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
let g = &partial["crop"]["geometry"];
let r = &g["rect"];
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
};
// (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise
assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]);
assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]);
assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]);
assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]);
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
// a mirror turns the straighten the other way
let angle = |o: Orientation| {
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
};
assert_eq!(angle(Orientation::Rotate90), -2.0);
assert_eq!(angle(Orientation::FlipH), 2.0);
// no crop: nothing to orient
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap();
assert!(partial.get("crop").is_none());
}
/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image
/// of aspect `w` (height 1) shown with orientation `o`.
fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String {
let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) };
let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
let stored = |x: f64, y: f64| {
let p = to_source.apply(Point::new(x * ow, y * oh));
o.inverse().map(p.x, p.y, ow, oh)
};
// the rectangle's corners that land top-left and bottom-right on the stored image
let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)];
let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap();
let angle = if o.to_parts().0 { g.angle } else { -g.angle };
format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1)
}
#[test]
fn straightened_catalog_crops_round_trip() {
let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 };
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
for angle in [6.5, -6.5] {
let g = CropGeometry { angle, ..g };
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(
close(got.angle, g.angle)
&& [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)]
.iter()
.all(|&(a, b)| close(a, b)),
"{o:?} {angle}: {got:?}"
);
}
}
}
#[test]
fn straightened_catalog_crops_match_lightroom_exports() {
// Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the
// JPEGs Lightroom exported from it at full size.
for (settings, o, export) in [
(
"s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }",
Orientation::Normal,
(7725.0, 5150.0),
),
(
"s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }",
Orientation::Rotate90,
(6100.0, 9150.0),
),
] {
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap();
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
let (w, h) = (r.width() * ow, r.height() * oh);
assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}");
}
}
#[test]
fn imported_crops_use_the_catalog_orientation() {
let mut data = sample();
data.photos.truncate(1);
data.photos[0].image.remove("masterImage");
data.photos[0].image.insert("orientation".into(), json!("BC"));
data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into();
data.photos[0].xmp = String::new();
let mut s = crate::Session::new();
apply(&mut s, data, false).unwrap();
let g = s.catalog.photos().next().unwrap().develop.crop.geometry;
let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9);
assert_eq!(r, [0.1, 0.1, 0.8, 0.6]);
}
#[test]
fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
let mut data = sample();
data.photos.truncate(1);
data.photos[0].image.remove("masterImage");
data.photos[0].settings =
"s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into();
data.photos[0].xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="-999999" crs:Contrast2012="-999999" crs:Blacks2012="-22"/></rdf:RDF></x:xmpmeta>"#.into();
let mut s = crate::Session::new();
let report = apply(&mut s, data, false).unwrap();
let p = s.catalog.photos().next().unwrap();
assert_eq!(p.develop.light.exposure, 0.0);
assert_eq!(p.develop.light.contrast, 0.0);
assert_eq!(p.develop.light.shadows, 65.0);
assert_eq!(p.develop.light.blacks, -22.0);
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
let (partial, _) =
mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
.unwrap();
assert_eq!(partial["light"]["exposure"], -5.0);
assert_eq!(partial["light"]["contrast"], -100.0);
}
#[test]
fn wal_checksums_cover_commits_without_materializing_historical_databases() {
let mut main = vec![0; 512];
main[16..18].copy_from_slice(&512u16.to_be_bytes());
assert_eq!(wal_checksum(&[1, 0, 0, 0, 2, 0, 0, 0], false, (0, 0)), (1, 3));
for big in [false, true] {
let mut wal = vec![0; 32];
wal[0..4].copy_from_slice(&(if big { 0x377f0683u32 } else { 0x377f0682u32 }).to_be_bytes());
wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes());
wal[8..12].copy_from_slice(&512u32.to_be_bytes());
wal[16..20].copy_from_slice(&123u32.to_be_bytes());
let mut sum = wal_checksum(&wal[..24], big, (0, 0));
wal[24..28].copy_from_slice(&sum.0.to_be_bytes());
wal[28..32].copy_from_slice(&sum.1.to_be_bytes());
for _ in 0..128 {
let mut frame = vec![0; 536];
frame[0..4].copy_from_slice(&1u32.to_be_bytes());
frame[4..8].copy_from_slice(&1u32.to_be_bytes());
frame[8..12].copy_from_slice(&123u32.to_be_bytes());
sum = wal_checksum(&frame[..8], big, sum);
sum = wal_checksum(&frame[24..], big, sum);
frame[16..20].copy_from_slice(&sum.0.to_be_bytes());
frame[20..24].copy_from_slice(&sum.1.to_be_bytes());
wal.extend(frame);
}
assert_eq!(committed_wal_len(&main, &wal).unwrap(), wal.len());
let end = wal.len();
wal[end - 1] ^= 1;
assert_eq!(committed_wal_len(&main, &wal).unwrap(), end - 536);
wal[24] ^= 1;
assert!(committed_wal_len(&main, &wal).is_err());
main[17] = 0;
assert!(committed_wal_len(&main, &wal).is_err());
main[16..18].copy_from_slice(&512u16.to_be_bytes());
}
}
#[test]
fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id()));
let mut s = crate::Session::new();
s.open_library(&dir, false).unwrap();
let mut data = sample();
data.collections.reverse(); // Parent must be committed before its child regardless of row order.
let first = apply(&mut s, data.clone(), false).unwrap();
s.close_library().unwrap();
s.open_library(&dir, false).unwrap();
let id = PhotoId(first["mapping"]["1"].as_u64().unwrap());
s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap();
let second = apply(&mut s, data, true).unwrap();
assert_eq!(first["mapping"], second["mapping"]);
assert_eq!(first["imported"], 2);
assert_eq!(second["imported"], 0, "reimport updates records instead of adding them");
assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too");
assert_eq!(second["library"], dir.to_string_lossy().as_ref());
assert_eq!(s.catalog.len(), 2);
assert_eq!(s.catalog.albums().count(), 2);
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56"));
s.close_library().unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
// Construct a small SQLite fixture from public file-format records, never private media.
fn varint(mut n: u64) -> Vec<u8> {
let mut out = vec![(n & 127) as u8];
n >>= 7;
while n > 0 {
out.push((n & 127) as u8 | 128);
n >>= 7;
}
out.reverse();
out
}
fn record(values: &[SqlValue]) -> Vec<u8> {
let mut header = Vec::new();
let mut body = Vec::new();
for value in values {
let serial = match value {
SqlValue::Null => 0,
SqlValue::Integer(n) => {
body.extend(n.to_be_bytes());
6
}
SqlValue::Text(s) => {
body.extend(s.as_bytes());
13 + 2 * s.len() as u64
}
_ => unreachable!(),
};
header.extend(varint(serial));
}
let mut out = varint(1 + header.len() as u64);
out.extend(header);
out.extend(body);
out
}
fn leaf(page: &mut [u8], offset: usize, rows: &[Vec<SqlValue>]) {
page[offset] = 13;
page[offset + 3..offset + 5].copy_from_slice(&(rows.len() as u16).to_be_bytes());
let mut end = page.len();
for (i, values) in rows.iter().enumerate() {
let payload = record(values);
let mut cell = varint(payload.len() as u64);
cell.extend(varint(i as u64 + 1));
cell.extend(payload);
end -= cell.len();
page[end..end + cell.len()].copy_from_slice(&cell);
page[offset + 8 + i * 2..offset + 10 + i * 2].copy_from_slice(&(end as u16).to_be_bytes());
}
page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes());
}
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
native_catalog_with_last_table_page(tables, None)
}
/// [`native_catalog`] whose last table claims `last_page` as its root page.
fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec<SqlValue>)], last_page: Option<i64>) -> Vec<u8> {
let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect();
native_catalog_rows(&tables, last_page)
}
/// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last
/// table's root page.
fn native_catalog_rows(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], last_page: Option<i64>) -> Vec<u8> {
use SqlValue::{Integer as I, Text as T};
let mut bytes = vec![0u8; 5 * 2048];
bytes[..16].copy_from_slice(b"SQLite format 3\0");
bytes[16..18].copy_from_slice(&2048u16.to_be_bytes());
bytes[18] = 1;
bytes[19] = 1;
bytes[21] = 64;
bytes[22] = 32;
bytes[23] = 32;
bytes[28..32].copy_from_slice(&5u32.to_be_bytes());
bytes[44..48].copy_from_slice(&4u32.to_be_bytes());
bytes[56..60].copy_from_slice(&1u32.to_be_bytes());
let mut schema = Vec::new();
for (i, (name, columns, rows)) in tables.iter().enumerate() {
schema.push(vec![
T("table".into()),
T((*name).into()),
T((*name).into()),
I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }),
T(format!("CREATE TABLE {name} ({columns})")),
]);
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows);
}
leaf(&mut bytes[..2048], 100, &schema);
bytes
}
fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec<SqlValue>)> {
use SqlValue::{Integer as I, Null as N, Text as T};
vec![
(
"AgLibraryRootFolder",
"id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog",
vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())],
),
("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]),
("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]),
(
"Adobe_images",
"id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick",
vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)],
),
]
}
#[test]
fn reads_native_sqlite_tables_without_lightroom_or_external_processes() {
let bytes = native_catalog(&photo_tables(("/catalog/", "")));
let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id()));
std::fs::write(&path, &bytes).unwrap();
let catalog = read(&path).unwrap();
assert_eq!(catalog.photos.len(), 1);
assert_eq!(catalog.photos[0].uuid, "fixture-image");
assert_eq!(number(&catalog.photos[0].image, "rating"), 5);
assert!(catalog.photos[0].path.ends_with("test.ARW"));
// Put the required image table beyond the old 1 GiB cutoff without allocating it.
// A main-file-sized allocation would defeat the purpose of the page-backed reader.
// The large fixture relies on Unix sparse-file behavior; small parity runs everywhere.
#[cfg(unix)]
{
let far_offset = 1u64 << 30;
let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64));
use std::io::{Seek, SeekFrom, Write};
let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap();
sparse.write_all(&bytes).unwrap();
sparse.set_len(far_offset + 2048).unwrap();
sparse.seek(SeekFrom::Start(far_offset)).unwrap();
sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap();
drop(sparse);
let large = read(&path).unwrap();
assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap());
let mut session = crate::Session::new();
session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(session.catalog.len(), 1);
assert_eq!(session.catalog.photos().next().unwrap().rating, 5);
session.execute("edit.undo", &json!({})).unwrap();
assert!(session.catalog.is_empty());
}
std::fs::write(&path, b"bad catalog").unwrap();
assert!(read(&path).is_err());
std::fs::remove_file(path).unwrap();
}
/// Reads `tables` as a catalog file and returns what the reader made of it.
fn read_tables(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], name: &str) -> CatalogImport {
let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id()));
std::fs::write(&path, native_catalog_rows(tables, None)).unwrap();
let catalog = read(&path).unwrap();
std::fs::remove_file(path).unwrap();
catalog
}
/// `tables` with each row as a one-row table, for [`native_catalog_rows`].
fn one_row(tables: Vec<(&'static str, &'static str, Vec<SqlValue>)>) -> Vec<(&'static str, &'static str, Vec<Vec<SqlValue>>)> {
tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect()
}
#[test]
fn images_with_incomplete_records_are_skipped_not_fatal() {
use SqlValue::{Integer as I, Text as T};
// (table, column, value): the record that breaks, and the reason the import gives
let cases = [
(3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file
(2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder
(1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root
(2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty
];
for (table, column, value, reason) in cases {
let mut tables = one_row(photo_tables(("/catalog/", "")));
tables[table].2[0][column] = value;
let catalog = read_tables(&tables, &format!("skipped-{reason:?}"));
assert!(catalog.photos.is_empty(), "{reason:?}");
assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]);
assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings);
}
}
#[test]
fn a_virtual_copy_of_a_skipped_master_is_skipped_too() {
use SqlValue::{Integer as I, Null as N, Text as T};
let mut tables = one_row(photo_tables(("/catalog/", "")));
tables[3] = (
"Adobe_images",
"id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick",
vec![
// the master's file record is gone, so it is skipped …
vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)],
// … and its virtual copy goes with it, although its own file record is there
vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)],
],
);
let catalog = read_tables(&tables, "skipped-copy");
assert!(catalog.photos.is_empty());
assert_eq!(
catalog.skipped,
[SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }]
);
validate(&catalog).unwrap();
}
#[test]
fn skipped_images_are_in_the_import_report() {
let mut data = sample();
data.photos.retain(|p| p.source_id != 1);
drop_copies_of(&mut data, &HashSet::from([1]));
data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile });
let mut s = crate::Session::new();
let report = apply(&mut s, data, false).unwrap();
assert_eq!(report["photos"], 0);
assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}]));
assert_eq!(report["warnings"], json!([]));
}
#[test]
fn preparation_skips_missing_original_probes_but_keeps_catalog_records() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("source.lrcat");
let root = dir.join("offline");
let tables = photo_tables((root.to_str().unwrap(), ""));
std::fs::write(&source, native_catalog(&tables)).unwrap();
let mut s = crate::Session::new();
let probes = Arc::new(AtomicUsize::new(0));
let calls = probes.clone();
s.media.file_probe = Some(Arc::new(move |_| {
calls.fetch_add(1, Ordering::Relaxed);
Err("a known-missing original must not be probed".into())
}));
let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap();
let prepared = job.prepare(&AtomicBool::new(false)).unwrap();
assert_eq!(probes.load(Ordering::Relaxed), 0);
let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap();
assert_eq!(result.report["imported"], 1);
assert_eq!(result.report["failed"], json!([]));
assert_eq!(result.report["missing"].as_array().unwrap().len(), 1);
let photo = s.catalog.photos().next().unwrap();
assert_eq!(photo.rating, 5);
assert_eq!((photo.width, photo.height), (20, 10));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.catalog.is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn probed_original_is_counted_once_and_reimport_adds_nothing() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id()));
std::fs::create_dir_all(dir.join("Photos")).unwrap();
let source = dir.join("source.lrcat");
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
let mut s = crate::Session::new();
let probes = Arc::new(AtomicUsize::new(0));
let calls = probes.clone();
s.media.file_probe = Some(Arc::new(move |_| {
calls.fetch_add(1, Ordering::Relaxed);
Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() })
}));
for expected in [1, 0] {
let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap();
let prepared = job.prepare(&AtomicBool::new(false)).unwrap();
let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap();
assert_eq!(completion.report["imported"], expected);
assert_eq!(completion.report["missing"], json!([]));
assert_eq!(completion.report["failed"], json!([]));
assert_eq!(s.catalog.len(), 1);
}
assert_eq!(probes.load(Ordering::Relaxed), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_root_missing_here_is_found_relative_to_the_catalog() {
let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Lightroom")).unwrap();
std::fs::create_dir_all(dir.join("RAW")).unwrap();
let path = dir.join("Lightroom").join("Lightroom.lrcat");
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap();
let catalog = read(&path).unwrap();
assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW"));
assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings);
// a relative path that leads nowhere keeps the recorded one
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
let catalog = read(&path).unwrap();
assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn originals_on_other_computers_are_never_looked_at_or_added() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Photos")).unwrap();
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
let source = dir.join("source.lrcat");
let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
if cfg!(unix) {
// `//<local folder>/` is a local folder to Linux, but a network path to the resolver.
roots.push(format!("/{}/", dir.display()));
}
for root in &roots {
std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
let catalog = read(&source).unwrap();
assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
let mut s = crate::Session::new();
let probes = Arc::new(AtomicUsize::new(0));
let calls = probes.clone();
s.media.file_probe = Some(Arc::new(move |_| {
calls.fetch_add(1, Ordering::Relaxed);
Err("a refused original must not be probed".into())
}));
let report = import(&mut s, &source, false).unwrap();
assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
assert!(s.catalog.is_empty(), "{root}");
}
// An absolute folder path would replace the root.
let mut tables = photo_tables((dir.to_str().unwrap(), ""));
tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
std::fs::write(&source, native_catalog(&tables)).unwrap();
assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
// A root beside the catalog is followed.
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
let catalog = read(&source).unwrap();
assert_eq!(catalog.photos[0].original, Original::Local);
assert!(catalog.photos[0].original_is_file());
// A root that moved is found through its path relative to the catalog.
std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
let catalog = read(&source).unwrap();
assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn oversized_optional_table_warns_and_is_skipped() {
let mut main = vec![0u8; 2 * 512];
main[..16].copy_from_slice(b"SQLite format 3\0");
main[16..18].copy_from_slice(&512u16.to_be_bytes());
main[18] = 1;
main[19] = 1;
main[21] = 64;
main[22] = 32;
main[23] = 32;
main[28..32].copy_from_slice(&2u32.to_be_bytes());
main[44..48].copy_from_slice(&4u32.to_be_bytes());
main[56..60].copy_from_slice(&1u32.to_be_bytes());
main[100] = 0x0d;
let page = &mut main[512..];
page[0] = 0x0d;
page[3..5].copy_from_slice(&1u16.to_be_bytes());
let mut cell = varint((64 * 1024 * 1024 + 1) as u64);
cell.extend(varint(1));
let start = 500 - cell.len();
page[start..start + cell.len()].copy_from_slice(&cell);
page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes());
page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes());
let db = Database::open_with_wal(main, &[]).unwrap();
let reader =
Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] };
let mut warnings = Vec::new();
let rows = reader.optional_table("optional_history", &mut warnings).unwrap();
assert!(rows.is_empty());
assert!(warnings.iter().any(|warning| warning.contains("optional_history")));
}
}