#2672: ask whether a Lightroom catalog merges into the open library or gets a new one

LRC-LIB-CATALOG-IMPORT. Before File ▸ Import Lightroom Catalog… starts, a
window asks where the catalog goes: merge into the open library (named,
with its photo count; preselected when it is empty) or create a new
library named after the catalog file, next to the open library (both
editable, Choose… picks the folder). The new library is made and opened
through app.newLibrary (its rules: an empty or new folder, refused while
another import runs), then the catalog is imported into it. Cancel does
nothing. Open Library… on a Lightroom catalog goes through the same choice.

Opt-out: Settings ▸ General ▸ When importing a Lightroom catalog (Ask
where to import it / Always merge into the open library), saved as
ui.json settings.lightroomImport (lenient, default ask); the window's
"Don't ask again" picks Always merge. A session in memory merges without
asking, as before.

file.importLightroom takes {into?: open|new, name?, location?} to choose
without the window. UI layer only: library.importLightroom, the engine
and lightroom_import.rs are unchanged.

Tests: decision table (setting x library on disk/in memory x photos x
into), params, default name; headless window / merge / new library /
cancel / empty library / setting / Don't ask again persisted / folder
not empty / into / in memory / stale library; tests_open_lightroom
follows the new window. Format messages in all 17 catalogs, plain
strings in French. Docs: lightroom-catalog-import, control-protocol,
parity-checklist, whats-new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
This commit is contained in:
Claude
2026-10-23 10:19:04 +00:00
parent 23e181283c
commit e9b3839780
31 changed files with 989 additions and 38 deletions
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Procházení složek – složky: {folders}, soubory: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí.",
"Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben",
"Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?",
"Listing folders — folders: {folders}, files: {files}": "Ordner werden aufgelistet – Ordner: {folders}, Dateien: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.",
"Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”",
"Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?",
"Listing folders — folders: {folders}, files: {files}": "Listando carpetas — carpetas: {folders}, archivos: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.",
"Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” on jo henkilö. Yhdistäminen tekee kahdesta yhden henkilön; Kumoa erottaa ne uudelleen.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… ja {more} lisää",
"…and {more} more": "…ja {more} lisää"
"…and {more} more": "…ja {more} lisää",
"Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",
"Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »",
"Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?",
"Listing folders — folders: {folders}, files: {files}": "Liste des dossiers — dossiers : {folders}, fichiers : {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place.",
"Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?",
"Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})"
}
+13 -1
View File
@@ -2340,5 +2340,17 @@
"Checking the GPU…": "Vérification du GPU…",
"CPU": "Processeur",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.",
"Checking the GPU for faces": "Vérification du GPU pour les visages"
"Checking the GPU for faces": "Vérification du GPU pour les visages",
"The library is empty: the catalog's photos, collections and edits become its first.": "La bibliothèque est vide : les photos, collections et retouches du catalogue seront les premières.",
"The catalog's photos, collections and edits join the ones already in this library.": "Les photos, collections et retouches du catalogue rejoignent celles déjà présentes dans cette bibliothèque.",
"Create a new library": "Créer une nouvelle bibliothèque",
"LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty.": "LightCraft ouvre la nouvelle bibliothèque, puis y importe le catalogue. Son dossier doit être nouveau ou vide.",
"Don't ask again (always merge into the open library)": "Ne plus demander (toujours fusionner avec la bibliothèque ouverte)",
"To be asked again: Settings → General → When importing a Lightroom catalog.": "Pour que la question revienne : Réglages → Général → À l’importation d’un catalogue Lightroom.",
"Create Library and Import": "Créer la bibliothèque et importer",
"Another library was opened since this window appeared: nothing was imported": "Une autre bibliothèque a été ouverte depuis l’apparition de cette fenêtre : rien n’a été importé",
"When importing a Lightroom catalog": "À l’importation d’un catalogue Lightroom",
"Ask where to import it": "Demander où l’importer",
"Always merge into the open library": "Toujours fusionner avec la bibliothèque ouverte",
"The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library.": "L’importation lit le catalogue sans le modifier. Ensuite, choisissez si ses photos, retouches, mots-clés et collections rejoignent la bibliothèque ouverte ou une nouvelle bibliothèque."
}
+3 -1
View File
@@ -297,5 +297,7 @@
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}"
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",
"Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",
"Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}",
"Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro.",
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}"
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}",
"Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません",
"Listing folders — folders: {folders}, files: {files}": "フォルダーを一覧中 — フォルダー:{folders}、ファイル:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})",
"Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか?",
"Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Listing folders — folders: {folders}, files: {files}": "საქაღალდეების ჩამოთვლა — საქაღალდეები: {folders}, ფაილები: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Przeglądanie folderów — foldery: {folders}, pliki: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je.",
"Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem",
"Listing folders — folders: {folders}, files: {files}": "Listando pastas — pastas: {folders}, arquivos: {files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})",
"Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Просмотр папок — папок: {folders}, файлов: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их.",
"Where should the Lightroom catalog “{catalog}” go?": "Куда импортировать каталог Lightroom «{catalog}»?",
"Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}"
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",
"Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?",
"Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Перегляд тек — тек: {folders}, файлів: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх.",
"Where should the Lightroom catalog “{catalog}” go?": "Куди імпортувати каталог Lightroom «{catalog}»?",
"Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… và {more} nữa",
"…and {more} more": "…and {more} more"
"…and {more} more": "…and {more} more",
"Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?",
"Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存",
"Listing folders — folders: {folders}, files: {files}": "正在列出文件夹 — 文件夹:{folders},文件:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})",
"Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目录“{catalog}”要导入到哪里?",
"Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}"
}
+3 -1
View File
@@ -297,5 +297,7 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體",
"Listing folders — folders: {folders}, files: {files}": "正在列出資料夾 — 資料夾:{folders},檔案:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})",
"Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目錄「{catalog}」要匯入到哪裡?",
"Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}"
}
+1
View File
@@ -394,6 +394,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
| crate::state::Dialog::SaveEffect { .. }
))
|| (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d))
|| (r.is_err() && crate::lightroom_destination::keeps_open_on_error(app, &d))
{
app.ui.dialog = Some(d);
}
+3
View File
@@ -21,6 +21,7 @@ pub mod headless;
pub mod i18n;
pub mod icons;
pub mod import;
pub mod lightroom_destination;
pub mod lightroom_import;
pub mod links;
pub mod menu_level;
@@ -121,6 +122,8 @@ mod tests_layout_image;
#[cfg(test)]
mod tests_library_problem;
#[cfg(test)]
mod tests_lightroom_destination;
#[cfg(test)]
mod tests_lights_out;
#[cfg(test)]
mod tests_mask_effects;
+365
View File
@@ -0,0 +1,365 @@
//! File ▸ Import Lightroom Catalog…: where the catalog goes (issue #2672).
//!
//! Before the import starts, a window asks whether the catalog's photos, collections and edits
//! merge into the library open now or go into a new library, named after the catalog and put next
//! to the open one. The new library is made and opened as File ▸ New Library… does
//! (`app.newLibrary`: refused in a folder that isn't empty, or while another import runs), then the
//! catalog is imported into it. Settings ▸ General ▸ Library (or the window's "Don't ask again")
//! turns the question off: the catalog then merges into the open library, as it always did.
//!
//! Only the app command `file.importLightroom` changes: it takes `{into?: "open" | "new", name?,
//! location?}` so the control channel can make the choice without the window. The import itself
//! (`library.importLightroom`, [`crate::lightroom_import`]) is unchanged. A session with no
//! library on disk (in memory) has nothing to put a new library next to: it merges without asking.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::state::{Dialog, LightroomImport};
/// Where `file.importLightroom {into}` puts the catalog.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Destination {
/// `"open"`: merge into the library open now.
Open,
/// `"new"`: a new library, opened before the import.
New,
}
/// The destination params of `file.importLightroom`, checked.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Request {
/// `into`; `None`: ask (or merge, as the setting says).
pub into: Option<Destination>,
/// `name`: the new library's name (default: the catalog file's name without `.lrcat`).
pub name: Option<String>,
/// `location`: the folder the new library goes in (default: the open library's parent folder).
pub location: Option<String>,
}
impl Request {
/// Read `{into?, name?, location?}` from `file.importLightroom`'s params. A wrong type or an
/// unknown `into` is an error, before any file dialog opens.
pub fn parse(p: &Value) -> Result<Request, String> {
let into = match p.get("into") {
None | Some(Value::Null) => None,
Some(Value::String(s)) if s == "open" => Some(Destination::Open),
Some(Value::String(s)) if s == "new" => Some(Destination::New),
Some(v) => return Err(format!("file.importLightroom: `into` must be \"open\" or \"new\", not {v}")),
};
let text = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(v) => Err(format!("file.importLightroom: `{key}` must be a string, not {v}")),
};
Ok(Request { into, name: text("name")?, location: text("location")? })
}
}
/// What `file.importLightroom` does with a catalog.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Plan {
/// Import into the library open now.
Merge,
/// Make a new library, open it, import into it.
NewLibrary,
/// Show the window; `new_library`: "Create a new library" is the choice it opens with.
Ask { new_library: bool },
}
/// The decision, from the setting, the open library (`on_disk`: saved in a folder, not in memory;
/// `photos`: how many it holds) and the command's `into`. `into` always wins; without it a library
/// in memory, or the setting "Always merge", merges; otherwise the window asks, opening on "Merge"
/// when the library is empty (there is nothing to merge with) and on "Create a new library" when
/// it holds photos.
pub fn decide(setting: LightroomImport, on_disk: bool, photos: usize, into: Option<Destination>) -> Plan {
match into {
Some(Destination::Open) => Plan::Merge,
Some(Destination::New) => Plan::NewLibrary,
None if !on_disk || setting == LightroomImport::AlwaysMerge => Plan::Merge,
None => Plan::Ask { new_library: photos > 0 },
}
}
/// The photos in the open library (not those in Recently Deleted or browsed under Local).
fn library_photos(app: &LightcraftApp) -> usize {
app.session.catalog.photos().filter(|p| p.in_library()).count()
}
/// The open library's folder, when it is saved on disk.
fn library_dir(app: &LightcraftApp) -> Option<PathBuf> {
app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone())
}
/// Where a new library goes by default: next to the open one (its parent folder).
pub fn default_location(app: &LightcraftApp) -> Option<PathBuf> {
library_dir(app).and_then(|d| d.parent().map(Path::to_path_buf))
}
/// The new library's default name: the catalog's file name without `.lrcat` (`Travel.lrcat` →
/// `Travel`), or `Travel 2`, `Travel 3`… when `location` has something by that name already.
pub fn default_name(catalog: &Path, location: Option<&Path>) -> String {
let stem = catalog.file_stem().map(|s| s.to_string_lossy().trim().to_string()).filter(|s| !s.is_empty());
let stem = stem.unwrap_or_else(|| "Lightroom Catalog".to_string());
match location {
Some(loc) => crate::panels::settings::free_name(loc, &stem).file_name().map_or(stem.clone(), |n| n.to_string_lossy().into_owned()),
None => stem,
}
}
/// Refuse while a Lightroom catalog task or another import runs (as the import itself would),
/// before a window asks a question whose answer couldn't be carried out.
fn busy(app: &LightcraftApp) -> Result<(), String> {
if crate::lightroom_import::is_running(app) {
return Err("a Lightroom catalog task is already running; wait for it to finish".into());
}
if app.import.is_some() || app.scan.is_some() {
return Err("an import is running; wait for it to finish before importing a Lightroom catalog".into());
}
Ok(())
}
/// `file.importLightroom` once the catalog `path` is known: merge, make a new library first, or
/// ask, as [`decide`] says. Returns the import's start (`{running, kind, path}`), with
/// `library: {path, name, photos}` when a new library was made, or `{asking: true, path, into}`
/// while the window asks (`into`: the choice it opens with).
pub fn import(app: &mut LightcraftApp, path: &str, req: &Request) -> Result<Value, String> {
// the browser build can't import a catalog at all: the import says so (and no library is made)
if cfg!(target_arch = "wasm32") {
return app.run("library.importLightroom", json!({"path": path}));
}
let on_disk = library_dir(app).is_some();
match decide(app.ui.settings.lightroom_import, on_disk, library_photos(app), req.into) {
Plan::Merge => app.run("library.importLightroom", json!({"path": path})),
Plan::NewLibrary => into_new_library(app, path, req),
Plan::Ask { new_library } => {
busy(app)?;
if !Path::new(path).is_file() {
return Err(format!("{path}: there is no Lightroom catalog file here"));
}
let location = req.location.clone().or_else(|| default_location(app).map(|d| d.to_string_lossy().into_owned())).unwrap_or_default();
let name = req.name.clone().unwrap_or_else(|| default_name(Path::new(path), (!location.is_empty()).then_some(Path::new(&location))));
app.ui.dialog = Some(Dialog::LightroomDestination {
catalog: path.to_string(),
library: crate::panels::settings::open_library_name(app).unwrap_or_default(),
photos: library_photos(app),
new_library,
name,
location,
dont_ask: false,
generation: app.session.library_generation(),
});
Ok(json!({"asking": true, "path": path, "into": if new_library { "new" } else { "open" }}))
}
}
}
/// Make the new library (`app.newLibrary {path: location, name}`: its rules, its errors), open it
/// and import the catalog there. The catalog is checked first, so a mistyped path doesn't leave an
/// empty library behind.
fn into_new_library(app: &mut LightcraftApp, path: &str, req: &Request) -> Result<Value, String> {
busy(app)?;
if !Path::new(path).is_file() {
return Err(format!("{path}: there is no Lightroom catalog file here"));
}
let location = match req.location.as_deref().map(str::trim) {
Some("") => return Err("file.importLightroom: choose where to put the new library (`location`)".into()),
Some(l) => PathBuf::from(l),
None => default_location(app).ok_or("file.importLightroom: no library is open on disk to put the new one next to; give `location`")?,
};
if !location.is_absolute() {
return Err(format!("file.importLightroom: {}: the location must be a full folder path", location.display()));
}
let name = req.name.clone().unwrap_or_else(|| default_name(Path::new(path), Some(&location)));
let library = app.run("app.newLibrary", json!({"path": location.to_string_lossy(), "name": name}))?;
let mut started = app.run("library.importLightroom", json!({"path": path}))?;
if let Some(o) = started.as_object_mut() {
o.insert("library".into(), library);
}
Ok(started)
}
/// The window's choice as `file.importLightroom` params.
fn params(catalog: &str, new_library: bool, name: &str, location: &str) -> Value {
if new_library { json!({"path": catalog, "into": "new", "name": name, "location": location}) } else { json!({"path": catalog, "into": "open"}) }
}
/// The window's confirm button: import as chosen. "Don't ask again" is kept once a merge has
/// started (it only applies to merging). Another library opened while the window was up (by an
/// agent, the control channel) is not merged into.
pub fn confirm(app: &mut LightcraftApp, dialog: &Dialog) -> Result<Value, String> {
let Dialog::LightroomDestination { catalog, new_library, name, location, dont_ask, generation, .. } = dialog else {
return Err("not the Lightroom catalog destination window".into());
};
if !*new_library && *generation != app.session.library_generation() {
return Err(crate::i18n::tr("Another library was opened since this window appeared: nothing was imported").to_string());
}
let r = app.run("file.importLightroom", params(catalog, *new_library, name, location))?;
if !*new_library && *dont_ask {
app.ui.settings.lightroom_import = LightroomImport::AlwaysMerge;
}
Ok(r)
}
/// After a failed confirm the window stays open to be corrected (a folder that isn't empty, a
/// name a folder can't have), unless the library it named has gone.
pub fn keeps_open_on_error(app: &LightcraftApp, dialog: &Dialog) -> bool {
matches!(dialog, Dialog::LightroomDestination { generation, .. } if *generation == app.session.library_generation())
}
/// The confirm button's label.
pub fn ok_label(dialog: &Dialog) -> &'static str {
match dialog {
Dialog::LightroomDestination { new_library: true, .. } => "Create Library and Import",
_ => "Import",
}
}
/// The window's body: the two choices, the new library's name and location, and "Don't ask again".
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) {
let Dialog::LightroomDestination { catalog, library, photos, new_library, name, location, dont_ask, .. } = dialog else { return };
let t = crate::theme::Tokens::get(ui.ctx());
let tr = crate::i18n::tr;
let file = Path::new(catalog.as_str()).file_name().map_or_else(|| catalog.clone(), |n| n.to_string_lossy().into_owned());
ui.label(crate::i18n::tr_format!("Where should the Lightroom catalog “{catalog}” go?", catalog = file));
ui.label(egui::RichText::new(catalog.as_str()).color(t.text_dim));
ui.add_space(4.0);
let n = *photos;
let merge = crate::i18n::tr_format!(
"Merge into the open library “{library}” ({count} photo{})",
if n == 1 { "" } else { "s" },
library = library,
count = n
);
let r = ui.radio_value(new_library, false, merge);
crate::widgets::register(ui.ctx(), "radio:lightroomDestination-open", r.rect);
let indent = ui.spacing().icon_width + ui.spacing().icon_spacing;
let note = |ui: &mut egui::Ui, text: &str| {
ui.horizontal(|ui| {
ui.add_space(indent);
ui.add(egui::Label::new(egui::RichText::new(text).color(t.text_dim)).wrap());
});
};
note(
ui,
if n == 0 {
tr("The library is empty: the catalog's photos, collections and edits become its first.")
} else {
tr("The catalog's photos, collections and edits join the ones already in this library.")
},
);
let r = ui.radio_value(new_library, true, tr("Create a new library"));
crate::widgets::register(ui.ctx(), "radio:lightroomDestination-new", r.rect);
ui.add_enabled_ui(*new_library, |ui| {
// a field's caption, left-aligned in a column as wide as the longer one (in any language)
let font = egui::TextStyle::Body.resolve(ui.style());
let caption_w = [tr("Library name"), tr("Location")]
.iter()
.map(|c| ui.painter().layout_no_wrap(c.to_string(), font.clone(), t.text_label).size().x)
.fold(60.0_f32, f32::max)
+ 8.0;
let caption = |ui: &mut egui::Ui, text: &str| {
ui.allocate_ui_with_layout(egui::vec2(caption_w, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(caption_w);
ui.label(egui::RichText::new(text).color(t.text_label));
});
};
ui.horizontal(|ui| {
ui.add_space(indent);
caption(ui, tr("Library name"));
crate::text_field::TextField::singleline("field:lightroomLibraryName", name)
.label(tr("Library name"))
.width(ui.available_width().max(120.0))
.show(ui);
});
ui.horizontal(|ui| {
ui.add_space(indent);
caption(ui, tr("Location"));
let choose = app.services.pick_folder.is_some();
let button_w = if choose { 80.0 } else { 0.0 };
crate::text_field::TextField::singleline("field:lightroomLibraryLocation", location)
.label(tr("Location"))
.width((ui.available_width() - button_w).max(120.0))
.show(ui);
if choose
&& crate::widgets::text_button(ui, "lightroomLibraryChoose", tr("Choose…"), false).clicked()
&& let Some(pick) = app.services.pick_folder.as_mut()
&& let Some(d) = pick()
{
*location = d;
}
});
note(ui, tr("LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty."));
});
ui.add_space(4.0);
// (it only speaks of merging: unticked and greyed out while a new library is chosen)
let mut ticked = *dont_ask && !*new_library;
let c = ui.add_enabled(!*new_library, egui::Checkbox::new(&mut ticked, tr("Don't ask again (always merge into the open library)")));
crate::widgets::register(ui.ctx(), "check:lightroomDontAsk", c.rect);
if !*new_library {
*dont_ask = ticked;
}
ui.label(egui::RichText::new(tr("To be asked again: Settings → General → When importing a Lightroom catalog.")).size(11.0).color(t.text_dim));
}
#[cfg(test)]
mod tests {
use super::*;
/// Every combination of the setting, an empty or full library on disk or in memory, and `into`.
#[test]
fn the_setting_the_library_and_into_decide() {
use Destination::{New, Open};
use LightroomImport::{AlwaysMerge, Ask};
for setting in [Ask, AlwaysMerge] {
for on_disk in [true, false] {
for photos in [0, 5] {
// `into` always wins: the control channel acts without asking
assert_eq!(decide(setting, on_disk, photos, Some(Open)), Plan::Merge);
assert_eq!(decide(setting, on_disk, photos, Some(New)), Plan::NewLibrary);
}
}
}
// without it: ask, opening on Merge for an empty library and on a new library for a full one
assert_eq!(decide(Ask, true, 0, None), Plan::Ask { new_library: false });
assert_eq!(decide(Ask, true, 5, None), Plan::Ask { new_library: true });
// "Always merge" doesn't ask
assert_eq!(decide(AlwaysMerge, true, 0, None), Plan::Merge);
assert_eq!(decide(AlwaysMerge, true, 5, None), Plan::Merge);
// a library in memory has no folder to put a new one next to: it merges, as before
assert_eq!(decide(Ask, false, 0, None), Plan::Merge);
assert_eq!(decide(Ask, false, 5, None), Plan::Merge);
}
/// `into`, `name` and `location` are read, and a wrong one is an error naming the command.
#[test]
fn params_are_checked_before_anything_happens() {
assert_eq!(Request::parse(&json!({})).unwrap(), Request::default());
assert_eq!(Request::parse(&json!({"into": null})).unwrap(), Request::default());
let r = Request::parse(&json!({"into": "new", "name": "Travel", "location": "/photos"})).unwrap();
assert_eq!(r, Request { into: Some(Destination::New), name: Some("Travel".into()), location: Some("/photos".into()) });
assert_eq!(Request::parse(&json!({"into": "open"})).unwrap().into, Some(Destination::Open));
for bad in [json!({"into": "elsewhere"}), json!({"into": 1}), json!({"name": 3}), json!({"location": ["a"]})] {
let err = Request::parse(&bad).unwrap_err();
assert!(err.starts_with("file.importLightroom:"), "{err}");
}
}
/// The catalog's file name, or the next free one in the location.
#[test]
fn the_new_library_is_named_after_the_catalog() {
assert_eq!(default_name(Path::new("/x/Travel.lrcat"), None), "Travel");
assert_eq!(default_name(Path::new("/x/Lightroom Catalog-v13-3.lrcat"), None), "Lightroom Catalog-v13-3");
// nothing to name it after
assert_eq!(default_name(Path::new(""), None), "Lightroom Catalog");
// a name taken in the location: the next free one
let dir = std::env::temp_dir().join(format!("lc-ui-lrdest-name-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Travel")).unwrap();
assert_eq!(default_name(Path::new("/x/Travel.lrcat"), Some(&dir)), "Travel 2");
assert_eq!(default_name(Path::new("/x/Family.lrcat"), Some(&dir)), "Family");
let _ = std::fs::remove_dir_all(&dir);
}
}
+8 -1
View File
@@ -227,6 +227,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"),
("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"),
("file.addFolder", "Import from Folder…", None, "File"),
// {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or
// Settings) says (#2672, `lightroom_destination`)
("file.importLightroom", "Import Lightroom Catalog…", None, "File"),
("file.addFromDevice", "Import from Device", None, ""),
("file.findMissing", "Find Missing Photos…", None, "File"),
@@ -1614,6 +1616,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
import_into_album(app, p, r)
}
"file.importLightroom" => {
// where the catalog goes (#2672): checked before a file dialog opens
let req = match crate::lightroom_destination::Request::parse(p) {
Ok(req) => req,
Err(e) => return Some(Err(e)),
};
let path = match p.get("path").and_then(Value::as_str).map(str::to_string) {
Some(path) => Some(path),
None => {
@@ -1626,7 +1633,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
};
match path {
Some(path) => app.run("library.importLightroom", json!({"path":path})),
Some(path) => crate::lightroom_destination::import(app, &path, &req),
None => Ok(Value::Null),
}
}
+20 -6
View File
@@ -261,6 +261,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::ConfirmDelete { .. } => "Delete Photos",
Dialog::MovePhotos { .. } => "Move Photos",
Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog",
Dialog::LightroomDestination { .. } => "Import Lightroom Catalog",
Dialog::SaveEffect { .. } => "New Effect Preset",
Dialog::ManageEffects { .. } => "Manage Effect Presets",
Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset",
@@ -315,6 +316,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Print => 1100.0,
Dialog::FaceModel { .. } => 460.0,
Dialog::LightroomCatalog { .. } => 440.0,
Dialog::LightroomDestination { .. } => 480.0,
Dialog::ManageEffects { .. } => 560.0,
_ => 380.0,
})
@@ -1244,13 +1246,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
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),
);
// (the import then asks where the catalog goes, unless Settings says to merge, #2672)
let asks = app.ui.settings.lightroom_import == crate::state::LightroomImport::Ask
&& app.session.library.as_ref().is_some_and(|l| l.on_disk);
let what = if asks {
"The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library."
} else {
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now."
};
ui.label(egui::RichText::new(crate::i18n::tr(what)).color(t.text_dim));
}
d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d),
Dialog::RemoveFolder { name, count, path, disk } => {
ui.label(crate::i18n::tr_format!(
"Remove “{name}” and its {count} photo{} from the library?",
@@ -1368,6 +1374,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::KeywordTag { .. } => "Save",
Dialog::RemoveFolder { .. } => "Remove",
Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…",
d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d),
Dialog::SynchronizeFolder { .. } if informational => "Close",
Dialog::SynchronizeFolder { .. } => "Synchronize",
Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue",
@@ -1500,6 +1507,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
app.toast(ctx, e);
close = true;
}
// where a Lightroom catalog goes: stays open to be corrected (a folder that isn't
// empty), unless the library it names has gone
Err(e) if matches!(dlg, Dialog::LightroomDestination { .. }) => {
close = !crate::lightroom_destination::keeps_open_on_error(app, &dlg);
app.toast(ctx, e);
}
Ok(_) if keeps_open(app, &dlg) => {
if let Dialog::SamModel { error, .. } = &mut dlg {
*error = None;
@@ -1932,6 +1945,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
Dialog::LightroomCatalog { catalog, .. } => {
app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c})))
}
d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::confirm(app, d),
Dialog::SaveEffect { name, group, fields, amount, brush } => {
crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush)
}
+14 -2
View File
@@ -3,7 +3,8 @@
//!
//! Changes apply immediately (no OK/Cancel). Where they are stored:
//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU,
//! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in
//! preview size, filmstrip/grid badges, last library, monitor profile, whether Import Lightroom
//! Catalog… asks where a catalog goes) and Auto Advance live in
//! the UI state,
//! saved by the host in its config folder (`ui.json`);
//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow
@@ -137,6 +138,17 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
hint(ui, t, crate::i18n::tr("not available here"));
}
});
// File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not
ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label));
row(ui, t, "", |ui| {
use crate::state::LightroomImport;
choices(
ui,
"settingsLightroomImport",
&[(LightroomImport::Ask, "Ask where to import it"), (LightroomImport::AlwaysMerge, "Always merge into the open library")],
&mut app.ui.settings.lightroom_import,
);
});
heading(ui, t, crate::i18n::tr("Startup"));
row(ui, t, crate::i18n::tr("Open in"), |ui| {
choices(
@@ -1284,7 +1296,7 @@ fn valid_library_name(s: &str) -> Result<&str, String> {
}
/// `base` inside `location`, or `base 2`, `base 3`… if that is taken.
fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf {
pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf {
let first = location.join(base);
if !first.exists() {
return first;
+40
View File
@@ -463,6 +463,22 @@ pub struct AppSettings {
/// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2.
#[serde(deserialize_with = "lenient")]
pub loupe_info: LoupeInfo,
/// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or
/// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that).
#[serde(deserialize_with = "lenient")]
pub lightroom_import: LightroomImport,
}
/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as
/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LightroomImport {
/// A window asks each time: merge into the open library, or create a new library for it.
#[default]
Ask,
/// Merge into the open library without asking (how imports worked before the window).
AlwaysMerge,
}
impl Default for AppSettings {
@@ -493,6 +509,7 @@ impl Default for AppSettings {
focus_point: false,
zoom_to_focus: true,
loupe_info: LoupeInfo::default(),
lightroom_import: LightroomImport::Ask,
}
}
}
@@ -1258,6 +1275,29 @@ pub enum Dialog {
folder: String,
catalog: Option<String>,
},
/// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see
/// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or
/// (`new_library`) create the library `name` in the folder `location`, switch to it and import
/// there. `dont_ask`: merge from now on without asking. `generation`: the library the window
/// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into.
LightroomDestination {
/// The `.lrcat` file.
catalog: String,
/// The open library's name (its folder's name).
library: String,
/// The photos in the open library.
photos: usize,
/// "Create a new library" is chosen (else "Merge into the open library").
new_library: bool,
/// The new library's name (prefilled from the catalog's file name).
name: String,
/// The folder the new library goes in (prefilled with the open library's parent folder).
location: String,
/// "Don't ask again (always merge into the open library)".
dont_ask: bool,
/// `Session::library_generation` when the window opened.
generation: u64,
},
/// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names,
/// the colour as `color_sat`), whether it carries Amount and the brush settings.
SaveEffect {
@@ -0,0 +1,391 @@
//! Issue #2672: before File ▸ Import Lightroom Catalog… starts, a window asks whether the catalog
//! merges into the open library or goes into a new library (named after the catalog, next to the
//! open one), unless Settings (or the window's "Don't ask again") says to always merge.
//!
//! The catalogs here are fake (`.lrcat` files that aren't SQLite): what is checked is where the
//! import is started, which library is open when it starts, and that the read then fails cleanly.
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::{Value, json};
use crate::headless::Headless;
use crate::state::{Dialog, LightroomImport};
use crate::{LightcraftApp, Services, UiState};
const T: Duration = Duration::from_secs(20);
/// A folder of its own per test: `Library` (a LightCraft library, demo photos in it unless
/// `empty`) and `Lightroom/Travel.lrcat` (a fake catalog) side by side.
struct Fixture {
/// The test's own folder.
root: PathBuf,
/// `root/Library`.
lib: PathBuf,
/// `root/Lightroom/Travel.lrcat`.
lrcat: PathBuf,
}
impl Fixture {
/// Make the folders and the fake catalog (an earlier run's leftovers removed).
fn new(tag: &str) -> Fixture {
let root = std::env::temp_dir().join(format!("lc-ui-lrdest-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let lr = root.join("Lightroom");
std::fs::create_dir_all(&lr).unwrap();
let lrcat = lr.join("Travel.lrcat");
std::fs::write(&lrcat, b"not really SQLite").unwrap();
Fixture { lib: root.join("Library"), root, lrcat }
}
/// The desktop app on `Library`, with a folder dialog (for Choose…) and screenshots.
fn app(&self, empty: bool) -> Headless {
let mut session = lightcraft_engine::Session::new().with_fs();
session.open_library(&self.lib, !empty).unwrap();
let png = |img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
};
// (a folder dialog for the window's Choose…, and what a screenshot needs)
let services = Services {
pick_folder: Some(Box::new(|| None)),
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();
h
}
/// The catalog's path as text.
fn lrcat(&self) -> String {
self.lrcat.to_string_lossy().into_owned()
}
}
impl Drop for Fixture {
/// Remove the folders.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// The widget `id` was drawn last frame.
fn has(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
/// Click the widget `id` through the control channel, then draw a frame.
fn click(h: &mut Headless, id: &str) {
let r = h.request("ui.clickWidget", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
h.step();
}
/// The open library's folder.
fn open_dir(h: &Headless) -> Option<PathBuf> {
h.app.session.library.as_ref().map(|l| l.dir.clone())
}
/// The window's state: (new library chosen, name, location, don't ask).
fn destination(h: &Headless) -> Option<(bool, String, String, bool)> {
match &h.app.ui.dialog {
Some(Dialog::LightroomDestination { new_library, name, location, dont_ask, .. }) => {
Some((*new_library, name.clone(), location.clone(), *dont_ask))
}
_ => None,
}
}
/// Let the import of the fake catalog end (it fails to read) and return what it reported.
fn import_ends(h: &mut Headless, lrcat: &Path) -> Value {
assert!(h.step_until(T, |h| h.app.lightroom.is_none()), "the import never ended");
let last = h.app.lightroom_last.clone().unwrap_or_default();
assert_eq!(last["path"], json!(lrcat), "the import was of this catalog: {last}");
last
}
/// A headless screenshot into `$LIGHTCRAFT_TEST_SHOTS`, when it is set.
fn shot(h: &mut Headless, name: &str) {
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
let r = h.request("ui.screenshot", json!({"path": Path::new(&p).join(name).to_string_lossy(), "headless": true}), T);
assert_eq!(r["ok"], true, "{r}");
}
}
/// The window appears before anything starts, with its choices; Merge imports into the open library.
#[test]
fn the_window_asks_and_merge_imports_into_the_open_library() {
let f = Fixture::new("merge");
let mut h = f.app(false);
let photos = h.app.session.catalog.len();
assert!(photos > 0);
let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
assert_eq!(r, json!({"asking": true, "path": f.lrcat(), "into": "new"}));
h.step();
h.step();
// a library with photos: the window opens on a new library, named after the catalog, next to the open one
assert_eq!(destination(&h), Some((true, "Travel".into(), f.root.to_string_lossy().into_owned(), false)));
for id in [
"radio:lightroomDestination-open",
"radio:lightroomDestination-new",
"field:lightroomLibraryName",
"field:lightroomLibraryLocation",
"button:lightroomLibraryChoose",
"check:lightroomDontAsk",
"button:dialogOk",
"button:dialogCancel",
] {
assert!(has(&h, id), "{id} is missing");
}
assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none(), "nothing starts before the answer");
shot(&mut h, "lightroom-destination-2672.png");
// Merge into the open library
click(&mut h, "radio:lightroomDestination-open");
assert_eq!(destination(&h).map(|d| d.0), Some(false));
click(&mut h, "button:dialogOk");
assert!(destination(&h).is_none());
assert!(h.app.lightroom.is_some() || h.app.lightroom_last.is_some(), "the import started");
import_ends(&mut h, &f.lrcat);
assert_eq!(open_dir(&h), Some(f.lib.clone()), "into the library open before");
assert_eq!(h.app.session.catalog.len(), photos);
assert!(!f.root.join("Travel").exists(), "no library was made");
assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::Ask, "it asks again next time");
}
/// Create a new library: it is made next to the open one, named after the catalog, and opened
/// before the import starts there.
#[test]
fn create_a_new_library_opens_it_before_the_import_starts() {
let f = Fixture::new("new");
let mut h = f.app(false);
h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
h.step();
h.step();
assert_eq!(destination(&h).map(|d| d.0), Some(true));
// ui.dialog.confirm answers what the window's button does
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
let new = f.root.join("Travel");
let result = &r["result"];
assert_eq!(result["library"]["path"], json!(new.to_string_lossy()), "{r}");
assert_eq!(result["library"]["name"], "Travel", "{r}");
assert_eq!(result["path"], json!(f.lrcat()), "{r}");
// the new library is the open one, and it was when the import started (the import's
// snapshot is taken in the same call, after the switch)
assert_eq!(open_dir(&h), Some(new.clone()));
assert!(lightcraft_engine::library::is_library(&new));
assert_eq!(h.app.ui.settings.library_path, new.to_string_lossy(), "opened at the next launch");
assert!(h.app.ui.settings.recent_libraries.contains(&f.lib.to_string_lossy().into_owned()), "the old one is a recent library");
import_ends(&mut h, &f.lrcat);
assert_eq!(open_dir(&h), Some(new));
}
/// Cancel: no import, no library made, the open library stays.
#[test]
fn cancel_does_nothing() {
let f = Fixture::new("cancel");
let mut h = f.app(false);
h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
h.step();
h.step();
assert!(destination(&h).is_some());
click(&mut h, "button:dialogCancel");
assert!(h.app.ui.dialog.is_none());
assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none());
assert_eq!(open_dir(&h), Some(f.lib.clone()));
assert!(!f.root.join("Travel").exists());
}
/// An empty library has nothing to merge with: the window opens on Merge.
#[test]
fn an_empty_library_opens_the_window_on_merge() {
let f = Fixture::new("empty");
let mut h = f.app(true);
let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
assert_eq!(r["into"], "open", "{r}");
h.step();
assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomDestination { new_library: false, photos: 0, .. })), "{:?}", h.app.ui.dialog);
// (the window in French, which translates it: the language is this test thread's own)
if std::env::var_os("LIGHTCRAFT_TEST_SHOTS").is_some() {
h.app.run("app.language.french", json!({})).unwrap();
h.step();
h.step();
shot(&mut h, "lightroom-destination-empty-fr-2672.png");
h.app.run("app.language.english", json!({})).unwrap();
}
}
/// "Always merge into the open library" imports without the window.
#[test]
fn always_merge_skips_the_window() {
let f = Fixture::new("always");
let mut h = f.app(false);
h.app.ui.settings.lightroom_import = LightroomImport::AlwaysMerge;
let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
assert_eq!(r["running"], true, "{r}");
h.step();
assert!(destination(&h).is_none());
import_ends(&mut h, &f.lrcat);
assert_eq!(open_dir(&h), Some(f.lib.clone()));
}
/// "Don't ask again" (with Merge only) sets "Always merge", which is saved in `ui.json`.
#[test]
fn dont_ask_again_merges_from_then_on_and_is_saved() {
let f = Fixture::new("dontask");
let mut h = f.app(false);
h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
h.step();
h.step();
// greyed out while a new library is chosen: it only speaks of merging
click(&mut h, "check:lightroomDontAsk");
assert_eq!(destination(&h).map(|d| d.3), Some(false));
click(&mut h, "radio:lightroomDestination-open");
click(&mut h, "check:lightroomDontAsk");
assert_eq!(destination(&h).map(|d| d.3), Some(true));
click(&mut h, "button:dialogOk");
assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::AlwaysMerge);
import_ends(&mut h, &f.lrcat);
// saved with the UI state (ui.json) and read back
let saved = serde_json::to_string(&h.app.ui).unwrap();
assert!(saved.contains(r#""lightroomImport":"alwaysMerge""#), "{saved}");
let back: UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(back.settings.lightroom_import, LightroomImport::AlwaysMerge);
// the next import doesn't ask
let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
assert_eq!(r["running"], true, "{r}");
assert!(destination(&h).is_none());
import_ends(&mut h, &f.lrcat);
}
/// Settings written before the setting existed, or hand-edited, ask (the default).
#[test]
fn old_and_odd_settings_files_ask() {
let old: UiState = serde_json::from_str(r#"{"settings": {"startupView": "detail"}}"#).unwrap();
assert_eq!(old.settings.lightroom_import, LightroomImport::Ask);
let odd: UiState = serde_json::from_str(r#"{"settings": {"lightroomImport": "sometimes", "startupView": "detail"}}"#).unwrap();
assert_eq!(odd.settings.lightroom_import, LightroomImport::Ask);
assert_eq!(odd.settings.startup_view, crate::state::StartupView::Detail, "the rest of the file still loads");
}
/// Settings ▸ General ▸ When importing a Lightroom catalog switches between the two.
#[test]
fn settings_general_switches_the_question_off_and_on() {
let f = Fixture::new("settings");
let mut h = f.app(false);
h.app.run("app.settings", json!({})).unwrap();
h.step();
h.step();
click(&mut h, "button:settingsLightroomImport-1");
assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::AlwaysMerge);
shot(&mut h, "settings-lightroom-import-2672.png");
click(&mut h, "button:settingsLightroomImport-0");
assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::Ask);
}
/// A new library's folder must be new or empty (File ▸ New Library…'s rule): the window says so
/// and stays open to pick another name; nothing is imported and the library stays.
#[test]
fn a_folder_that_isnt_empty_is_refused_and_the_window_stays() {
let f = Fixture::new("notempty");
std::fs::create_dir_all(f.root.join("Travel")).unwrap();
std::fs::write(f.root.join("Travel").join("notes.txt"), b"mine").unwrap();
let mut h = f.app(false);
h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
h.step();
h.step();
// the free name is offered…
assert_eq!(destination(&h).map(|d| d.1), Some("Travel 2".to_string()));
// …but the user types the taken one
if let Some(Dialog::LightroomDestination { name, .. }) = h.app.ui.dialog.as_mut() {
*name = "Travel".into();
}
click(&mut h, "button:dialogOk");
assert!(destination(&h).is_some(), "the window stays to be corrected");
let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("isn't empty"), "{toast}");
assert_eq!(open_dir(&h), Some(f.lib.clone()));
assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none());
// the same through the control channel
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], false, "{r}");
assert!(destination(&h).is_some());
}
/// `into` acts without asking (agents, scripts); bad params are refused before anything happens.
#[test]
fn into_acts_without_the_window() {
let f = Fixture::new("into");
let mut h = f.app(false);
for bad in [json!({"path": f.lrcat(), "into": "elsewhere"}), json!({"path": f.lrcat(), "into": "new", "location": "relative/dir"})] {
let err = h.app.run("file.importLightroom", bad.clone()).unwrap_err();
assert!(err.contains("file.importLightroom"), "{bad}: {err}");
}
// a catalog that isn't there doesn't leave an empty library behind
let missing = f.root.join("Gone.lrcat");
let err = h.app.run("file.importLightroom", json!({"path": missing.to_string_lossy(), "into": "new"})).unwrap_err();
assert!(err.contains("no Lightroom catalog"), "{err}");
assert!(!f.root.join("Gone").exists());
assert!(destination(&h).is_none() && h.app.lightroom_last.is_none());
let elsewhere = f.root.join("Libraries");
let r = h
.app
.run("file.importLightroom", json!({"path": f.lrcat(), "into": "new", "name": "Trips", "location": elsewhere.to_string_lossy()}))
.unwrap();
assert_eq!(r["library"]["name"], "Trips", "{r}");
assert_eq!(open_dir(&h), Some(elsewhere.join("Trips")));
assert!(destination(&h).is_none());
import_ends(&mut h, &f.lrcat);
// a new library while another import runs: refused before anything is made
let r = h.app.run("file.importLightroom", json!({"path": f.lrcat(), "into": "open"})).unwrap();
assert_eq!(r["running"], true, "{r}");
let err = h.app.run("file.importLightroom", json!({"path": f.lrcat(), "into": "new", "name": "Again"})).unwrap_err();
assert!(err.contains("already running"), "{err}");
assert!(!elsewhere.join("Again").exists());
// and the window isn't shown for an answer that couldn't be carried out
assert!(h.app.run("file.importLightroom", json!({"path": f.lrcat()})).is_err());
assert!(destination(&h).is_none());
import_ends(&mut h, &f.lrcat);
assert_eq!(open_dir(&h), Some(elsewhere.join("Trips")));
}
/// A session in memory (no library on disk) has nothing to put a new library next to: it merges
/// without asking, as before.
#[test]
fn a_library_in_memory_merges_without_asking() {
let f = Fixture::new("memory");
let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()), [1200.0, 800.0], 1.0);
h.step();
let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
assert_eq!(r["running"], true, "{r}");
assert!(destination(&h).is_none());
import_ends(&mut h, &f.lrcat);
}
/// Another library opened while the window was up (an agent, the control channel): the window
/// doesn't merge into it.
#[test]
fn merge_is_refused_when_another_library_was_opened_meanwhile() {
let f = Fixture::new("stale");
let mut h = f.app(false);
h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap();
h.step();
click(&mut h, "radio:lightroomDestination-open");
let other = f.root.join("Other");
let dialog = h.app.ui.dialog.take();
h.app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap();
h.app.ui.dialog = dialog;
h.step();
click(&mut h, "button:dialogOk");
assert!(h.app.ui.dialog.is_none(), "the window closes: the library it named is gone");
let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("nothing was imported"), "{toast}");
assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none());
assert_eq!(open_dir(&h), Some(other));
}
+15 -4
View File
@@ -1,6 +1,6 @@
//! 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`.
//! Catalog… with the catalog found, which asks where that `.lrcat` goes (#2672), then imports it.
use std::time::Duration;
@@ -84,12 +84,23 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() {
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.)
// Import Lightroom Catalog…: no file dialog, the window that asks where that catalog goes
// (#2672) takes its place, as File ▸ Import Lightroom Catalog… does
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true);
h.step();
assert!(
matches!(&h.app.ui.dialog, Some(Dialog::LightroomDestination { catalog, .. }) if *catalog == lrcat.to_string_lossy()),
"{:?}",
h.app.ui.dialog
);
// Merge: the import of that catalog starts, 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": "radio:lightroomDestination-open"}), T)["ok"], true);
h.step();
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true);
h.step();
// A fast read failure can already have replaced the offer with its lasting result.
assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. })));
assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. } | Dialog::LightroomDestination { .. })));
assert!(h.step_until(T, |h| h.app.lightroom.is_none()));
assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomResult { summary, first: None, .. }) if summary.contains("failed")));
let last = h.app.lightroom_last.clone().unwrap_or_default();
+19 -2
View File
@@ -173,12 +173,29 @@ writes nothing. `app.openLibrary` opening a library ends it.
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
`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one), which then
asks where the catalog goes (next section), 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.
### Import Lightroom Catalog: where the catalog goes
`file.importLightroom {path?, into?, name?, location?}` (File ▸ Import Lightroom Catalog…, issue #2672). With `into`
it acts without asking: `"open"` imports into the library open now (`library.importLightroom`); `"new"` creates the
library `name` (default: the catalog's file name without `.lrcat`, or `Travel 2`… when taken) in the folder
`location` (a full path; default: the open library's parent folder) under `app.newLibrary`'s rules, opens it and
imports there, returning the import's start with `library: {path, name, photos}`. A catalog file that isn't there is
refused before anything is made; so is a new library while an import runs. Without `into`, a library on disk with
`settings.lightroomImport` `"ask"` (the default) opens a window and returns `{asking: true, path, into}` (`into`: the
choice it opens with, `"open"` when the library is empty, else `"new"`). Its widgets:
`radio:lightroomDestination-open`, `radio:lightroomDestination-new`, `field:lightroomLibraryName`,
`field:lightroomLibraryLocation`, `button:lightroomLibraryChoose` (where there is a folder dialog), `check:lightroomDontAsk` (with Merge only; sets
`"alwaysMerge"`), `button:dialogOk` / `ui.dialog.confirm` (which stays open on an error such as a folder that isn't
empty) and `button:dialogCancel`. With `"alwaysMerge"` (`ui.set {"settings": {"lightroomImport": "alwaysMerge"}}`,
or Settings ▸ General `button:settingsLightroomImport-0|1`), or in a session in memory, it imports into the open
library without asking.
## External controllers
External apps can use this channel to send commands and poll the running desktop app for feedback
+35 -3
View File
@@ -6,7 +6,39 @@ and a C SQLite runtime are not required. The source database is read-only, inclu
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.
(`crates/engine/src/lightroom_folder.rs`, issue #891), and then asks where the catalog goes as described next.
`lightcraft-cli --library` refuses those places too.
**Where the catalog goes.** Before the import starts, the desktop app asks where the catalog goes (issue #2672):
- **Merge into the open library "<name>" (N photos)**: the catalog's photos, collections and edits join the ones
already in the library open now (the library's name is its folder's name).
- **Create a new library**: named after the catalog file (`Travel.lrcat` → "Travel", or "Travel 2" when a
"Travel" folder is already there) and placed next to the open library (in its parent folder); both can be
changed (Choose… picks the folder). LightCraft creates and opens it as File → New Library… does (the folder
must be new or empty; it is refused while another import runs), then imports the catalog into it. The library
left behind stays in File → Open Recent Library.
- **Cancel** does nothing.
The window opens on *Merge* when the open library has no photos (there is nothing to merge with) and on *Create a
new library* when it has some.
**Turning the question off.** Settings → General → Library → *When importing a Lightroom catalog*: *Ask where to
import it* (the default) or *Always merge into the open library* (how imports always worked before). The window's
*Don't ask again (always merge into the open library)* checkbox, offered with *Merge*, picks the second; the window
says where to turn the question back on. The choice is an app setting (`ui.json`, `settings.lightroomImport`:
`"ask"` or `"alwaysMerge"`); a settings file without it asks.
A session with no library on disk (in memory, e.g. after Continue Without Saving) has nothing to put a new library
next to: the catalog merges into it without asking, as before.
**Scripts and agents.** The app command `file.importLightroom {path?, into?, name?, location?}` (control channel,
`lightcraft-cli mcp --connect`) chooses without the window: `into: "open"` merges, `into: "new"` creates the library
`name` (default: the catalog's name) in the folder `location` (default: the open library's parent folder; a full
path), opens it and imports. Without `into` it shows the window (and returns `{asking: true, path, into}`, `into`
being the choice the window opens with) or merges when the setting says so. A catalog path that doesn't exist is
refused before a new library is made. The engine command `library.importLightroom` always imports into the library
open now.
Equivalent commands:
@@ -15,8 +47,8 @@ lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path=
lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat"
```
Import adds records to the currently open LightCraft library; it does not create a separate
folder named after the Lightroom catalog. The desktop result stays open until dismissed and
The import itself (`library.importLightroom`, `lightcraft-cli run`) adds records to the currently open LightCraft
library; only the desktop window above can make a new library for it first. The desktop result stays open until dismissed and
shows the destination library, the number of records added, missing originals and warnings.
**Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay
readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… asks whether the catalog merges into the open library or goes into a new library, with an opt-out in Settings ▸ General, #2672; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -699,7 +699,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does. Escape → Detail without losing edits; pins are catalog-local; missing/deleted photos refused and deleted references no longer drawn (Windows regression/native smoke verified) |
| 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 (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) |
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `cmd:file.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `crates/ui-egui/src/lightroom_destination.rs`, `crates/ui-egui/src/tests_lightroom_destination.rs`, `docs/lightroom-catalog-import.md` | before the import starts, File ▸ Import Lightroom Catalog… asks where the catalog goes: merge into the open library (named, with its photo count; preselected when it is empty) or create a new library (named after the catalog file, next to the open library, both editable), which is made and opened following File ▸ New Library…'s rules before the import; Cancel does nothing; Settings ▸ General ▸ "When importing a Lightroom catalog" (Ask where to import it / Always merge into the open library) or the window's "Don't ask again" turns it off; `file.importLightroom` takes `into` (`open` / `new`), `name`, `location` to choose without the window; a session in memory merges without asking (issue #2672); read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) |
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) |
| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key |
| 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) |
@@ -927,3 +927,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged |
| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) |
| 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) |
| 2026-10-23 | minor | LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… (and Open Library's offer for a Lightroom catalog) asks whether the catalog merges into the open library or goes into a new library made next to it; Settings ▸ General or "Don't ask again" switches the question off; `file.importLightroom` takes `into` / `name` / `location` (#2672); status unchanged (🟡) |
+11
View File
@@ -1,5 +1,16 @@
# What's new in LightCraft
## 2026-10-23
### Choose where a Lightroom catalog goes
- File ▸ Import Lightroom Catalog… now asks first: merge the catalog's photos, collections and edits into the
library open now, or create a new library for them, named after the catalog and placed next to the open library
(both can be changed). LightCraft opens the new library, then imports the catalog into it. Cancel does nothing.
Open Library… on a Lightroom catalog goes through the same choice.
- To import as before without the question, choose Settings ▸ General ▸ When importing a Lightroom catalog ▸
Always merge into the open library, or tick Don't ask again in the window. Ask where to import it brings the
question back.
## 2026-10-21
### Sharper resampling in HDR and panorama merges