LR-IMP-DEVICE: import from camera / memory card

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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 23:53:49 -04:00
1 parent 64d7ffe3a7
commit 967bf3d8fb
8 files changed
+162 -3

No files matched your search

+18
View File
@@ -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}");
}
+3
View File
@@ -240,6 +240,9 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+105
View File
@@ -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<std::path::PathBuf> {
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<Device> {
use std::sync::Mutex;
use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, Vec<Device>)>> = 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<Device> {
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<std::path::PathBuf>) -> Vec<Device> {
let mut roots: Vec<std::path::PathBuf> = 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<Device> = 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);
}
}
+1
View File
@@ -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;
+7
View File
@@ -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);
}
+9
View File
@@ -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<Vec<MenuNode>> {
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<MenuNode> = [("pick", "Picks"), ("reject", "Rejects"), ("none", "Unflagged")]
.into_iter()
+16
View File
@@ -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<Re
app.ui.quit = true;
Ok(Value::Null)
}
"file.addFromDevice" => {
// 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) {
+3 -3
View File
@@ -21,7 +21,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
<!-- parity:summary -->
| 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%) |
<!-- /parity:summary -->
## 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 |