LRC-LIB-CATALOG-IMPORT: find moved root folders relative to the catalog

A catalog copied from another computer (e.g. Windows to Linux) records
root folders at paths that don't exist here, so every original was
imported as missing. When a root's absolutePath isn't a folder, use the
relativePathFromCatalog Lightroom stores for roots on the catalog's
volume, resolved against the catalog's folder, and report the remapping
as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mark Pariente
2026-10-10 00:35:25 -07:00
co-authored by Claude Opus 5.5
parent 5436951299
commit f9bbeb9d00
3 changed files with 93 additions and 18 deletions
+86 -15
View File
@@ -2,7 +2,7 @@
//! 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::path::{Component, Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
@@ -336,6 +336,29 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
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.
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
return None;
}
let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
let mut out = PathBuf::new();
for part in dir.components() {
match part {
Component::ParentDir => {
if !out.pop() {
return None;
}
}
Component::CurDir => {}
part => out.push(part),
}
}
out.is_dir().then_some(out)
}
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
pub fn read(path: &Path) -> Result<CatalogImport, String> {
let cancel = AtomicBool::new(false);
@@ -428,6 +451,21 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
}
out
};
let mut root_paths = HashMap::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(),
};
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 {
@@ -444,7 +482,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
let root_path = text(root, "absolutePath");
let root_path = root_paths.get(&number(root, "id_local")).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() {
@@ -1259,19 +1297,9 @@ mod tests {
}
page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes());
}
#[test]
fn reads_native_sqlite_tables_without_lightroom_or_external_processes() {
use SqlValue::{Integer as I, Null as N, Text as T};
let tables = [
("AgLibraryRootFolder", "id_local INTEGER PRIMARY KEY, absolutePath", vec![N, T("/catalog/".into())]),
("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]),
("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]),
(
"Adobe_images",
"id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick",
vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)],
),
];
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> 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());
@@ -1295,6 +1323,30 @@ mod tests {
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
}
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();
@@ -1307,6 +1359,25 @@ mod tests {
std::fs::remove_file(path).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!(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 oversized_optional_table_warns_and_is_skipped() {
let mut main = vec![0u8; 2 * 512];
+6 -2
View File
@@ -62,5 +62,9 @@ Archive version 1 begins with the 12 bytes `LC-LRARCH\0\x01Z`, followed by zlib-
Recovery readers must cap decompression at 32 MiB. JSON retains source identities, photo paths,
settings, decoded XMP, keywords, history, snapshots, collection definitions and membership.
The importer uses shared Rust code on macOS, Windows and Linux. If originals move between
computers or volumes, use LightCraft's missing-photo relinking tools to update their paths.
The importer uses shared Rust code on macOS, Windows and Linux. When a root folder's recorded
path isn't a folder on this computer (a catalog copied from another machine), the importer uses
the path relative to the catalog that Lightroom records for root folders on the catalog's volume
(`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying
a project folder with its catalog inside it therefore keeps its originals linked. Other moved
originals stay missing; use LightCraft's missing-photo relinking tools to update their paths.
+1 -1
View File
File diff suppressed because one or more lines are too long