diff --git a/apps/lightcraft/src/session.rs b/apps/lightcraft/src/session.rs index b886b472f..7d9db79bd 100644 --- a/apps/lightcraft/src/session.rs +++ b/apps/lightcraft/src/session.rs @@ -253,7 +253,7 @@ pub(crate) fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std:: pub(crate) fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { let (mut s, problem) = match launch { Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), - Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), + Launch::Open { dir, remembered, .. } => open_library_session(in_memory, dir.clone(), *remembered, seed_demo), }; s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); @@ -267,7 +267,9 @@ pub(crate) fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> /// If the library can't be opened the session is empty and in memory — never seeded with the /// demo photos, never written anywhere — and the problem is returned: the window then says so /// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100). -fn open_library_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { +/// So is a `remembered` library (the one last opened) that isn't there any more: opening would +/// make a new, empty one in its place, e.g. on an unmounted drive's path (issue #3239). +fn open_library_session(in_memory: bool, dir: Option, remembered: bool, seed_demo: bool) -> (Session, Option) { if in_memory { return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None); } @@ -276,9 +278,14 @@ fn open_library_session(in_memory: bool, dir: Option, seed_d log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)"); return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); }; + if remembered && let Some(why) = lightcraft_ui_egui::panels::library_problem::missing_library_reason(&dir) { + log::error!("library {} can't be opened: {why}", dir.display()); + // Try Again must not make one there either (an empty mount point, issue #3239) + return (unopened(), Some(LibraryProblem { existing_only: true, ..LibraryProblem::new(dir.to_string_lossy(), why) })); + } let t0 = std::time::Instant::now(); let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock(); - match s.open_library(&dir, seed_demo) { + match s.open_library(&dir, seed_demo && !remembered) { Ok(r) => { let (replayed, torn) = (r.replayed, r.torn_bytes); log::info!( diff --git a/apps/lightcraft/src/tests.rs b/apps/lightcraft/src/tests.rs index d9ec07e8d..dac2f6df5 100644 --- a/apps/lightcraft/src/tests.rs +++ b/apps/lightcraft/src/tests.rs @@ -69,7 +69,7 @@ fn a_librarys_catalog_given_at_launch_opens_that_library() { /// adds to Apple Photos on a worker (headless sessions wait instead). #[test] fn the_desktop_session_adds_to_apple_photos_in_the_background() { - let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); + let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false, remembered: false }, false); assert!(problem.is_none()); assert!(s.apple_photos_background); assert!(!lightcraft_engine::Session::new().apple_photos_background); @@ -103,6 +103,74 @@ fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { let _ = std::fs::remove_dir_all(&d); } +/// Issue #3239: the library remembered in Settings whose folder is missing at launch (a disk or +/// network share not connected) is reported in the library problem window. Nothing is created +/// there (not the folder, not its parents) and no demo photos are added. +#[test] +fn a_remembered_library_missing_at_launch_is_reported_not_made_anew() { + let d = dir("missing-library"); + let gone = d.join("gone"); + let lib = gone.join("Lib"); + let launch = Launch::Open { dir: Some(lib.clone()), alt_asks: true, remembered: true }; + let (s, problem) = open_session(false, &launch, true); + let problem = problem.expect("the missing library is reported"); + assert_eq!(problem.path, lib.to_string_lossy()); + assert!(problem.can_retry, "Try Again once the drive is back"); + assert!(problem.existing_only, "Try Again makes no library there either"); + assert!(!gone.exists(), "nothing is created where the library was"); + assert!(s.library.is_none() && s.catalog.is_empty(), "no library, no demo photos"); + // a remembered folder that is there but holds no library is reported too + std::fs::create_dir_all(&lib).unwrap(); + let (s, problem) = open_session(false, &launch, true); + assert!(problem.as_ref().is_some_and(|p| p.existing_only) && s.library.is_none() && s.catalog.is_empty(), "{problem:?}"); + assert!(!lightcraft_engine::library::is_library(&lib), "nothing written in it"); + // the default location (or --library) is still made when it's missing, with the demo photos + let fresh = d.join("fresh").join("Lib"); + let (s, problem) = open_session(false, &Launch::Open { dir: Some(fresh.clone()), alt_asks: true, remembered: false }, true); + assert!(problem.is_none() && s.library.is_some() && !s.catalog.is_empty(), "{problem:?}"); + assert!(lightcraft_engine::library::is_library(&fresh)); + drop(s); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #3239: a remembered library whose folder can't be looked into (no permission) is +/// reported as unreadable at launch, not as gone, and nothing is opened or written there. +#[cfg(unix)] +#[test] +fn a_remembered_library_it_may_not_read_at_launch_says_so() { + use std::os::unix::fs::PermissionsExt; + let d = dir("denied-library"); + let lib = d.join("Lib"); + let mut s = lightcraft_engine::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.close_library().unwrap(); + drop(s); + let names = |dir: &std::path::Path| { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name()).collect(); + names.sort(); + names + }; + let before = names(&lib); + std::fs::set_permissions(&lib, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read_dir(&lib).is_ok() { + std::fs::set_permissions(&lib, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = std::fs::remove_dir_all(&d); + lightcraft_testkit::skip!(System, "running as root: a folder with mode 000 is still readable"); + return; + } + let launch = Launch::Open { dir: Some(lib.clone()), alt_asks: true, remembered: true }; + let (s, problem) = open_session(false, &launch, true); + std::fs::set_permissions(&lib, std::fs::Permissions::from_mode(0o755)).unwrap(); + let problem = problem.expect("the unreadable library is reported"); + assert!(problem.error.starts_with("This folder can't be read:"), "{}", problem.error); + assert!(problem.error.to_lowercase().contains("permission denied"), "{}", problem.error); + assert!(problem.existing_only, "Try Again makes no library there either"); + assert!(s.library.is_none() && s.catalog.is_empty(), "no library, no demo photos"); + assert_eq!(names(&lib), before, "nothing written there"); + drop(s); + let _ = std::fs::remove_dir_all(&d); +} + /// Issue #103: a damaged ui.json (which holds the library location) is kept aside and /// reported, not silently replaced by the defaults. #[test] diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2021951ba..caf51a0ab 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -58,6 +58,22 @@ pub fn is_library(dir: &Path) -> bool { dir.join(lightcraft_catalog::journal::SNAPSHOT).is_file() || dir.join(lightcraft_catalog::journal::LOG).is_file() } +/// [`is_library`] that tells "no library there" from "can't tell": `Ok(false)` only when the +/// folder or its catalog files are definitely missing; a lookup that fails otherwise (a folder it +/// may not read, a share that timed out) is the error, so a library that is there but can't be +/// read isn't reported as gone (issue #3239). +pub fn library_state(dir: &Path) -> std::io::Result { + for name in [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG] { + match std::fs::metadata(dir.join(name)) { + Ok(m) if m.is_file() => return Ok(true), + Ok(_) => {} + Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => {} + Err(e) => return Err(e), + } + } + Ok(false) +} + /// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library` /// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows). pub fn default_dir() -> Option { diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index fefc418ee..199ea3351 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -1020,6 +1020,9 @@ "LightCraft could not start": "LightCraft se nepodařilo spustit", "LightCraft couldn't find where to keep your library.": "LightCraft nedokázal najít místo pro uložení knihovny.", "LightCraft couldn't open the library at": "LightCraft nedokázal otevřít knihovnu v umístění", + "There is no LightCraft library in this folder any more.": "V této složce už žádná knihovna LightCraft není.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Knihovna tu není: byla přesunuta nebo přejmenována, nebo není připojen její disk či síťové úložiště.", + "This folder can't be read:": "Tuto složku nelze přečíst:", "LightCraft does not know this model, so it assumed:": "LightCraft tento model nezná, a proto předpokládá:", "LightCraft on GitHub": "LightCraft na GitHubu", "LightCraft website": "Web LightCraft", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index cb9dfba52..eb7a80b83 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -801,6 +801,9 @@ "LightCraft could not start": "LightCraft konnte nicht starten", "LightCraft couldn't find where to keep your library.": "LightCraft konnte keinen Speicherort für deine Bibliothek finden.", "LightCraft couldn't open the library at": "LightCraft konnte die Bibliothek nicht öffnen unter", + "There is no LightCraft library in this folder any more.": "In diesem Ordner ist keine LightCraft-Bibliothek mehr.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Die Bibliothek ist nicht da: Sie wurde verschoben oder umbenannt, oder ihr Laufwerk bzw. ihre Netzwerkfreigabe ist nicht verbunden.", + "This folder can't be read:": "Dieser Ordner kann nicht gelesen werden:", "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).", "LightCraft on GitHub": "LightCraft auf GitHub", "LightCraft website": "LightCraft-Website", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 892f33acc..cd7608c7b 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -806,6 +806,9 @@ "LightCraft could not start": "No se pudo iniciar LightCraft", "LightCraft couldn't find where to keep your library.": "LightCraft no encontró dónde guardar la biblioteca.", "LightCraft couldn't open the library at": "LightCraft no pudo abrir la biblioteca en", + "There is no LightCraft library in this folder any more.": "Ya no hay ninguna biblioteca de LightCraft en esta carpeta.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "La biblioteca no está: se movió o se le cambió el nombre, o su disco o carpeta de red no está conectado.", + "This folder can't be read:": "No se puede leer esta carpeta:", "LightCraft on GitHub": "LightCraft en GitHub", "LightCraft website": "Sitio web de LightCraft", "LightCraft — Second Window": "LightCraft: segunda ventana", diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 87fbf8e30..a94ba37b7 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -1040,6 +1040,9 @@ "LightCraft could not start": "LightCraft ei voinut käynnistyä", "LightCraft couldn't find where to keep your library.": "LightCraft ei löytänyt paikkaa kirjastosi säilytykselle.", "LightCraft couldn't open the library at": "LightCraft ei voinut avata kirjastoa kohteessa", + "There is no LightCraft library in this folder any more.": "Tässä kansiossa ei ole enää LightCraft-kirjastoa.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Kirjastoa ei löydy: se on siirretty tai nimetty uudelleen, tai sen asemaa tai verkkojakoa ei ole yhdistetty.", + "This folder can't be read:": "Tätä kansiota ei voi lukea:", "LightCraft does not know this model, so it assumed:": "LightCraft ei tunne tätä mallia, joten se oletti:", "LightCraft on GitHub": "LightCraft GitHubissa", "LightCraft stopped": "LightCraft pysähtyi", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index a37a9356a..89eb8917f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -759,6 +759,8 @@ "LightCraft could not start": "LightCraft n'a pas pu démarrer", "LightCraft couldn't find where to keep your library.": "LightCraft n'a pas trouvé où conserver votre bibliothèque.", "LightCraft couldn't open the library at": "LightCraft n'a pas pu ouvrir la bibliothèque à l'emplacement", + "There is no LightCraft library in this folder any more.": "Ce dossier ne contient plus de bibliothèque LightCraft.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "La bibliothèque est introuvable : elle a été déplacée ou renommée, ou son disque ou son partage réseau n'est pas connecté.", "LightCraft on GitHub": "LightCraft sur GitHub", "LightCraft website": "Site web de LightCraft", "LightCraft — Second Window": "LightCraft : deuxième fenêtre", diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index e9a73d62e..b5f2ea70a 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -1041,6 +1041,9 @@ "LightCraft could not start": "LightCraft gagal dijalankan", "LightCraft couldn't find where to keep your library.": "LightCraft tidak dapat menemukan lokasi untuk menyimpan pustaka Anda.", "LightCraft couldn't open the library at": "LightCraft tidak dapat membuka pustaka di", + "There is no LightCraft library in this folder any more.": "Folder ini tidak lagi berisi pustaka LightCraft.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Pustaka tidak ada di sini: pustaka dipindahkan atau diganti namanya, atau drive atau berbagi jaringannya tidak terhubung.", + "This folder can't be read:": "Folder ini tidak dapat dibaca:", "LightCraft does not know this model, so it assumed:": "LightCraft tidak mengenali model ini, jadi menggunakan asumsi berikut:", "LightCraft on GitHub": "LightCraft di GitHub", "LightCraft stopped": "LightCraft berhenti", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 41c1a21e1..c03a07b2a 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -761,6 +761,9 @@ "LightCraft could not start": "Impossibile avviare LightCraft", "LightCraft couldn't find where to keep your library.": "LightCraft non ha trovato dove salvare la tua libreria.", "LightCraft couldn't open the library at": "LightCraft non è riuscito ad aprire la libreria in", + "There is no LightCraft library in this folder any more.": "In questa cartella non c'è più una libreria LightCraft.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "La libreria non c'è: è stata spostata o rinominata, oppure il suo disco o la sua condivisione di rete non è collegata.", + "This folder can't be read:": "Impossibile leggere questa cartella:", "LightCraft on GitHub": "LightCraft su GitHub", "LightCraft website": "Sito web di LightCraft", "LightCraft — Second Window": "LightCraft: seconda finestra", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 6c977a022..3df86e1b5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1634,6 +1634,9 @@ "Your library couldn't be opened": "ライブラリを開けませんでした", "LightCraft couldn't find where to keep your library.": "ライブラリの保存場所が見つかりませんでした。", "LightCraft couldn't open the library at": "次の場所にあるライブラリを開けませんでした:", + "There is no LightCraft library in this folder any more.": "このフォルダーには LightCraft ライブラリがもうありません。", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "ライブラリが見つかりません。移動されたか名前が変更されたか、ドライブまたはネットワーク共有が接続されていません。", + "This folder can't be read:": "このフォルダーを読み取れません:", "Try Again": "再試行", "Choose Another Library…": "別のライブラリを選択…", "Continue Without Saving": "保存せずに続行", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index d6518d4f4..fc343624e 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -1120,6 +1120,9 @@ "LightCraft could not start": "LightCraft ვერ გაეშვა", "LightCraft couldn't find where to keep your library.": "LightCraft-მა ვერ იპოვა, სად შეინახოს თქვენი ბიბლიოთეკა.", "LightCraft couldn't open the library at": "LightCraft-მა ვერ გახსნა ბიბლიოთეკა მისამართზე", + "There is no LightCraft library in this folder any more.": "ამ საქაღალდეში LightCraft-ის ბიბლიოთეკა აღარ არის.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "ბიბლიოთეკა აქ არ არის: ის გადაიტანეს ან გადაარქვეს სახელი, ან მისი დისკი ან ქსელური საქაღალდე არ არის მიერთებული.", + "This folder can't be read:": "ამ საქაღალდის წაკითხვა ვერ ხერხდება:", "LightCraft does not know this model, so it assumed:": "LightCraft-მა ეს მოდელი არ იცის, ამიტომ ივარაუდა:", "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft აღარ ითიშება გაშვებისას Windows კომპიუტერებზე, რომელთა Vulkan დრაივერი გაუმართავია (ხარვეზი #136, მაგ. ზოგიერთი Intel UHD", "LightCraft on GitHub": "LightCraft GitHub-ზე", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index 1d4b9d0f3..1138009e2 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -743,6 +743,9 @@ "LightCraft could not start": "Nie można uruchomić LightCraft", "LightCraft couldn't find where to keep your library.": "LightCraft nie znalazł miejsca na przechowywanie biblioteki.", "LightCraft couldn't open the library at": "LightCraft nie mógł otworzyć biblioteki w lokalizacji", + "There is no LightCraft library in this folder any more.": "W tym folderze nie ma już biblioteki LightCraft.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Biblioteki tu nie ma: została przeniesiona lub zmieniono jej nazwę albo jej dysk lub udział sieciowy nie jest podłączony.", + "This folder can't be read:": "Nie można odczytać tego folderu:", "LightCraft on GitHub": "LightCraft w serwisie GitHub", "LightCraft website": "Strona internetowa LightCraft", "LightCraft — Second Window": "LightCraft — drugie okno", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 936623ea0..e07cfaf73 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -770,6 +770,9 @@ "LightCraft could not start": "Não foi possível iniciar o LightCraft", "LightCraft couldn't find where to keep your library.": "O LightCraft não conseguiu encontrar onde guardar sua biblioteca.", "LightCraft couldn't open the library at": "O LightCraft não conseguiu abrir a biblioteca em", + "There is no LightCraft library in this folder any more.": "Esta pasta não tem mais uma biblioteca do LightCraft.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "A biblioteca não está aqui: foi movida ou renomeada, ou o disco ou compartilhamento de rede dela não está conectado.", + "This folder can't be read:": "Não é possível ler esta pasta:", "LightCraft on GitHub": "LightCraft no GitHub", "LightCraft website": "Site do LightCraft", "LightCraft — Second Window": "LightCraft — segunda janela", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index ee6196868..5deeee5dc 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -831,6 +831,9 @@ "LightCraft could not start": "Не удалось запустить LightCraft", "LightCraft couldn't find where to keep your library.": "LightCraft не удалось найти место для хранения вашей библиотеки.", "LightCraft couldn't open the library at": "LightCraft не удалось открыть библиотеку в", + "There is no LightCraft library in this folder any more.": "В этой папке больше нет библиотеки LightCraft.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Библиотеки здесь нет: её переместили или переименовали, либо её диск или сетевая папка не подключены.", + "This folder can't be read:": "Не удаётся прочитать эту папку:", "LightCraft on GitHub": "LightCraft на GitHub", "LightCraft website": "Сайт LightCraft", "LightCraft — Second Window": "LightCraft — второе окно", diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index c9cc169b0..a6bea8eed 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -1137,6 +1137,9 @@ "LightCraft could not start": "LightCraft kunde inte starta", "LightCraft couldn't find where to keep your library.": "LightCraft hittade ingen plats att spara ditt bibliotek på.", "LightCraft couldn't open the library at": "LightCraft kunde inte öppna biblioteket på", + "There is no LightCraft library in this folder any more.": "Det finns inte längre något LightCraft-bibliotek i den här mappen.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Biblioteket finns inte där: det har flyttats eller bytt namn, eller så är dess enhet eller nätverksresurs inte ansluten.", + "This folder can't be read:": "Den här mappen kan inte läsas:", "LightCraft Default": "LightCraft-standard", "LightCraft does not know this model, so it assumed:": "LightCraft känner inte till den här modellen och antog därför:", "LightCraft Help": "LightCraft-hjälp", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 0d1acabfb..ed69eea57 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1629,6 +1629,9 @@ "Your library couldn't be opened": "Не вдалося відкрити вашу бібліотеку", "LightCraft couldn't find where to keep your library.": "LightCraft не вдалося знайти місце для бібліотеки.", "LightCraft couldn't open the library at": "LightCraft не вдалося відкрити бібліотеку в", + "There is no LightCraft library in this folder any more.": "У цій папці більше немає бібліотеки LightCraft.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Бібліотеки тут немає: її перемістили чи перейменували, або її диск чи мережеву папку не підключено.", + "This folder can't be read:": "Не вдається прочитати цю папку:", "Try Again": "Спробувати знову", "Choose Another Library…": "Вибрати іншу бібліотеку…", "Continue Without Saving": "Продовжити без збереження", diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index d52d41217..54d01dd15 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -958,6 +958,9 @@ "LightCraft could not start": "LightCraft không thể khởi động", "LightCraft couldn't find where to keep your library.": "LightCraft không tìm được nơi lưu thư viện của bạn.", "LightCraft couldn't open the library at": "LightCraft không mở được thư viện tại", + "There is no LightCraft library in this folder any more.": "Thư mục này không còn thư viện LightCraft nào.", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "Thư viện không có ở đây: nó đã bị di chuyển hoặc đổi tên, hoặc ổ đĩa hay ổ mạng của nó chưa được kết nối.", + "This folder can't be read:": "Không đọc được thư mục này:", "LightCraft does not know this model, so it assumed:": "LightCraft không biết mô hình này, nên đã giả định:", "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft không còn sự cố khi khởi động trên PC Windows có driver Vulkan lỗi (issue #136, ví dụ một số Intel UHD", "LightCraft on GitHub": "LightCraft trên GitHub", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 48339491c..4c35b9c02 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -867,6 +867,9 @@ "LightCraft Website": "LightCraft 网站", "LightCraft couldn't find where to keep your library.": "LightCraft 找不到保存图库的位置。", "LightCraft couldn't open the library at": "LightCraft 无法打开以下位置的图库:", + "There is no LightCraft library in this folder any more.": "此文件夹中已经没有 LightCraft 图库。", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "图库不在这里:它已被移动或重命名,或者它所在的驱动器或网络共享未连接。", + "This folder can't be read:": "无法读取此文件夹:", "LightCraft on GitHub": "GitHub 上的 LightCraft", "LightCraft website": "LightCraft 网站", "LightCraft — Second Window": "LightCraft — 第二窗口", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 26ad944b5..89078b3a2 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -868,6 +868,9 @@ "LightCraft Website": "LightCraft 網站", "LightCraft couldn't find where to keep your library.": "LightCraft 找不到儲存照片庫的位置。", "LightCraft couldn't open the library at": "LightCraft 無法開啟此位置的照片庫:", + "There is no LightCraft library in this folder any more.": "這個資料夾中已經沒有 LightCraft 照片庫。", + "The library isn't there: it was moved or renamed, or its drive or network share isn't connected.": "照片庫不在這裡:它已被移動或重新命名,或是它所在的磁碟機或網路共用資料夾未連線。", + "This folder can't be read:": "無法讀取這個資料夾:", "LightCraft on GitHub": "GitHub 上的 LightCraft", "LightCraft website": "LightCraft 網站", "LightCraft — Second Window": "LightCraft — 第二視窗", diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 544e29449..6ba99b0c6 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -274,7 +274,9 @@ pub const ALT_SECONDS: f64 = 2.0; pub enum Launch { /// Open this library folder (`None`: there is nowhere to keep one; the problem window says /// so). `alt_asks`: holding Alt/Option as the window appears offers the recent libraries. - Open { dir: Option, alt_asks: bool }, + /// `remembered`: `dir` is the library last opened (Settings), not the default location, so a + /// missing one is reported instead of created anew (issue #3239). + Open { dir: Option, alt_asks: bool, remembered: bool }, /// Open nothing yet: the window lists the recent libraries ([`LibraryProblem::choose`]). Ask, } @@ -284,16 +286,18 @@ pub enum Launch { /// `settings` is `None` without a settings file; `memory` (`--memory`) never asks. pub fn launch_plan(memory: bool, flag: Option, env: Option, settings: Option<&AppSettings>, default: Option) -> Launch { if memory { - return Launch::Open { dir: None, alt_asks: false }; + return Launch::Open { dir: None, alt_asks: false, remembered: false }; } if let Some(dir) = flag.or(env) { - return Launch::Open { dir: Some(dir), alt_asks: false }; + return Launch::Open { dir: Some(dir), alt_asks: false, remembered: false }; } if settings.is_some_and(|s| s.at_launch == LaunchLibrary::Ask) { return Launch::Ask; } let last = settings.map(|s| s.library_path.clone()).filter(|p| !p.is_empty()).map(PathBuf::from); - Launch::Open { dir: last.or(default), alt_asks: true } + // the default location is made when it's missing (first launch); a remembered library isn't + let remembered = last.as_ref().is_some_and(|l| default.as_ref() != Some(l)); + Launch::Open { dir: last.or(default), alt_asks: true, remembered } } /// One library in the launch window's list. @@ -341,6 +345,11 @@ pub struct LibraryProblem { /// Try Again reopens `path` as a library folder (`false` in the browser, whose storage isn't /// a folder: reloading the page tries again). pub can_retry: bool, + /// Try Again opens `path` only once it holds a library again, and never makes one there: set + /// for the library remembered in Settings that was missing at launch, whose folder can be + /// the empty mount point of a share that isn't connected yet (issue #3239). Choose Another + /// Library… still creates one where asked. + pub existing_only: bool, /// The window lists the recent libraries to choose from instead (issue #1290); `error` then /// says why the last one tried didn't open. pub chooser: Option, @@ -710,6 +719,37 @@ fn unavailable(path: &str) -> Option { None } +/// Why the library at `dir` that has to exist already (a remembered one, issue #3239) can't be +/// opened (`None`: it's there): the folder is there without a catalog, or the folder itself is +/// missing. A folder that can't be looked into (no permission, a share that timed out) says so +/// instead: the library may well be there. +pub fn missing_library_reason(dir: &Path) -> Option { + match lightcraft_engine::library::library_state(dir) { + Ok(true) => None, + Ok(false) if dir.exists() => Some(crate::i18n::tr("There is no LightCraft library in this folder any more.").to_string()), + Ok(false) => { + Some(crate::i18n::tr("The library isn't there: it was moved or renamed, or its drive or network share isn't connected.").to_string()) + } + Err(e) => Some(format!("{} {e}", crate::i18n::tr("This folder can't be read:"))), + } +} + +/// Try Again: reopen the problem's library. One that must exist already (`existing_only`) is +/// opened only once its folder holds a library again (a share remounted): until then the window +/// stays with the reason, and nothing is created there (issue #3239). +fn retry(app: &mut LightcraftApp, problem: LibraryProblem) { + if problem.existing_only + && let Some(why) = missing_library_reason(Path::new(&problem.path)) + { + if let Some(p) = app.library_problem.as_mut() { + p.error = why; + p.dismissed = false; + } + return; + } + open(app, Some(problem.path)); +} + /// What the user chose. #[derive(Clone, Copy, Debug, PartialEq)] enum Choice { @@ -807,6 +847,10 @@ fn open(app: &mut LightcraftApp, path: Option) { } } if let Some(path) = path { + // another library failed: Try Again may make that one, as it always could + if p.path != path { + p.existing_only = false; + } p.path = path; } p.error = e; @@ -1073,7 +1117,7 @@ fn act(app: &mut LightcraftApp, choice: Choice) { let Some(problem) = app.library_problem.clone() else { return }; let chosen = problem.chooser.as_ref().and_then(|c| c.selected.and_then(|i| c.libraries.get(i)).map(|l| (l.clone(), c.over))); match choice { - Choice::Retry => open(app, Some(problem.path)), + Choice::Retry => retry(app, problem), Choice::Choose => open(app, None), Choice::Open => { let Some((library, over)) = chosen.filter(|(l, _)| l.unavailable.is_none()) else { return }; diff --git a/crates/ui-egui/src/tests/launch_library.rs b/crates/ui-egui/src/tests/launch_library.rs index 448bb735a..935a44b25 100644 --- a/crates/ui-egui/src/tests/launch_library.rs +++ b/crates/ui-egui/src/tests/launch_library.rs @@ -50,30 +50,35 @@ fn path(s: &str) -> Option { Some(PathBuf::from(s)) } -/// `--library`, then `LIGHTCRAFT_LIBRARY`, then the setting; `--memory` never asks. +/// `--library`, then `LIGHTCRAFT_LIBRARY`, then the setting; `--memory` never asks. Only the +/// library remembered in Settings is `remembered` (issue #3239: a missing one is reported, not +/// made anew), and not when it is the default location. #[test] fn the_launch_plan_puts_the_flag_and_the_variable_before_the_setting() { let ask = AppSettings { at_launch: LaunchLibrary::Ask, library_path: "/last".into(), ..AppSettings::default() }; let last = AppSettings { library_path: "/last".into(), recent_libraries: vec!["/last".into(), "/older".into()], ..AppSettings::default() }; + let at_default = AppSettings { library_path: "/default".into(), ..AppSettings::default() }; let fresh = AppSettings::default(); - let open = |dir: &str, alt_asks| Launch::Open { dir: path(dir), alt_asks }; + let open = |dir: &str, alt_asks, remembered| Launch::Open { dir: path(dir), alt_asks, remembered }; // --library, then LIGHTCRAFT_LIBRARY, whatever the setting says; neither asks on Alt for s in [None, Some(&ask), Some(&last)] { - assert_eq!(launch_plan(false, path("/flag"), path("/env"), s, path("/default")), open("/flag", false)); - assert_eq!(launch_plan(false, None, path("/env"), s, path("/default")), open("/env", false)); + assert_eq!(launch_plan(false, path("/flag"), path("/env"), s, path("/default")), open("/flag", false, false)); + assert_eq!(launch_plan(false, None, path("/env"), s, path("/default")), open("/env", false, false)); } // Ask me asks, with or without a recent list assert_eq!(launch_plan(false, None, None, Some(&ask), path("/default")), Launch::Ask); let ask_empty = AppSettings { at_launch: LaunchLibrary::Ask, ..AppSettings::default() }; assert_eq!(launch_plan(false, None, None, Some(&ask_empty), None), Launch::Ask); // Open the last library: the last one, else the default place; Alt can still ask - assert_eq!(launch_plan(false, None, None, Some(&last), path("/default")), open("/last", true)); - assert_eq!(launch_plan(false, None, None, Some(&fresh), path("/default")), open("/default", true)); - assert_eq!(launch_plan(false, None, None, None, path("/default")), open("/default", true)); - assert_eq!(launch_plan(false, None, None, None, None), Launch::Open { dir: None, alt_asks: true }); + assert_eq!(launch_plan(false, None, None, Some(&last), path("/default")), open("/last", true, true)); + assert_eq!(launch_plan(false, None, None, Some(&last), None), open("/last", true, true)); + assert_eq!(launch_plan(false, None, None, Some(&at_default), path("/default")), open("/default", true, false)); + assert_eq!(launch_plan(false, None, None, Some(&fresh), path("/default")), open("/default", true, false)); + assert_eq!(launch_plan(false, None, None, None, path("/default")), open("/default", true, false)); + assert_eq!(launch_plan(false, None, None, None, None), Launch::Open { dir: None, alt_asks: true, remembered: false }); // --memory never asks and opens no folder - assert_eq!(launch_plan(true, None, None, Some(&ask), path("/default")), Launch::Open { dir: None, alt_asks: false }); - assert_eq!(launch_plan(true, path("/flag"), None, Some(&last), None), Launch::Open { dir: None, alt_asks: false }); + assert_eq!(launch_plan(true, None, None, Some(&ask), path("/default")), Launch::Open { dir: None, alt_asks: false, remembered: false }); + assert_eq!(launch_plan(true, path("/flag"), None, Some(&last), None), Launch::Open { dir: None, alt_asks: false, remembered: false }); } /// Each listed library is checked when the window opens; the last one opened is selected. @@ -1027,3 +1032,103 @@ fn found_again(multipass: bool) { drop(h); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #3239: Try Again on a remembered library that was missing at launch makes no library in +/// its folder when the folder is there but empty (a share's mount point before the share is +/// connected): the window stays and says so. Once the folder holds the library again, it opens. +#[test] +fn try_again_on_a_missing_remembered_library_makes_nothing_in_an_empty_folder() { + let dir = temp("retry-existing"); + let mount = dir.join("Share"); + std::fs::create_dir_all(&mount).unwrap(); + let path = mount.to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.library_problem = Some(LibraryProblem { existing_only: true, ..LibraryProblem::new(path.clone(), "missing") }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + settle(&mut h); + assert!(!lightcraft_engine::library::is_library(&mount), "no catalog is made in the empty folder"); + assert_eq!(std::fs::read_dir(&mount).unwrap().count(), 0, "nothing is written there"); + assert!(h.app.session.library.is_none()); + let p = h.app.library_problem.as_ref().expect("the window stays"); + assert_eq!(p.error, "There is no LightCraft library in this folder any more."); + assert!(has(&h, "button:libraryRetry")); + // the share is connected again: Try Again opens the library in it + make_library(&mount); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(path)); + assert!(h.app.library_problem.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #3239: why Try Again didn't open a remembered library is in the interface language, +/// both when its folder is empty and when the folder itself is gone. +#[test] +fn try_again_says_why_the_remembered_library_is_missing_in_the_interface_language() { + let dir = temp("retry-lang"); + let mount = dir.join("Share"); + std::fs::create_dir_all(&mount).unwrap(); + let path = mount.to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.language = crate::i18n::Locale::De; + app.library_problem = Some(LibraryProblem { existing_only: true, ..LibraryProblem::new(path, "missing") }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + settle(&mut h); + let error = h.app.library_problem.as_ref().map(|p| p.error.clone()).unwrap_or_default(); + assert_eq!(error, "In diesem Ordner ist keine LightCraft-Bibliothek mehr."); + std::fs::remove_dir(&mount).unwrap(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + settle(&mut h); + let error = h.app.library_problem.as_ref().map(|p| p.error.clone()).unwrap_or_default(); + crate::i18n::set_language(crate::i18n::Locale::En); + assert!(error.starts_with("Die Bibliothek ist nicht da:"), "{error}"); + assert!(!mount.exists(), "nothing is made where it was"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #3239: a remembered library whose folder can't be looked into (no permission) isn't +/// reported as gone: Try Again says the folder can't be read, and nothing is written there. +#[cfg(unix)] +#[test] +fn try_again_on_a_remembered_library_it_may_not_read_says_so() { + use std::os::unix::fs::PermissionsExt; + let dir = temp("retry-denied"); + let locked = dir.join("Locked"); + let path = make_library(&locked); + let names = |dir: &Path| { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name()).collect(); + names.sort(); + names + }; + let before = names(&locked); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let readable = std::fs::read_dir(&locked).is_ok(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.library_problem = Some(LibraryProblem { existing_only: true, ..LibraryProblem::new(path, "missing") }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + if !readable { + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + settle(&mut h); + } + let error = h.app.library_problem.as_ref().map(|p| p.error.clone()).unwrap_or_default(); + let library = h.app.session.library.is_some(); + drop(h); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + if readable { + let _ = std::fs::remove_dir_all(&dir); + lightcraft_testkit::skip!(System, "running as root: a folder with mode 000 is still readable"); + return; + } + assert!(error.starts_with("This folder can't be read:"), "{error}"); + assert!(error.to_lowercase().contains("permission denied"), "{error}"); + assert!(!library, "nothing opened"); + assert_eq!(names(&locked), before, "nothing written there"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e9dcbb002..b8f690513 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -701,7 +701,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | 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`, `cmd:file.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `crates/ui-egui/src/dialogs/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; VIDEO records match the Videos filter, with reimport repairing older image classification while keeping edits and memberships; 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/dialogs/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests/launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; 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; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290); a library's `catalog.snap` or folder given at launch (a double-click in the file manager) opens that library, through the usual launch path (lock check), makes it the library in use and is never imported; a second library argument gets a notice | +| 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/dialogs/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests/launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; 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; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290); the library last opened that is missing at launch (moved, or on a drive or network share that isn't connected) opens the can't-open window (Try Again, Choose Another, Continue Without Saving, Quit) instead of a new library with demo photos in its place (`apps/lightcraft/src/tests.rs`: `a_remembered_library_missing_at_launch_is_reported_not_made_anew`, issue #3239); a library's `catalog.snap` or folder given at launch (a double-click in the file manager) opens that library, through the usual launch path (lock check), makes it the library in use and is never imported; a second library argument gets a notice | | 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); the rows of the standard places themselves (Pictures, Desktop, Downloads, Home) offer neither Rename Folder… nor Move Folder To…, while the folders inside them and the ones kept with Browse Folder… do, starting from their real name; `folder.rename` and `folder.move` refuse the home folder and its Pictures, Desktop and Downloads for every caller | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests/panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, which is kept only once the move has started, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 44936b5fa..61ff091d2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,18 @@ # What's new in LightCraft +## 2026-10-29 + +### A library on a disconnected drive is reported at launch, not replaced +- When the library LightCraft last opened is missing at launch, because its drive or network share isn't connected + yet or it was moved, the window now says it couldn't open the library and offers Try Again, Choose Another + Library…, Continue Without Saving and Quit. Before, LightCraft quietly made a new, empty library at that path, + filled it with the demo photos and opened it, so it looked as if the photos and edits were gone. Nothing is + written there now, and Try Again opens the library once its drive or share is back: on an empty folder left in + its place, such as a share's mount point, it says so again instead of making a new library there. A library + folder LightCraft isn't allowed to read is reported as unreadable, with the system's reason, not as gone. A + library given with `--library` or `LIGHTCRAFT_LIBRARY`, and the default location on first launch, are still + created as before. + ## 2026-10-28 ### Local's standard places can't be renamed by mistake