This commit is contained in:
Claude 2026-10-11 11:43:08 +00:00
commit 33a29dcdc3
No known key found for this signature in database
24 changed files with 550 additions and 25 deletions

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}"),
}
}

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;

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

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
}

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

View File

@ -2041,5 +2041,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."
}

View File

@ -1886,5 +1886,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."
}

View File

@ -1886,5 +1886,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."
}

View File

@ -1888,5 +1888,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.": "読み込みはカタログを変更せずに読み取り、写真・編集・キーワード・コレクションを現在開いているライブラリに追加します。"
}

View File

@ -1886,5 +1886,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."
}

View File

@ -1600,5 +1600,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.": "Импорт читает каталог, не изменяя его, и добавляет его фото, правки, ключевые слова и коллекции в открытую сейчас библиотеку."
}

View File

@ -2080,5 +2080,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.": "Імпорт читає каталог, не змінюючи його, і додає його фото, редагування, ключові слова та колекції до відкритої зараз бібліотеки."
}

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.": "导入只读取目录、不做任何更改,并将其中的照片、编辑、关键字和收藏夹添加到当前打开的图库。"
}

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.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。"
}

View File

@ -87,6 +87,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;

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());
}

View File

@ -137,6 +137,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",
@ -165,6 +166,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| {
@ -919,6 +921,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?",
@ -1030,6 +1042,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 => "",
@ -1413,6 +1426,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),

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;

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,

View File

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

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

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

File diff suppressed because one or more lines are too long

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