mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
64d7ffe3a7
commit
967bf3d8fb
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user