Merge pull request #73 from storytold/fix/issue-30-import-entry-point

CI: clean under clippy 1.99 (as_chunks for constant chunk sizes, ? in export resize parsing)
This commit is contained in:
Brandon Thomas 2026-10-04 14:01:10 -04:00 committed by GitHub
commit c02b39a15d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 100 additions and 27 deletions

View File

@ -732,7 +732,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"library.import",
"Add Photos…",
"Import Photos",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",

View File

@ -1,5 +1,5 @@
//! 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
//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and
//! the import review copies from them into the library.
//!
//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the

View File

@ -942,7 +942,7 @@ mod tests {
assert!(h.quit_requested());
}
/// File → Add Folder… opens the import review for a folder (searched recursively).
/// File → Import from Folder… opens the import review for a folder (searched recursively).
#[test]
fn add_folder_opens_the_import_review() {
let mut h = demo([1200.0, 900.0]);
@ -964,6 +964,13 @@ mod tests {
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");
// the review names its source; scanning doesn't save a Local location
assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]);
let name = dir.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)"));
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importSource"), "source shown");
assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved");
// 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);
@ -1157,7 +1164,7 @@ mod tests {
h.settle(SETTLE);
}
/// File → Add Photos… opens the import review: candidates with thumbnails, the duplicate is
/// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is
/// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album
/// with keywords, as one undo step.
#[test]

View File

@ -1,7 +1,8 @@
//! The import review dialog (File → Add Photos…): the files found under the chosen files and
//! folders as a grid of thumbnails with checkboxes (duplicates marked and unchecked), the
//! destination (add in place / copy into the library's `Originals/` or a chosen folder, filed by day, by month,
//! into one folder or by a custom folder template, optionally renamed), an album
//! The import review dialog (File → Import Photos… / Import from Folder… / Import from Device):
//! the source it scanned (a scanned folder is *not* added to Local), the files found under it as
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked), the destination (add in
//! place / copy into the library's `Originals/` or a chosen folder, filed by day, by month, into
//! one folder or by a custom folder template, optionally renamed), an album
//! (existing or new), a preset and keywords to apply. Importing runs in small batches, one per
//! frame, with a progress window; the whole import is one undo step.
@ -22,6 +23,8 @@ pub(crate) const BATCH: usize = 8;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ImportDialog {
/// The files / folders that were scanned (shown as the source).
pub sources: Vec<String>,
pub candidates: Vec<ImportCandidate>,
pub checked: Vec<bool>,
/// Copy into the library (else add in place).
@ -111,6 +114,8 @@ pub struct ScanTask {
pub copy: bool,
/// Browsing a folder (Local): the photos are read in place instead of opening the review.
browse: bool,
/// What is being scanned (the review's source).
sources: Vec<String>,
}
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
@ -118,6 +123,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String
if app.scan.is_some() {
return Err("a scan is already running".into());
}
let sources = paths.clone();
let (input, paths) = ScanInput::new(&mut app.session, &paths);
let progress = std::sync::Arc::new(ScanProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
@ -129,7 +135,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false });
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false, sources });
Ok(json!({"scanning": true}))
}
@ -190,7 +196,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false, browse: true });
app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new() });
app.renderer.forget_imports();
Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true}))
}
@ -231,6 +237,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
app.renderer.forget_imports();
let mut d = ImportDialog::new(out.candidates);
d.copy = task.copy;
d.sources = task.sources;
app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) });
}
@ -360,7 +367,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(f) = task.first {
let _ = app.run("library.select", json!({"ids": [f]}));
}
let mut msg = format!("Added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
let mut msg = format!("Imported {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
if task.duplicates > 0 {
msg.push_str(&format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" }));
}
@ -426,7 +433,18 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
});
ui.add_space(4.0);
// options
field(ui, "Destination", |ui| {
if !d.sources.is_empty() {
field(ui, "Source", |ui| {
let r = ui.label(egui::RichText::new(source_summary(&d.sources)).color(t.text_label)).on_hover_text(d.sources.join("\n"));
register(ui.ctx(), "label:importSource", r.rect);
});
ui.label(
egui::RichText::new("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")
.color(t.text_dim)
.small(),
);
}
field(ui, "Transfer", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() {
d.copy = false;
@ -437,6 +455,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
d.copy = true;
}
});
ui.label(
egui::RichText::new(if d.copy {
"Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are."
} else {
"Add in place: the library references the files where they are; nothing is copied or moved."
})
.color(t.text_dim)
.small(),
);
if d.copy {
field(ui, "Copy to", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
@ -775,6 +802,23 @@ pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<Stri
Some(parts.join(sep))
}
/// The import source in a few words: a folder's name, a file's name, or "N files and folders".
pub fn source_summary(sources: &[String]) -> String {
let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string());
match sources {
[one] if std::path::Path::new(one).is_dir() => format!("Folder “{}” (and its subfolders)", name(one)),
[one] => name(one),
many => {
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
match folders {
0 => format!("{} files", many.len()),
f if f == many.len() => format!("{f} folders (and their subfolders)"),
f => format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }),
}
}
}
}
/// A labelled row (fixed label column).
fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());

View File

