diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 587dae180..97354c58f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -982,7 +982,7 @@ fn main() -> eframe::Result { } // the recent libraries to choose from (issue #1290), each checked now if launch == Launch::Ask { - problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings)); + problem = Some(LibraryProblem::choose(&app.ui.settings)); } app.alt_asks_for_library = matches!(launch, Launch::Open { alt_asks: true, .. }); lightcraft_ui_egui::i18n::set_language(app.ui.language); @@ -1177,7 +1177,7 @@ mod tests { let path = d.join("ui.json"); let mut app = LightcraftApp::new(s, Services::default()); app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; - app.library_problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings)); + app.library_problem = Some(LibraryProblem::choose(&app.ui.settings)); let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; app.ui.settings.recent_libraries.remove(0); w.save(&app).unwrap(); diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index e3619d974..76f6e12cd 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -2355,5 +2355,8 @@ "Open now": "Ouverte en ce moment", "Last opened": "Ouverte le", "Not found: moved, renamed or on a disconnected drive": "Introuvable : déplacée, renommée ou sur un disque déconnecté", - "There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft" + "There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft", + "This is a file, not a library folder": "C’est un fichier, pas un dossier de bibliothèque", + "This folder can't be read:": "Ce dossier ne peut pas être lu :", + "The library's catalog can't be read:": "Le catalogue de la bibliothèque ne peut pas être lu :" } diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 48c4c5c9e..f57393f9a 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -98,21 +98,17 @@ impl LibraryProblem { LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() } } - /// The window listing the recent libraries, each checked now: one that is gone, isn't a - /// LightCraft library any more, or can't be opened (another program has it, unreadable) is - /// greyed out with the reason. The last library opened is selected, or else the first one + /// The window listing the recent libraries, each checked now without writing anything + /// ([`unavailable`]): one that is gone, isn't a LightCraft library any more or can't be read + /// is greyed out with the reason. The last library opened is selected, or else the first one /// that can be opened. - pub fn choose(session: &lightcraft_engine::Session, settings: &AppSettings) -> LibraryProblem { + pub fn choose(settings: &AppSettings) -> LibraryProblem { // as many as File ▸ Open Recent Library keeps (a hand-edited settings file may hold more) let libraries: Vec = settings .recent_libraries .iter() .take(crate::state::RECENT_LIBRARIES) - .map(|path| RecentLibrary { - path: path.clone(), - opened: settings.recent_opened.get(path).cloned(), - unavailable: unavailable(session, path), - }) + .map(|path| RecentLibrary { path: path.clone(), opened: settings.recent_opened.get(path).cloned(), unavailable: unavailable(path) }) .collect(); let can = |l: &RecentLibrary| l.unavailable.is_none(); let selected = libraries @@ -143,20 +139,33 @@ impl LibraryProblem { } } -/// Why the library at `path` can't be opened now (`None`: it can). -fn unavailable(session: &lightcraft_engine::Session, path: &str) -> Option { +/// Why the library at `path` can't be opened (`None`: as far as can be told without opening it). +/// +/// Read-only: the folder and its catalog file are looked at, never locked or written (the list +/// may name libraries on other drives, network shares or sleeping disks). A library another +/// program has open is therefore found when Open is pressed: the open fails and the window says +/// who has it. +fn unavailable(path: &str) -> Option { let dir = Path::new(path); if !dir.exists() { return Some(crate::i18n::tr("Not found: moved, renamed or on a disconnected drive").to_string()); } - // a folder that is there, readable and not held by another program - if let Err(e) = session.check_library(dir) { - return Some(e.to_string()); + if !dir.is_dir() { + return Some(crate::i18n::tr("This is a file, not a library folder").to_string()); + } + if let Err(e) = std::fs::read_dir(dir) { + return Some(format!("{} {e}", crate::i18n::tr("This folder can't be read:"))); } // opening would make a new, empty library in it if !lightcraft_engine::library::is_library(dir) { return Some(crate::i18n::tr("There is no LightCraft library in this folder any more").to_string()); } + // the catalog the open reads first: the snapshot, else the log alone (opened to read, not read) + let snapshot = dir.join(lightcraft_catalog::journal::SNAPSHOT); + let catalog = if snapshot.is_file() { snapshot } else { dir.join(lightcraft_catalog::journal::LOG) }; + if let Err(e) = std::fs::File::open(&catalog) { + return Some(format!("{} {e}", crate::i18n::tr("The library's catalog can't be read:"))); + } None } @@ -257,7 +266,7 @@ fn alt_at_launch(app: &mut LightcraftApp, ctx: &egui::Context) { if crate::panels::settings::can_switch_library(app).is_err() { return; } - let mut p = LibraryProblem::choose(&app.session, &app.ui.settings); + let mut p = LibraryProblem::choose(&app.ui.settings); if let Some(c) = p.chooser.as_mut() { c.over = app.session.library.as_ref().map(|_| app.session.library_generation()); } diff --git a/crates/ui-egui/src/tests_launch_library.rs b/crates/ui-egui/src/tests_launch_library.rs index 8f3767d9b..699e19a8b 100644 --- a/crates/ui-egui/src/tests_launch_library.rs +++ b/crates/ui-egui/src/tests_launch_library.rs @@ -84,48 +84,100 @@ fn the_list_checks_each_library_and_selects_the_last_one() { let (a, b, held_path) = (make_library(&a), make_library(&b), make_library(&held)); std::fs::create_dir_all(&plain).unwrap(); std::fs::write(plain.join("notes.txt"), b"not a library").unwrap(); - let gone = dir.join("Gone").to_string_lossy().into_owned(); - let plain = plain.to_string_lossy().into_owned(); + let file = dir.join("a-file.txt"); + std::fs::write(&file, b"a file").unwrap(); + let (gone, plain, file) = + (dir.join("Gone").to_string_lossy().into_owned(), plain.to_string_lossy().into_owned(), file.to_string_lossy().into_owned()); + // another program has it open: listing doesn't take locks, so it is found when opened let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap(); - let session = lightcraft_engine::Session::new().with_fs(); let mut settings = AppSettings { - recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), b.clone()], + recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), file.clone(), b.clone()], library_path: b.clone(), ..AppSettings::default() }; settings.recent_opened.insert(b.clone(), "2026-10-20T08:30:00".into()); - let p = LibraryProblem::choose(&session, &settings); + let p = LibraryProblem::choose(&settings); assert!(!p.saves_nothing(), "the launch list is not a temporary session: removals are saved"); let c = p.chooser.as_ref().unwrap(); let why: Vec> = c.libraries.iter().map(|l| l.unavailable.as_deref()).collect(); assert!(why[0].unwrap().contains("Not found"), "{why:?}"); - assert!(why[1].unwrap().contains("already open in another LightCraft"), "{why:?}"); + assert_eq!(why[1], None, "a lock is not probed: {why:?}"); assert_eq!(why[2], None); assert!(why[3].unwrap().contains("no LightCraft library"), "{why:?}"); - assert_eq!(why[4], None); - assert_eq!(c.selected, Some(4), "the last library opened"); - assert_eq!(c.libraries[4].opened.as_deref(), Some("2026-10-20T08:30:00")); + assert!(why[4].unwrap().contains("not a library folder"), "{why:?}"); + assert_eq!(why[5], None); + assert_eq!(c.selected, Some(5), "the last library opened"); + assert_eq!(c.libraries[5].opened.as_deref(), Some("2026-10-20T08:30:00")); assert_eq!(c.libraries[2].opened, None, "no time known"); let json = p.to_json(); - assert_eq!(json["chooser"]["libraries"][1]["available"], false); - assert_eq!(json["chooser"]["selected"], 4); + assert_eq!(json["chooser"]["libraries"][0]["available"], false); + assert_eq!(json["chooser"]["selected"], 5); // the last library can't be opened: the first one that can is selected - settings.library_path = held_path.clone(); - assert_eq!(LibraryProblem::choose(&session, &settings).chooser.unwrap().selected, Some(2)); + settings.library_path = plain.clone(); + assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(1)); // none can: the first row, Open greyed out - settings.recent_libraries = vec![gone.clone()]; - assert_eq!(LibraryProblem::choose(&session, &settings).chooser.unwrap().selected, Some(0)); + settings.recent_libraries = vec![gone.clone(), file.clone()]; + assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(0)); // an empty list selects nothing settings.recent_libraries.clear(); - let c = LibraryProblem::choose(&session, &settings).chooser.unwrap(); + let c = LibraryProblem::choose(&settings).chooser.unwrap(); assert!(c.libraries.is_empty() && c.selected.is_none()); assert!(LibraryProblem::new("/x", "e").saves_nothing(), "the failed-library window still saves nothing"); drop(lock); let _ = std::fs::remove_dir_all(&dir); } +/// Every file under `dir`: relative path → (contents, modification time). +fn snapshot(dir: &Path) -> std::collections::BTreeMap, std::time::SystemTime)> { + let mut out = std::collections::BTreeMap::new(); + let mut todo = vec![dir.to_path_buf()]; + while let Some(d) = todo.pop() { + for e in std::fs::read_dir(&d).unwrap() { + let p = e.unwrap().path(); + let m = std::fs::metadata(&p).unwrap(); + if m.is_dir() { + todo.push(p.clone()); + } + let bytes = if m.is_file() { std::fs::read(&p).unwrap() } else { Vec::new() }; + out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, m.modified().unwrap())); + } + } + out +} + +/// Listing the recent libraries (and drawing the window) writes nothing into any of them: no +/// lock file, no owner file, nothing modified — free, held by another program or not a library. +#[test] +fn listing_the_libraries_leaves_every_folder_byte_identical() { + let dir = temp("readonly"); + let (free, fresh, held) = (dir.join("Free"), dir.join("Fresh"), dir.join("Held")); + let (free, held_path) = (make_library(&free), make_library(&held)); + // a library whose lock files were never made (copied over from elsewhere, say) + let fresh_path = make_library(&fresh); + for f in [lightcraft_catalog::lock::LOCK, lightcraft_catalog::lock::OWNER] { + let _ = std::fs::remove_file(fresh.join(f)); + } + std::fs::create_dir_all(dir.join("Plain")).unwrap(); + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap(); + let recent = vec![free.clone(), fresh_path.clone(), held_path.clone(), dir.join("Plain").to_string_lossy().into_owned()]; + let before = snapshot(&dir); + // (mtime granularity: a write in the same instant would still change contents or add files) + let settings = AppSettings { recent_libraries: recent.clone(), library_path: free.clone(), ..AppSettings::default() }; + let p = LibraryProblem::choose(&settings); + assert_eq!(snapshot(&dir), before, "checking the rows must not create or change any file"); + assert!(p.chooser.as_ref().unwrap().libraries.iter().take(3).all(|l| l.unavailable.is_none())); + let mut h = asking(recent, &free, None); + settle(&mut h); + assert!(has(&h, "libraryRecent:2")); + drop(h); + assert_eq!(snapshot(&dir), before, "listing must not create or change any file"); + assert!(!fresh.join(lightcraft_catalog::lock::LOCK).exists() && !fresh.join(lightcraft_catalog::lock::OWNER).exists()); + drop(lock); + let _ = std::fs::remove_dir_all(&dir); +} + /// The app as the desktop host starts it with *Ask me*: no library open, the list showing. fn asking(recent: Vec, last: &str, picked: Option) -> Headless { let session = lightcraft_engine::Session::new().with_fs(); @@ -145,7 +197,7 @@ fn asking(recent: Vec, last: &str, picked: Option) -> Headless { if let Some(p) = picked { app.services.pick_folder = Some(Box::new(move || Some(p.clone()))); } - app.library_problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings)); + app.library_problem = Some(LibraryProblem::choose(&app.ui.settings)); let mut h = Headless::new(app, [1300.0, 900.0], 1.0); settle(&mut h); h @@ -206,7 +258,7 @@ fn return_opens_the_selected_library_and_a_failure_says_why() { settle(&mut h); assert!(h.app.session.library.is_none()); let p = h.app.library_problem.as_ref().unwrap(); - assert!(p.error.contains("already open"), "{}", p.error); + assert!(p.error.contains("already open in another LightCraft"), "who has it: {}", p.error); assert!(p.chooser.as_ref().unwrap().libraries[1].unavailable.is_some(), "greyed out now"); drop(lock); // ↑ to Alpha, Return: it opens diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1ade1b76e..d57b0fe9a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -173,7 +173,9 @@ With Settings ▸ General ▸ At launch set to Ask me (`ui.set {"settings": {"at default, opens the last library), or Alt/Option held during the first 2 s after the window appears, the desktop app shows a window listing the recent libraries (issue #1290). `--library` and `LIGHTCRAFT_LIBRARY` skip it, and so does `--memory`; `lightcraft-cli` never shows it. `ui.inspect` → `libraryProblem.chooser` is `{libraries: [{path, -lastOpened, available, reason}], selected, libraryOpen}` (else `null`; `libraryOpen: true` when it was opened over +lastOpened, available, reason}], selected, libraryOpen}` (`available` is checked read-only: the folder and its catalog +file are looked at, never locked or written, so a library another program has open shows as available and Open +reports who has it in `libraryProblem.error`) (else `null`; `libraryOpen: true` when it was opened over the last library with Alt). Each row is the widget `libraryRecent:`; the buttons are `button:libraryOpen`, `button:libraryOpenAnother` (the folder dialog of `app.openLibrary`), `button:libraryNew` (`app.newLibrary`), `button:libraryForget` (Remove from List) and `button:libraryQuit`; `ui.key` ArrowUp / ArrowDown move the selection, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a780418bc..4369d7413 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`, `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-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`, `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, locked or no-longer-library ones greyed out with the reason; 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) | +| 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`, `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) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.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, 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 10c24ca45..35e69750a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -5,8 +5,10 @@ ### Choose which library opens at launch - Settings ▸ General ▸ **At launch**: *Open the last library* (as before) or *Ask me*. With *Ask me*, LightCraft starts with a window listing your recent libraries: each one's name, the folder it is in and when you last opened - it. One that is gone (moved, renamed, on a disconnected drive), open in another program or no longer a library is - greyed out with the reason. The last library is selected; **Open** (or Return, or a double-click) opens it, + it. One that is gone (moved, renamed, on a disconnected drive), can't be read or no longer holds a library is + greyed out with the reason. Making the list only looks: it never locks or writes anything in your libraries, so one + that another program has open is reported, with who has it, when you press **Open**, and the window stays. The + last library is selected; **Open** (or Return, or a double-click) opens it, **Open Another…** asks for a folder, **New Library…** makes one, **Quit** (or Esc) quits. ↑ and ↓ move through the list. - Holding Alt (Option on a Mac) as the window appears shows the same list once, over the library that opened: open