LRC-LIB-CATALOG-IMPORT: Open Library recognises a Lightroom catalog and offers to import it (#891)

Open Library given a Lightroom Classic catalog folder, one of its .lrdata /
.lrcat-data folders, Backups/, the .lrcat or its -wal/-shm/.lock files used to
create an empty LightCraft library inside Lightroom's folder (or, for the
.lrcat file, close the current library and then fail). Now:

- engine: lightroom_folder::lightroom_catalog_at (names only, top-level scan
  bounded to 10 000 entries, never fails); Session::open_library refuses those
  places with EngineError::LightroomCatalog {path, catalog} before creating
  anything. A LightCraft library (catalog.snap / catalog.log) always wins.
- app: app.openLibrary checks before closing the current library, keeps it,
  returns {opened: false, lightroomCatalog, path} and shows a dialog with
  Import Lightroom Catalog… (file.importLightroom with the catalog found) and
  Cancel. The library problem window gets the error instead.
- Lightroom import status keeps the catalog path after the task ends.
- CLI --library explains how to import instead.
This commit is contained in:
Jason Baldwin committed 2026-10-11 08:40:18 +02:00
1 parent 0ba118de92
commit 43f31532a7
24 files changed
+550 -25

No files matched your search

+3
View File
@@ -115,6 +115,9 @@ fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
),
e @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!(
"{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path."
),
e => format!("{dir}: {e}"),
}
}
+8
View File
@@ -41,6 +41,7 @@ mod import_move;
pub mod library;
mod lightroom_archive;
pub mod lightroom_catalog;
pub mod lightroom_folder;
pub mod lightroom_job;
mod lightroom_sqlite;
pub mod logging;
@@ -88,6 +89,11 @@ pub enum EngineError {
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
#[error("{0}")]
LibraryInUse(String),
/// The folder (or file) asked for is a Lightroom Classic catalog or part of one, not a
/// LightCraft library (issue #891): nothing was created or opened. `catalog`: the `.lrcat`
/// to import with `library.importLightroom`, when one was found.
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
LightroomCatalog { path: std::path::PathBuf, catalog: Option<std::path::PathBuf> },
#[error("{0}")]
Other(String),
}
@@ -892,6 +898,8 @@ mod tests_libops;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_lightroom_folder;
#[cfg(test)]
mod tests_merge;
#[cfg(test)]
mod tests_organize;
+8
View File
@@ -252,8 +252,16 @@ impl Session {
/// The library is locked for this session ([`lightcraft_catalog::lock`]): if another process
/// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed.
/// Reopening the library this session already has open keeps its lock.
///
/// A Lightroom Classic catalog folder, one of its `.lrdata` / `.lrcat-data` folders or an
/// `.lrcat` file is refused with [`EngineError::LightroomCatalog`] before anything is
/// created ([`crate::lightroom_folder`]); a LightCraft library always opens, whatever else
/// its folder holds.
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
let dir = dir.as_ref().to_path_buf();
if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) {
return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog });
}
let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())));
let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() };
// the library open in this session (same directory): hand its lock over
+143
View File
@@ -0,0 +1,143 @@
//! Recognise a Lightroom Classic catalog where a LightCraft library was asked for (issue #891).
//!
//! A Lightroom catalog folder holds `Name.lrcat` (SQLite) next to `Name Previews.lrdata`,
//! `Name Smart Previews.lrdata`, `Name Helper.lrdata`, `Name.lrcat-data`, the `-wal` / `-shm` /
//! `.lock` files of an open catalog and `Backups/`. Opening any of these as a library would create
//! an empty LightCraft library inside Lightroom's folder; [`Session::open_library`] refuses with
//! [`EngineError::LightroomCatalog`] instead, and the app offers File ▸ Import Lightroom Catalog….
//!
//! What counts (only names are looked at; no catalog is opened or read):
//! - a LightCraft library (`catalog.snap` or `catalog.log` in the folder) is never a Lightroom
//! catalog, whatever else the folder holds: it opens as before;
//! - a path named `*.lrcat` or one of its companions (`*.lrcat-wal`, `*.lrcat-shm`,
//! `*.lrcat-journal`, `*.lrcat.lock`);
//! - a folder named `*.lrdata` or `*.lrcat-data` (Lightroom's previews and data);
//! - a folder holding a `*.lrcat` at its top level (the catalog folder itself), or `Backups/`
//! next to one.
//!
//! The folder scan is bounded ([`SCAN_LIMIT`] entries, top level only) and every I/O error just
//! means "not found": this never fails and never panics.
//!
//! [`Session::open_library`]: crate::Session::open_library
//! [`EngineError::LightroomCatalog`]: crate::EngineError::LightroomCatalog
use std::path::{Path, PathBuf};
/// The most directory entries looked at in one folder.
pub const SCAN_LIMIT: usize = 10_000;
/// A Lightroom Classic catalog (or part of one) found where a library was asked for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LightroomPlace {
/// The `.lrcat` file to import, when one was found.
pub catalog: Option<PathBuf>,
}
/// Companion files of an open catalog, after the `.lrcat` (lower case).
const COMPANIONS: [&str; 4] = ["-wal", "-shm", "-journal", ".lock"];
/// Lightroom's own `.lrdata` folders: `Name<suffix>.lrdata` belongs to `Name.lrcat`.
const LRDATA_SUFFIXES: [&str; 4] = [" Smart Previews", " Previews", " Helper", " Sync"];
/// `path`'s file name, lower-cased (lossy: only used to compare with ASCII suffixes).
fn lower_name(path: &Path) -> Option<String> {
path.file_name().map(|n| n.to_string_lossy().to_lowercase())
}
/// `name` ends with `suffix`, ignoring ASCII case; returns the part before it (`None` for a
/// non-UTF-8 name, or when the cut would not be on a character boundary).
fn strip_suffix_nocase<'a>(name: &'a str, suffix: &str) -> Option<&'a str> {
let cut = name.len().checked_sub(suffix.len())?;
let tail = name.get(cut..)?;
tail.eq_ignore_ascii_case(suffix).then(|| name.get(..cut)).flatten()
}
/// `name` is `*.lrcat` (any case).
fn is_lrcat_name(name: &str) -> bool {
name.to_lowercase().ends_with(".lrcat")
}
/// A LightCraft library lives in `dir` (its catalog journal is there).
pub fn is_lightcraft_library(dir: &Path) -> bool {
[lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG].iter().any(|f| dir.join(f).is_file())
}
/// The first `*.lrcat` file (by name) at the top level of `dir`, looking at no more than
/// [`SCAN_LIMIT`] entries. Unreadable folders and entries are skipped.
pub fn find_lrcat(dir: &Path) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
let mut found: Option<PathBuf> = None;
for entry in entries.take(SCAN_LIMIT).flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !is_lrcat_name(name) {
continue;
}
let path = entry.path();
// (follows a symlink; a folder named `*.lrcat` is not a catalog)
if !path.is_file() {
continue;
}
if found.as_ref().is_none_or(|f| path < *f) {
found = Some(path);
}
}
found
}
/// `candidate` if it is a file, else the first catalog in `dir`.
fn catalog_or_scan(candidate: Option<PathBuf>, dir: Option<&Path>) -> Option<PathBuf> {
candidate.filter(|c| c.is_file()).or_else(|| dir.and_then(find_lrcat))
}
/// Whether `path`, given as a library folder, is a Lightroom Classic catalog or part of one (see
/// the module docs). `None`: not Lightroom's, open it as a library.
pub fn lightroom_catalog_at(path: &Path) -> Option<LightroomPlace> {
let is_dir = path.is_dir();
if is_dir && is_lightcraft_library(path) {
return None;
}
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
let name = path.file_name().and_then(|n| n.to_str());
let lower = lower_name(path).unwrap_or_default();
// the catalog file itself (a folder named `*.lrcat` is looked into below)
if lower.ends_with(".lrcat") && !is_dir {
return Some(LightroomPlace { catalog: path.is_file().then(|| path.to_path_buf()) });
}
// an open catalog's companion files: `Name.lrcat-wal` → `Name.lrcat`
for c in COMPANIONS {
if lower.ends_with(&format!(".lrcat{c}")) && !is_dir {
let candidate = name.and_then(|n| strip_suffix_nocase(n, c)).and_then(|n| parent.map(|p| p.join(n)));
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
}
}
// `Name.lrcat-data` → `Name.lrcat`
if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, "-data"))
&& is_lrcat_name(stem)
{
let candidate = parent.map(|p| p.join(stem));
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
}
// `Name Previews.lrdata` (and Smart Previews, Helper, Sync) → `Name.lrcat`
if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, ".lrdata")) {
let base = LRDATA_SUFFIXES.iter().find_map(|s| strip_suffix_nocase(stem, s)).unwrap_or(stem);
let candidate = parent.map(|p| p.join(format!("{base}.lrcat")));
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
}
if lower.ends_with(".lrdata") || lower.ends_with(".lrcat-data") {
// (a non-UTF-8 name)
return Some(LightroomPlace { catalog: parent.and_then(find_lrcat) });
}
if is_dir {
// the catalog folder
if let Some(catalog) = find_lrcat(path) {
return Some(LightroomPlace { catalog: Some(catalog) });
}
// `Backups/` next to a catalog
if lower == "backups"
&& let Some(catalog) = parent.and_then(find_lrcat)
{
return Some(LightroomPlace { catalog: Some(catalog) });
}
}
None
}
+174
View File
@@ -0,0 +1,174 @@
//! Issue #891: Open Library given a Lightroom Classic catalog (its folder, `.lrdata` /
//! `.lrcat-data` folders, the `.lrcat` or its companion files) refuses it, creating nothing, and
//! names the catalog to import; a LightCraft library still opens, whatever else its folder holds.
use std::path::{Path, PathBuf};
use crate::lightroom_folder::{LightroomPlace, SCAN_LIMIT, lightroom_catalog_at};
use crate::{EngineError, Session};
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-engine-lrfolder-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A Lightroom catalog folder as Lightroom Classic leaves it (fake contents: only names count).
fn lightroom_folder(root: &Path) -> PathBuf {
let dir = root.join("Lightroom");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Lightroom Catalog.lrcat"), b"not really SQLite").unwrap();
for f in ["Lightroom Catalog.lrcat-wal", "Lightroom Catalog.lrcat-shm", "Lightroom Catalog.lrcat.lock"] {
std::fs::write(dir.join(f), b"").unwrap();
}
for d in [
"Lightroom Catalog Previews.lrdata",
"Lightroom Catalog Smart Previews.lrdata",
"Lightroom Catalog Helper.lrdata",
"Lightroom Catalog.lrcat-data",
"Backups",
] {
std::fs::create_dir_all(dir.join(d)).unwrap();
}
dir
}
fn names(dir: &Path) -> Vec<std::ffi::OsString> {
let mut v: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect();
v.sort();
v
}
/// Refused with the catalog named, and nothing created or changed anywhere in `root`.
fn refused(root: &Path, path: &Path, catalog: &Path) {
let before = names(&root.join("Lightroom"));
let mut s = Session::new();
match s.open_library(path, true) {
Err(EngineError::LightroomCatalog { path: p, catalog: c }) => {
assert_eq!(p, path);
assert_eq!(c.as_deref(), Some(catalog), "{}", path.display());
}
other => panic!("{}: {other:?}", path.display()),
}
assert!(s.library.is_none());
assert_eq!(names(&root.join("Lightroom")), before, "nothing written next to the catalog");
if path.is_dir() {
assert!(!path.join("catalog.snap").exists() && !path.join("catalog.lock").exists(), "no library created in {}", path.display());
}
}
#[test]
fn each_part_of_a_lightroom_catalog_is_refused() {
let root = temp_dir("parts");
let dir = lightroom_folder(&root);
let catalog = dir.join("Lightroom Catalog.lrcat");
for p in [
dir.clone(),
catalog.clone(),
dir.join("Lightroom Catalog Previews.lrdata"),
dir.join("Lightroom Catalog Smart Previews.lrdata"),
dir.join("Lightroom Catalog Helper.lrdata"),
dir.join("Lightroom Catalog.lrcat-data"),
dir.join("Lightroom Catalog.lrcat-wal"),
dir.join("Lightroom Catalog.lrcat-shm"),
dir.join("Lightroom Catalog.lrcat.lock"),
dir.join("Backups"),
] {
refused(&root, &p, &catalog);
}
let e = Session::new().open_library(&dir, false).unwrap_err().to_string();
assert!(e.contains("Lightroom Classic catalog, not a LightCraft library") && e.contains("Import Lightroom Catalog"), "{e}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_lrdata_folder_whose_catalog_was_renamed_finds_the_one_next_to_it() {
let root = temp_dir("renamed");
let dir = root.join("Lightroom");
std::fs::create_dir_all(dir.join("Old Name Previews.lrdata")).unwrap();
std::fs::write(dir.join("New Name.lrcat"), b"x").unwrap();
refused(&root, &dir.join("Old Name Previews.lrdata"), &dir.join("New Name.lrcat"));
// no catalog anywhere: still refused (it is Lightroom's data), with nothing to import
let lone = root.join("lone");
std::fs::create_dir_all(lone.join("Gone Previews.lrdata")).unwrap();
assert_eq!(lightroom_catalog_at(&lone.join("Gone Previews.lrdata")), Some(LightroomPlace { catalog: None }));
assert_eq!(lightroom_catalog_at(&lone.join("Gone.lrcat")), Some(LightroomPlace { catalog: None }), "a missing .lrcat path");
assert!(!lone.join("Gone.lrcat").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_empty_folder_still_becomes_a_library() {
let root = temp_dir("empty");
let lib = root.join("New Library");
assert_eq!(lightroom_catalog_at(&lib), None, "not there yet");
let mut s = Session::new();
s.open_library(&lib, false).unwrap();
assert!(s.library.is_some());
// a folder with other files (and a folder named like a catalog, which is not one) too
let other = root.join("Other");
std::fs::create_dir_all(other.join("weird.lrcat")).unwrap();
std::fs::write(other.join("notes.txt"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&other), None);
let _ = std::fs::remove_dir_all(&root);
}
/// Precedence: a LightCraft library (its catalog journal) opens, even with an `.lrcat` beside it.
#[test]
fn a_lightcraft_library_with_a_catalog_inside_still_opens() {
let root = temp_dir("both");
let lib = root.join("Lightroom");
{
let mut s = Session::new();
s.open_library(&lib, true).unwrap();
assert!(!s.catalog.is_empty());
s.close_library().unwrap();
}
std::fs::write(lib.join("Lightroom Catalog.lrcat"), b"x").unwrap();
std::fs::create_dir_all(lib.join("Lightroom Catalog Previews.lrdata")).unwrap();
assert_eq!(lightroom_catalog_at(&lib), None);
let mut s = Session::new();
s.open_library(&lib, false).unwrap();
assert!(!s.catalog.is_empty(), "the LightCraft library opened");
let _ = std::fs::remove_dir_all(&root);
}
/// Never crash, never scan without end: a huge folder, unreadable paths, odd names.
#[test]
fn odd_folders_never_fail_the_check() {
let root = temp_dir("odd");
// the catalog after the scan limit isn't looked for (sorted names put it last on most file systems; either answer is fine, the check returns)
let big = root.join("big");
std::fs::create_dir_all(&big).unwrap();
for i in 0..SCAN_LIMIT + 10 {
std::fs::write(big.join(format!("{i:06}.jpg")), b"").unwrap();
}
let _ = lightroom_catalog_at(&big);
assert_eq!(lightroom_catalog_at(&root.join("missing/deeper")), None);
assert_eq!(lightroom_catalog_at(Path::new("")), None);
assert_eq!(lightroom_catalog_at(Path::new("/")), None);
assert!(lightroom_catalog_at(Path::new("x.LRCAT")).is_some(), "case-insensitive");
// a one-letter name with a multibyte character never cuts inside it
let _ = lightroom_catalog_at(&root.join("é"));
let _ = lightroom_catalog_at(&root.join("ataé-data"));
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
std::fs::create_dir_all(&bad).unwrap();
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe"));
std::fs::create_dir_all(&lr).unwrap();
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
std::fs::write(lr.join("b.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped");
// a symlink to a catalog folder is one too; a dangling one is not
std::os::unix::fs::symlink(&lr, root.join("link")).unwrap();
assert!(lightroom_catalog_at(&root.join("link")).is_some());
std::os::unix::fs::symlink(root.join("nowhere"), root.join("dangling")).unwrap();
assert_eq!(lightroom_catalog_at(&root.join("dangling")), None);
}
let _ = std::fs::remove_dir_all(&root);
}
+3 -1
View File
@@ -2039,5 +2039,7 @@
"Downloading model": "Modell wird heruntergeladen",
"Finding faces": "Gesichter werden gesucht",
"Synchronizing folder": "Ordner wird synchronisiert",
"Looking for changes": "Änderungen werden gesucht"
"Looking for changes": "Änderungen werden gesucht",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Dies ist ein Lightroom-Katalog, keine LightCraft-Bibliothek. Stattdessen in eine LightCraft-Bibliothek importieren?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Der Import liest den Katalog, ohne ihn zu ändern, und fügt seine Fotos, Bearbeitungen, Stichwörter und Sammlungen der jetzt geöffneten Bibliothek hinzu."
}
+3 -1
View File
@@ -1884,5 +1884,7 @@
"Downloading model": "Descargando modelo",
"Finding faces": "Buscando caras",
"Synchronizing folder": "Sincronizando carpeta",
"Looking for changes": "Buscando cambios"
"Looking for changes": "Buscando cambios",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Esto es un catálogo de Lightroom, no una biblioteca de LightCraft. ¿Importarlo a una biblioteca de LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "La importación lee el catálogo sin modificarlo y añade sus fotos, ediciones, palabras clave y colecciones a la biblioteca abierta ahora."
}
+3 -1
View File
@@ -1884,5 +1884,7 @@
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
"Resize panels": "Resize panels",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Ceci est un catalogue Lightroom, pas une bibliothèque LightCraft. L’importer plutôt dans une bibliothèque LightCraft ?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L’importation lit le catalogue sans le modifier et ajoute ses photos, retouches, mots-clés et collections à la bibliothèque ouverte."
}
+3 -1
View File
@@ -1886,5 +1886,7 @@
"Downloading model": "モデルをダウンロード中",
"Finding faces": "顔を検索中",
"Synchronizing folder": "フォルダを同期中",
"Looking for changes": "変更を確認中"
"Looking for changes": "変更を確認中",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "これは Lightroom カタログで、LightCraft ライブラリではありません。代わりに LightCraft ライブラリに読み込みますか?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "読み込みはカタログを変更せずに読み取り、写真・編集・キーワード・コレクションを現在開いているライブラリに追加します。"
}
+3 -1
View File
@@ -1884,5 +1884,7 @@
"Downloading model": "Baixando modelo",
"Finding faces": "Procurando rostos",
"Synchronizing folder": "Sincronizando pasta",
"Looking for changes": "Procurando alterações"
"Looking for changes": "Procurando alterações",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Este é um catálogo do Lightroom, não uma biblioteca do LightCraft. Importá-lo para uma biblioteca do LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "A importação lê o catálogo sem alterá-lo e adiciona as fotos, edições, palavras-chave e coleções dele à biblioteca aberta agora."
}
+3 -1
View File
@@ -1598,5 +1598,7 @@
"Finding faces": "Поиск лиц",
"Quit Anyway": "Всё равно выйти",
"Synchronizing folder": "Синхронизация папки",
"Looking for changes": "Поиск изменений"
"Looking for changes": "Поиск изменений",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Это каталог Lightroom, а не библиотека LightCraft. Импортировать его в библиотеку LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Импорт читает каталог, не изменяя его, и добавляет его фото, правки, ключевые слова и коллекции в открытую сейчас библиотеку."
}
+3 -1
View File
@@ -2078,5 +2078,7 @@
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
"Resize panels": "Resize panels",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Це каталог Lightroom, а не бібліотека LightCraft. Імпортувати його до бібліотеки LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Імпорт читає каталог, не змінюючи його, і додає його фото, редагування, ключові слова та колекції до відкритої зараз бібліотеки."
}
+3 -1
View File
@@ -1884,5 +1884,7 @@
"Downloading model": "正在下载模型",
"Finding faces": "正在查找人脸",
"Synchronizing folder": "正在同步文件夹",
"Looking for changes": "正在查找更改"
"Looking for changes": "正在查找更改",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "这是 Lightroom 目录,不是 LightCraft 图库。要改为将其导入 LightCraft 图库吗?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "导入只读取目录、不做任何更改,并将其中的照片、编辑、关键字和收藏夹添加到当前打开的图库。"
}
+3 -1
View File
@@ -1884,5 +1884,7 @@
"Downloading model": "正在下載模型",
"Finding faces": "正在尋找臉孔",
"Synchronizing folder": "正在同步資料夾",
"Looking for changes": "正在尋找變更"
"Looking for changes": "正在尋找變更",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "這是 Lightroom 目錄,不是 LightCraft 照片庫。要改為將其匯入 LightCraft 照片庫嗎?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。"
}
+2
View File
@@ -74,6 +74,8 @@ mod tests_menubar;
#[cfg(test)]
mod tests_offline;
#[cfg(test)]
mod tests_open_lightroom;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_preview_limit;
+20 -15
View File
@@ -227,7 +227,12 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) ->
})
}
fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) {
/// The task is over: keep its result (with the catalog's `path`) for `status` and say how it went.
fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, mut value: Value) {
let kind = task.kind;
if let Some(o) = value.as_object_mut() {
o.entry("path").or_insert_with(|| json!(task.path));
}
let message = if let Some(e) = value.get("error").and_then(Value::as_str) {
let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}"));
if matches!(kind, Kind::Inspect) {
@@ -343,26 +348,26 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
Err(mpsc::TryRecvError::Disconnected) => {
terminal(app, ctx, task.kind, json!({"error": "Lightroom worker stopped unexpectedly"}));
terminal(app, ctx, &task, json!({"error": "Lightroom worker stopped unexpectedly"}));
return;
}
};
match message {
Message::Inspected(result) => match result {
Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, task.kind, report),
Ok(_) => terminal(app, ctx, task.kind, json!({"cancelled": true})),
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, task.kind, json!({"cancelled": true})),
Err(e) => terminal(app, ctx, task.kind, json!({"error": e})),
Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, report),
Ok(_) => terminal(app, ctx, &task, json!({"cancelled": true})),
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, &task, json!({"cancelled": true})),
Err(e) => terminal(app, ctx, &task, json!({"error": e})),
},
Message::Prepared(result) => {
let prepared = match result {
Ok(p) => p,
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => {
terminal(app, ctx, task.kind, json!({"cancelled": true}));
terminal(app, ctx, &task, json!({"cancelled": true}));
return;
}
Err(e) => {
terminal(app, ctx, task.kind, json!({"error": e}));
terminal(app, ctx, &task, json!({"error": e}));
return;
}
};
@@ -371,7 +376,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
terminal(
app,
ctx,
task.kind,
&task,
if task.cancel.load(Ordering::Relaxed) {
json!({"cancelled": true})
} else {
@@ -394,7 +399,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let (tx, rx) = mpsc::channel();
let commit_error = committed.as_ref().err().map(ToString::to_string);
let Some(finalization) = finalization else {
terminal(app, ctx, task.kind, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));
terminal(app, ctx, &task, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));
return;
};
if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) {
@@ -402,7 +407,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(error) = commit_error {
result["error"] = json!(error);
}
terminal(app, ctx, task.kind, result);
terminal(app, ctx, &task, result);
return;
}
task.rx = rx;
@@ -418,14 +423,14 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let pending = app.lightroom_last.take().unwrap_or(Value::Null);
let report = pending.get("report").cloned().unwrap_or(Value::Null);
match result {
Ok(()) if pending.get("error").is_none() => terminal(app, ctx, task.kind, report),
Ok(()) => terminal(app, ctx, task.kind, json!({"report": report, "error": pending["error"].clone()})),
Ok(()) if pending.get("error").is_none() => terminal(app, ctx, &task, report),
Ok(()) => terminal(app, ctx, &task, json!({"report": report, "error": pending["error"].clone()})),
Err(e) => {
let mut value = json!({"report": report, "indexWarning": e});
if let Some(error) = pending.get("error") {
value["error"] = error.clone();
}
terminal(app, ctx, task.kind, value);
terminal(app, ctx, &task, value);
}
}
}
@@ -509,7 +514,7 @@ mod tests {
tx.send(Message::Prepared(Err("cancelled".into()))).unwrap();
h.step();
assert!(h.app.lightroom.is_none());
assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true})));
assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true, "path": "fixture.lrcat"})));
assert!(h.app.session.activity.list().is_empty());
}
+17
View File
@@ -112,6 +112,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Merge { opts } => opts.title(),
Dialog::Settings { .. } => "Settings",
Dialog::ConfirmDelete { .. } => "Delete Photos",
Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog",
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
Dialog::SynchronizeFolder { .. } => "Synchronize Folder",
@@ -140,6 +141,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::SmartRules { .. } => 680.0,
Dialog::AllMetadata { .. } => 620.0,
Dialog::FaceModel { .. } => 460.0,
Dialog::LightroomCatalog { .. } => 440.0,
_ => 380.0,
})
.show(ctx, |ui| {
@@ -882,6 +884,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d),
Dialog::LightroomCatalog { folder, catalog } => {
ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?"));
ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim));
ui.label(
egui::RichText::new(crate::i18n::tr(
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.",
))
.color(t.text_dim),
);
}
Dialog::RemoveFolder { name, count, path, disk } => {
ui.label(crate::i18n::tr_format!(
"Remove “{name}” and its {count} photo{} from the library?",
@@ -988,6 +1000,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::KeywordTag { editing: None, .. } => "Create",
Dialog::KeywordTag { .. } => "Save",
Dialog::RemoveFolder { .. } => "Remove",
Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…",
Dialog::SynchronizeFolder { .. } => "Synchronize",
Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue",
Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "",
@@ -1370,6 +1383,10 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
Dialog::Import { opts } => crate::import::start(app, opts),
Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})),
// the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it
Dialog::LightroomCatalog { catalog, .. } => {
app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c})))
}
Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})),
d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d),
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null),
+17
View File
@@ -624,6 +624,11 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`,
/// or a folder chosen in a dialog. Remembered as the library to open at launch.
///
/// A Lightroom Classic catalog (its folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat`
/// itself, see [`lightcraft_engine::lightroom_folder`]) is not opened: the current library stays
/// and a dialog offers Import Lightroom Catalog… instead; returns `{opened: false,
/// lightroomCatalog: <.lrcat path or null>, path}`.
pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
if crate::lightroom_import::is_running(app) {
return Err("wait for Lightroom catalog import to finish before switching libraries".into());
@@ -642,6 +647,18 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
}
}
};
// A Lightroom catalog folder (or part of one) is not a library: before closing the library
// open now, offer to import it instead (issue #891). The engine refuses it too.
if let Some(place) = lightcraft_engine::lightroom_folder::lightroom_catalog_at(std::path::Path::new(&path)) {
let catalog = place.catalog.map(|c| c.to_string_lossy().into_owned());
let err = lightcraft_engine::EngineError::LightroomCatalog { path: path.clone().into(), catalog: catalog.clone().map(Into::into) };
// the library problem window (no library could be opened) shows the error instead
if app.library_problem.is_some() {
return Err(err.to_string());
}
app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() });
return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path}));
}
app.session.close_library().map_err(|e| e.to_string())?;
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
app.lightroom_last = None;
+7
View File
@@ -725,6 +725,13 @@ pub enum Dialog {
#[serde(default)]
error: Option<String>,
},
/// Open Library was given a Lightroom Classic catalog (issue #891): offer File ▸ Import
/// Lightroom Catalog… with `catalog` (the `.lrcat` found; `None`: ask for it) instead.
/// `folder`: what was chosen.
LightroomCatalog {
folder: String,
catalog: Option<String>,
},
/// Confirm moving photos to Recently Deleted.
ConfirmDelete {
count: usize,
@@ -0,0 +1,99 @@
//! Issue #891: File ▸ Open Library… given a Lightroom Classic catalog folder doesn't make a
//! LightCraft library inside it. The library open now stays, and a dialog offers Import Lightroom
//! Catalog… with the catalog found, which starts the import of that `.lrcat`.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::Dialog;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
fn has(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
fn names(dir: &std::path::Path) -> Vec<std::ffi::OsString> {
let mut v: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect();
v.sort();
v
}
#[test]
fn open_library_on_a_lightroom_catalog_offers_its_import() {
let dir = std::env::temp_dir().join(format!("lc-ui-openlrcat-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let lib = dir.join("LightCraft Library");
let lr = dir.join("Lightroom");
std::fs::create_dir_all(lr.join("Lightroom Catalog Previews.lrdata")).unwrap();
std::fs::create_dir_all(lr.join("Lightroom Catalog.lrcat-data")).unwrap();
std::fs::create_dir_all(lr.join("Backups")).unwrap();
let lrcat = lr.join("Lightroom Catalog.lrcat");
std::fs::write(&lrcat, b"not really SQLite").unwrap();
let before = names(&lr);
let mut session = lightcraft_engine::Session::new().with_fs();
session.open_library(&lib, true).unwrap();
let photos = session.catalog.len();
assert!(photos > 0);
let picked = lr.to_string_lossy().to_string();
let png = |img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
};
let services = Services {
pick_folder: Some(Box::new(move || Some(picked.clone()))),
png: Some(Box::new(png)),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
};
let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0);
h.step();
// File ▸ Open Library…, the Lightroom folder chosen in the folder dialog
let r = h.app.run("app.openLibrary", json!({})).unwrap();
assert_eq!(r["opened"], false, "{r}");
assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}");
h.step();
h.step();
assert!(
matches!(&h.app.ui.dialog, Some(Dialog::LightroomCatalog { catalog: Some(c), .. }) if *c == lrcat.to_string_lossy()),
"{:?}",
h.app.ui.dialog
);
assert!(has(&h, "button:dialogOk") && has(&h, "button:dialogCancel"));
// the library open before is still open, and nothing was made in Lightroom's folder
assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone()));
assert_eq!(h.app.session.catalog.len(), photos);
assert_eq!(names(&lr), before);
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("prompt-891.png").to_string_lossy(), "headless": true}), T);
assert_eq!(r["ok"], true, "{r}");
}
// Cancel: nothing happens
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T)["ok"], true);
h.step();
assert!(h.app.ui.dialog.is_none());
assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none());
// a previews folder given by path (control channel, agents): the same offer
let r = h.app.run("app.openLibrary", json!({"path": lr.join("Lightroom Catalog Previews.lrdata").to_string_lossy()})).unwrap();
assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}");
h.step();
h.step();
// Import Lightroom Catalog…: the import of that catalog starts (no file dialog), into the
// library open now. (This fake catalog then fails to read: only its path matters here.)
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true);
h.step();
assert!(h.app.ui.dialog.is_none());
h.step_until(T, |h| h.app.lightroom.is_none());
let last = h.app.lightroom_last.clone().unwrap_or_default();
assert_eq!(last["path"], json!(lrcat), "{last}");
assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone()));
assert_eq!(names(&lr), before, "the catalog is only read");
drop(h);
let _ = std::fs::remove_dir_all(&dir);
}
+12
View File
@@ -114,6 +114,18 @@ are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Co
`button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and
writes nothing. `app.openLibrary` opening a library ends it.
### Open Library on a Lightroom catalog
`app.openLibrary` never makes a LightCraft library inside a Lightroom Classic catalog (issue #891): given the catalog
folder (it holds a `*.lrcat`), one of its `*.lrdata` / `*.lrcat-data` folders or `Backups/`, the `.lrcat` itself or
its `-wal` / `-shm` / `.lock` files, it keeps the library open now and returns `{opened: false, lightroomCatalog:
<the .lrcat found, or null>, path}`; the window shows a dialog whose `button:dialogOk` (Import Lightroom Catalog…, or
`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one) and
`button:dialogCancel` (`ui.dialog.cancel`) does nothing. A folder that is a LightCraft library (`catalog.snap` / `catalog.log`) always
opens as one. With no library open (the library problem window) it is an error instead. The engine refuses the same
places (`EngineError::LightroomCatalog`, so `lightcraft-cli --library` and `mcp --library` too). `library.importLightroom
{status: true}` reports the catalog's `path` after the task ends as well as while it runs.
## Headless rendering (no window, no GPU)
The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by
+4
View File
@@ -4,6 +4,10 @@ File → Import Lightroom Catalog… opens `.lrcat` directly. Lightroom, Python,
and a C SQLite runtime are not required. The source database is read-only, including committed
`-wal` pages; a catalog that changes during the read is rejected. LightCraft owns subsequent edits.
File → Open Library… given a Lightroom catalog folder (or an `.lrdata` / `.lrcat-data` folder, or the `.lrcat`)
doesn't make a LightCraft library there: it offers this import instead, with the catalog found
(`crates/engine/src/lightroom_folder.rs`, issue #891). `lightcraft-cli --library` refuses those places too.
Equivalent commands:
```text
+1 -1
View File
@@ -679,7 +679,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 |
| 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, 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 |
| 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, 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`) |
| 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) |
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove DiLine truncated
+8
View File
@@ -1,5 +1,13 @@
# What's new in LightCraft
## 2026-10-11
### Open Library knows a Lightroom catalog
- Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /
`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The
library open now stays, and LightCraft says "This is a Lightroom catalog, not a LightCraft library" and offers
**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.
## 2026-10-10
### Activity stack