@ -55,7 +55,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
&[
"file.addPhotos",
"file.addFolder",
"@Add from Device",
"@Import from Device",
"---",
"app.openLibrary",
"---",
@ -433,7 +433,7 @@ 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" => {
"Import 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)]
@ -733,6 +733,28 @@ mod tests {
})
}
/// File opens with the import entry points, worded as importing (not as adding a sidebar
/// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸.
#[test]
fn file_menu_starts_with_the_import_entry_points() {
let bar = menu_bar(&app());
let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1;
let labels: Vec<String> = file
.iter()
.take(3)
.map(|n| match n {
MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()),
MenuNode::Submenu { label, .. } => format!("{label} ▸"),
MenuNode::Separator => "---".into(),
})
.collect();
assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]");
assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]);
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
let text = serde_json::to_string(&all).unwrap();
assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus");
}
#[test]
fn bar_follows_the_registry_and_state() {
let mut app = app();

View File

@ -110,9 +110,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"),
("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"),
("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", 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, ""),
("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"),
("file.addFolder", "Import from Folder…", None, "File"),
("file.addFromDevice", "Import from Device", None, ""),
("file.findMissing", "Find Missing Photos…", None, "File"),
("photo.locate", "Locate Missing File…", None, ""),
("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""),

View File

@ -27,7 +27,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::NewAlbum { .. } => "Create Album",
Dialog::RenameAlbum { .. } => "Rename Album",
Dialog::Rename { .. } => "Rename Photos",
Dialog::Import { .. } => "Add Photos",
Dialog::Import { .. } => "Import Photos",
Dialog::LabelNames { .. } => "Edit Color Label Names",
Dialog::CaptureTime { .. } => "Edit Capture Time",
Dialog::RenameKeyword { .. } => "Rename Keyword",
@ -720,7 +720,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let ok = match &dlg {
Dialog::Import { opts } => {
let n = opts.selected_paths().len();
add_label = format!("Add {n} Photo{}", if n == 1 { "" } else { "s" });
add_label = format!("Import {n} Photo{}", if n == 1 { "" } else { "s" });
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",

View File

@ -66,7 +66,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
} else if app.session.source == lightcraft_engine::LibrarySource::Folder {
super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local");
} else {
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here");
}
return;
}

View File

@ -403,7 +403,7 @@ pub enum Dialog {
minutes: i32,
zone: f32,
},
/// The import review (File → Add Photos…).
/// The import review (File → Import Photos…).
Import {
opts: Box<crate::import::ImportDialog>,
},

View File

@ -99,10 +99,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| 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; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); batched import with a progress window; one undo step. Drag-and-drop still imports directly |
| 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 (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked), transfer mode (add in place / copy, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); 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 | ✅ | `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; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); no PTP/MTP (cameras that don't mount as a disk) |
| 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 → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); no PTP/MTP (cameras that don't mount as a disk) |
| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s |
| 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 |
@ -506,8 +506,8 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | |
| MENU-APP-HIDE | Hide / hide others / show all | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's own items in the app menu (⌘H, ⌥⌘H) |
| MENU-APP-QUIT | Quit | P0 | ✅ | `cmd:app.quit`, `apps/lightcraft/src/native_menu.rs` | macOS: app menu (native); elsewhere: File → Quit LightCraft, always the last item in its own group (after commands the File layout does not list) |
| MENU-FILE-ADDPHOTOS | Add Photos… | P0 | ✅ | `cmd:file.addPhotos` | |
| MENU-FILE-ADDFOLDER | Add Folder… | P0 | ✅ | `cmd:file.addFolder`, `cmd:library.importPreview` | folder picker (desktop) → the import review, subfolders included; `path` param for agents |
| MENU-FILE-ADDPHOTOS | Import Photos… (was Add Photos…) | P0 | ✅ | `cmd:file.addPhotos`, `crates/ui-egui/src/menus.rs` | first item of File, ⇧⌘I; file picker → the import review |
| MENU-FILE-ADDFOLDER | Import from Folder… (was Add Folder…) | P0 | ✅ | `cmd:file.addFolder`, `cmd:library.importPreview` | folder picker (desktop) → the import review, subfolders included; `path` param for agents. Worded as importing: it doesn't save a Local location (that is Local → Browse Folder…) |
| MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | |
| MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | |
| MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N |
@ -639,7 +639,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | `cmd:version.create` | secondary binding (primary ⌘⇧S) |
| KEY-CLOSEWINDOW | Close window — ⌘W | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native File ▸ Close Window |
| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | |
| KEY-ADDPHOTOS | Add photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | |
| KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | |
| KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | |
| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 |
| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | |

View File

@ -90,7 +90,7 @@ same-origin or send CORP/CORS headers (the bundle has no third-party resources).
(`navigator.storage.persist()`); without it the browser may evict the data under storage
pressure. URL options: `?store=idb` forces IndexedDB, `?store=memory` keeps nothing, `?reset`
deletes the stored library first.
- **Importing photos with no filesystem.** *File ▸ Add Photos…* (<kbd>⌘⇧I</kbd>) opens the
- **Importing photos with no filesystem.** *File ▸ Import Photos…* (<kbd>⌘⇧I</kbd>) opens the
browser's file picker. You can also drop files anywhere on the page. The bytes are written to
storage, then imported and decoded by the same engine code as the desktop app
(`lightcraft_engine::files::{probe_bytes, load_bytes}`): JPEG/PNG/TIFF/WebP and the supported