From 967bf3d8fb2f09d1ca31e29f963fd05e4ef3598a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:53:49 -0400 Subject: [PATCH] LR-IMP-DEVICE: import from camera / memory card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit library.devices lists mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters; LIGHTCRAFT_DEVICE_ROOTS overrides; cached for 2 s). File → Add from Device opens the import review of a card's DCIM folder, set to copy into the library. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/tests/cli.rs | 18 ++++++ crates/engine/src/cmd/library.rs | 3 + crates/engine/src/devices.rs | 105 +++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/ui-egui/src/headless.rs | 7 +++ crates/ui-egui/src/menubar.rs | 9 +++ crates/ui-egui/src/menus.rs | 16 +++++ docs/parity.md | 6 +- 8 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/devices.rs diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index ab91901d..913200d2 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -224,3 +224,21 @@ fn run_subcommand_chains_commands_and_persists_a_library() { let (ok, _, err) = run_cli(&["--demo", "x=1"], None); assert!(!ok && err.contains("before any command"), "{err}"); } + +#[test] +fn devices_are_listed_and_imported_from() { + let base = tmp("devices"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("CARD/DCIM/100TEST")).unwrap(); + gradient_png(&base.join("CARD/DCIM/100TEST/IMG_0001.png")); + let o = Command::new(BIN).env("LIGHTCRAFT_DEVICE_ROOTS", &base).args(["run", "--demo", "library.devices"]).output().unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap(); + let dev = &line["result"][0]; + assert_eq!(dev["name"], "CARD", "{line}"); + // the import review of its DCIM folder finds the photo + let dcim = dev["path"].as_str().unwrap().to_string(); + let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths=[\"{dcim}\"]")]).output().unwrap(); + let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap(); + assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}"); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b652acec..7fd07da8 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -240,6 +240,9 @@ pub fn specs() -> Vec { s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; Ok(json!({"selected": s.selection.ids.len()})) }), + cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { + Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default()) + }), cmd!( "library.selectBy", "Select by Flag, Rating or Label", diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs new file mode 100644 index 00000000..0ad26922 --- /dev/null +++ b/crates/engine/src/devices.rs @@ -0,0 +1,105 @@ +//! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera +//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and +//! the import review copies from them into the library. + +use serde::Serialize; + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Device { + /// The volume's name ("EOS_DIGITAL", "Untitled"). + pub name: String, + /// Its `DCIM` folder (what gets imported). + pub path: String, + /// The volume's mount point. + pub root: String, +} + +/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS` +/// (paths joined like `PATH`) replaces them (tests, unusual mounts). +fn mount_parents() -> Vec { + if let Some(v) = std::env::var_os("LIGHTCRAFT_DEVICE_ROOTS") { + return std::env::split_paths(&v).collect(); + } + let mut out = Vec::new(); + if cfg!(target_os = "macos") { + out.push("/Volumes".into()); + } else if cfg!(target_os = "linux") { + if let Ok(user) = std::env::var("USER") { + out.push(format!("/media/{user}").into()); + out.push(format!("/run/media/{user}").into()); + } + out.push("/media".into()); + out.push("/mnt".into()); + } + out +} + +/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds: +/// menus ask often, and listing mounts can be slow (network volumes). +pub fn devices() -> Vec { + use std::sync::Mutex; + use std::time::{Duration, Instant}; + static CACHE: Mutex)>> = Mutex::new(None); + let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((t, d)) = c.as_ref() + && t.elapsed() < Duration::from_secs(2) + { + return d.clone(); + } + let d = scan(); + *c = Some((Instant::now(), d.clone())); + d +} + +fn scan() -> Vec { + let mut extra = Vec::new(); + if cfg!(windows) && std::env::var_os("LIGHTCRAFT_DEVICE_ROOTS").is_none() { + extra.extend((b'D'..=b'Z').map(|c| std::path::PathBuf::from(format!("{}:\\", c as char)))); + } + devices_in(&mount_parents(), extra) +} + +/// Devices among the children of `parents`, plus the volume roots `extra`. +pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec) -> Vec { + let mut roots: Vec = Vec::new(); + for parent in parents { + if let Ok(rd) = std::fs::read_dir(parent) { + let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + v.sort(); + // the startup disk shows up in /Volumes as a symlink to / + roots.extend(v.into_iter().filter(|p| !p.is_symlink())); + } + } + roots.extend(extra); + let mut out: Vec = Vec::new(); + for root in roots { + let Ok(rd) = std::fs::read_dir(&root) else { continue }; + let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else { + continue; + }; + let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string()); + if !out.iter().any(|d| d.root == root.to_string_lossy()) { + out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn volumes_with_a_dcim_folder_are_devices() { + let base = std::env::temp_dir().join(format!("lc-devices-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap(); + std::fs::create_dir_all(base.join("Backup/Photos")).unwrap(); + std::fs::create_dir_all(base.join("SD/dcim")).unwrap(); + let d = devices_in(std::slice::from_ref(&base), Vec::new()); + let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}"); + assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim")); + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 868607bf..c9bd0b5a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod cmd; pub mod crs; pub mod demo; +pub mod devices; pub mod export; pub mod files; pub mod import; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d700d7c2..5748b072 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -676,6 +676,13 @@ mod tests { h.settle(SETTLE); let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too"); + assert!(!opts.copy, "a folder is added in place by default"); + // a camera / card folder: copied into the library by default + h.app.ui.dialog = None; + let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert!(opts.copy); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index e27e2461..9b26ceae 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -54,6 +54,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ &[ "file.addPhotos", "file.addFolder", + "@Add from Device", "---", "app.openLibrary", "---", @@ -344,6 +345,14 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } + "Add from Device" => { + let devices = lightcraft_engine::devices::devices(); + if devices.is_empty() { + vec![item("file.addFromDevice", Value::Null, "No Camera or Card Found", None, false, None)] + } else { + devices.into_iter().map(|d| item("file.addFromDevice", json!({"path": d.path}), format!("{}…", d.name), None, true, None)).collect() + } + } "Select by" => { let mut v: Vec = [("pick", "Picks"), ("reject", "Rejects"), ("none", "Unflagged")] .into_iter() diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e566c637..43d25290 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -88,6 +88,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), ("file.addFolder", "Add Folder…", None, "File"), + ("file.addFromDevice", "Add from Device", None, ""), ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), ("file.importPresets", "Import Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), @@ -582,6 +583,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // a camera / card: review its DCIM folder, copying into the library by default + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => match lightcraft_engine::devices::devices().into_iter().next() { + Some(d) => d.path, + None => return Some(Err("no camera or memory card found".into())), + }, + }; + let r = crate::import::open(app, vec![path]); + if let Some(crate::state::Dialog::Import { opts }) = &mut app.ui.dialog { + opts.copy = true; + } + r + } "file.addFolder" => { // a folder (searched recursively) into the import review let path = match p.get("path").and_then(Value::as_str) { diff --git a/docs/parity.md b/docs/parity.md index f958f5b4..8e0c2bfb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,7 +21,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| -| A. Import (IMP) | 5 | 3 | 5 | 1 | 2/4 (50%) | 2/4 (50%) | +| A. Import (IMP) | 6 | 3 | 4 | 1 | 2/4 (50%) | 3/4 (75%) | | B. Library management (LIB) | 18 | 1 | 6 | 2 | 9/9 (100%) | 8/9 (89%) | | C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 65 | 9 | 11 | 8 | 46/47 (98%) | 17/23 (74%) | | Z. Keyboard shortcuts (desktop) | 61 | 12 | 7 | 1 | 49/52 (94%) | 12/23 (52%) | | Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) | -| **Total** | 296 | 63 | 141 | 37 | 192/198 (97%) | 94/144 (65%) | +| **Total** | 297 | 63 | 140 | 37 | 192/198 (97%) | 95/144 (66%) | ## Top gaps @@ -99,7 +99,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog: candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked), destination, album (existing/new), preset, keywords; batched import with a progress window; one undo step. Drag-and-drop still imports directly | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | -| LR-IMP-DEVICE | Import from camera/card | P1 | ⬜ | | no device detection | +| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Add from Device → the import review, copying into the library by default; no PTP/MTP (cameras that don't mount as a disk) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ⬜ | | | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |