diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index dfee9e7c..3cd795a3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -732,7 +732,7 @@ pub fn specs() -> Vec { ), 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?}", diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index bc82a727..825ed5f6 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -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 diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 2d60427d..a63a58ee 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -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] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 72b00c84..e58854a6 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -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, pub candidates: Vec, pub checked: Vec, /// 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, } /// 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) -> Result) -> Result) -> 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 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(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f0a46f95..16aa700e 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -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> { 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 = 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 = 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(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c2c117a1..721f177e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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, ""), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 78651bd8..3a71a55d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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", diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 5af5fa46..43fd84b8 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -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; } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6141e48b..ad759a06 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, }, diff --git a/docs/parity.md b/docs/parity.md index 28075fc8..7fffddfe 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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` | | diff --git a/docs/web.md b/docs/web.md index 8af632c3..6efe1345 100644 --- a/docs/web.md +++ b/docs/web.md @@ -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…* (⌘⇧I) opens the +- **Importing photos with no filesystem.** *File ▸ Import Photos…* (⌘⇧I) 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