diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 0ba56911..8d1d5497 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -748,7 +748,7 @@ pub fn specs() -> Vec { "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?}", + "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, 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; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", always, |s, p| { let paths = strs(p, "paths"); @@ -758,7 +758,8 @@ pub fn specs() -> Vec { let mode = match str_param(p, "mode").unwrap_or("add") { "add" => crate::import::ImportMode::Add, "copy" => crate::import::ImportMode::Copy, - other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))), + "move" => crate::import::ImportMode::Move, + other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))), }; let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) { Some(id) => { diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index b6a08adf..047330dc 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -6,13 +6,16 @@ //! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel //! on native targets. //! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch). -//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's -//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy. +//! 5. *Add* in place (the photo points at the original file), *copy* into the library's +//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy, or *move* +//! there: placed like a copy (with its XMP sidecars), and the original removed only once its +//! catalog record is committed and saved (see [`crate::import_move`] for the safety rules). //! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for //! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills //! in when the file has none (read before step 5, so it also files and names the copy). //! 7. Optionally apply a preset and add keywords (the import dialog's options). -//! 8. Commit all new photos as one undoable op. +//! 8. Commit all new photos as one undoable op. (Undoing a move removes the catalog records; +//! the files stay at the destination — undo never moves files back over a card.) //! //! The import dialog first calls [`scan`] (`library.importPreview`): the same expansion, probing //! and duplicate detection without adding anything, so the user can review the candidates. The @@ -40,6 +43,9 @@ pub enum ImportMode { Add, /// Copy the files into the library's `Originals/` folder. Copy, + /// Move the files there: as Copy, then each original (and its sidecars) is removed from the + /// source once the copy is verified and catalogued. + Move, } /// What an import does besides adding the photos. @@ -147,6 +153,13 @@ pub struct Duplicate { pub struct ImportReport { pub imported: Vec, pub duplicates: Vec, + /// Move: the originals that were moved (and removed from the source). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub moved: Vec, + /// Move: originals (or sidecars) left at the source, and why — the photo may still have been + /// imported (from its copy, or in place). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub kept: Vec, /// (path, error) pub failed: Vec<(String, String)>, /// Files found after expanding folders. @@ -155,6 +168,23 @@ pub struct ImportReport { pub sidecars: usize, } +/// A file moved by an import. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Moved { + pub from: String, + pub to: String, + /// Its sidecars' new paths. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub sidecars: Vec, +} + +/// A source a Move left where it was. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Kept { + pub path: String, + pub reason: String, +} + /// The develop settings a photo gets on import: raws start from their as-shot white balance with /// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the /// camera intended); a user default preset ([`ImportDefaults`]) goes on top. @@ -502,7 +532,11 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c let mode = opts.mode; // Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every // added file in its own storage already, so "copy" there means "add". - let mode = if mode == ImportMode::Copy && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode }; + let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move); + let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode }; + if mode == ImportMode::Move && opts.local { + return Err(crate::EngineError::Other("browsing a folder never moves its files".into())); + } let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); // where copies go: the chosen folder, else the library's Originals/ let copy_root = opts @@ -511,8 +545,8 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c .filter(|d| !d.trim().is_empty()) .map(std::path::PathBuf::from) .or_else(|| lib_dir.as_ref().map(|l| l.join("Originals"))); - if mode == ImportMode::Copy && copy_root.is_none() { - return Err(crate::EngineError::Other("copying needs a destination folder or an open library".into())); + if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() { + return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into())); } let mut seq = opts.rename_start.max(1); let files = expand(paths, lib_dir.as_deref()); @@ -552,6 +586,8 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c crate::memory::release(); let now = (s.clock)(); let mut ops = promote; + // Move: placed at the destination, sources untouched until the records are committed + let mut placed: Vec = Vec::new(); for (path, info) in todo.into_iter().zip(probed) { let info = match info { Ok(i) => i, @@ -586,8 +622,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); } let stored = match (mode, ©_root) { - (ImportMode::Copy, Some(root)) - if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) => + (ImportMode::Copy | ImportMode::Move, Some(root)) + if !crate::import_move::inside(Path::new(&path), root) + && !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) => { // the templates see the photo as it will be catalogued (undated: the import time) let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); @@ -599,14 +636,40 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c seq += 1; n }); - match copy_into(root, &path, &opts.organize.folders(&q), name.as_deref()) { - Ok(p) => p, - Err(e) => { - report.failed.push((path, e)); - continue; + let folders = opts.organize.folders(&q); + if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { + let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); + let name = name.unwrap_or(own); + match crate::import_move::place(Path::new(&path), &dir, &name) { + Ok(pl) => { + let dst = pl.dst.to_string_lossy().to_string(); + placed.push(pl); + dst + } + Err(e) => { + report.failed.push((path, e)); + continue; + } + } + } else { + if mode == ImportMode::Move { + let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; + report.kept.push(Kept { path: path.clone(), reason: reason.into() }); + } + match copy_into(root, &path, &folders, name.as_deref()) { + Ok(p) => p, + Err(e) => { + report.failed.push((path, e)); + continue; + } } } } + (ImportMode::Move, _) => { + let reason = "already inside the destination or the library: added where it is"; + report.kept.push(Kept { path: path.clone(), reason: reason.into() }); + path.clone() + } _ => path.clone(), }; let id = s.catalog.alloc_photo_id(); @@ -661,12 +724,52 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c p.local = opts.local; ops.push(Op::AddPhoto { photo: Box::new(p) }); } - if !ops.is_empty() { - s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })?; + let log0 = s.pending_log.len(); + if !ops.is_empty() + && let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops }) + { + // nothing was catalogued: take the moves back (their sources were never touched) + placed.iter().for_each(crate::import_move::rollback); + return Err(e); + } + if !placed.is_empty() { + finish_moves(s, placed, log0, &mut report); } Ok(report) } +/// Move, after the commit: save the library, then remove each source whose destination is +/// still intact. Nothing is removed when the library can't be saved. +fn finish_moves(s: &mut Session, placed: Vec, log0: usize, report: &mut ImportReport) { + // (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here) + let auto: Vec = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() }; + let before = s.pending_log.len(); + if let Err(e) = s.persist() { + for p in placed { + let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display()); + report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason }); + } + return; + } + for p in placed { + let from = p.src.to_string_lossy().to_string(); + match crate::import_move::finish(&p) { + Ok(kept) => { + let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect(); + report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars }); + report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason })); + } + Err(reason) => { + log::warn!("import: move {from}: {reason}"); + report.kept.push(Kept { path: from, reason }); + } + } + } + if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() { + s.auto_write_sidecars(&auto); + } +} + /// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock /// offset); for [`Session::clock`] on native hosts. pub fn system_clock() -> String { diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs new file mode 100644 index 00000000..3c949e79 --- /dev/null +++ b/crates/engine/src/import_move.rs @@ -0,0 +1,307 @@ +//! Import → **Move**: the file transfer behind [`crate::import::ImportMode::Move`]. +//! +//! Moving deletes the user's originals, so it is done in two phases and every step that could +//! lose data comes last: +//! +//! 1. [`place`] puts the photo (and its XMP sidecars) at the destination **without touching the +//! source**: a hard link when source and destination share a volume (instant, no extra space, +//! never overwrites), else a copy that is written to a new file (never overwriting), synced to +//! disk and compared byte for byte with the original. A name that is taken gets -1, -2… +//! Anything that fails is undone ([`rollback`]): the partial destination is removed and the +//! source stays as it was. +//! 2. Only after the catalog records pointing at the destinations were committed and saved +//! does [`finish`] remove each source — after checking once more that its destination is +//! still there with the same size. A source that can't be removed (a read-only card, a +//! locked file) stays and is reported; the photo is then imported from the copy. +//! +//! A `IMG_1.xmp` sidecar is shared by `IMG_1.CR3` and `IMG_1.JPG`: it is copied along with each, +//! and removed from the source only once no photo with that name is left beside it. +//! +//! (Not `std::fs::rename`: it would remove the source before the catalog knows the new place, +//! and on Unix it silently replaces an existing destination.) + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +/// A photo put in place by [`place`]; its source is untouched. +#[derive(Clone, Debug)] +pub(crate) struct Placed { + pub src: PathBuf, + pub dst: PathBuf, + pub sidecars: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct PlacedSidecar { + pub src: PathBuf, + pub dst: PathBuf, + /// `false`: an identical sidecar was already there (the other half of a raw + JPEG pair). + pub created: bool, + /// Named after the photo's stem (`IMG_1.xmp`), so possibly shared with a sibling photo. + pub stem: bool, +} + +/// Test-only fault injection (per thread), to exercise the failure paths. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[allow(dead_code)] +pub(crate) enum Fault { + #[default] + None, + /// Hard links fail, as across volumes: the copy path runs. + NoLink, + /// Writing the copy fails half-way (implies `NoLink`). + FailWrite, + /// The copy comes out different from the original, caught by the verification (implies `NoLink`). + Corrupt, + /// Removing the source fails (as on a read-only card). + FailRemove, +} + +#[cfg(test)] +thread_local! { + static FAULT: std::cell::Cell = const { std::cell::Cell::new(Fault::None) }; +} + +/// Inject a fault for the calling thread's next moves (tests only). +#[cfg(test)] +pub(crate) fn inject(f: Fault) { + FAULT.with(|c| c.set(f)); +} + +fn injected(f: Fault) -> bool { + #[cfg(test)] + { + FAULT.with(|c| c.get()) == f + } + #[cfg(not(test))] + { + let _ = f; + false + } +} + +/// Is `path` inside `dir` (as written, or once symlinks and `..` are resolved)? +pub(crate) fn inside(path: &Path, dir: &Path) -> bool { + if path.starts_with(dir) { + return true; + } + match (fs::canonicalize(path), fs::canonicalize(dir)) { + (Ok(p), Ok(d)) => p.starts_with(d), + _ => false, + } +} + +pub(crate) fn is_symlink(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) +} + +/// The XMP sidecars beside `src`, both naming conventions (`IMG_1.xmp`, `IMG_1.CR3.xmp`; the +/// `.xmp` extension in any case): (path, named after the stem). +pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> { + let (Some(dir), Some(name)) = (src.parent(), src.file_name().map(|n| n.to_string_lossy().to_string())) else { return Vec::new() }; + let stem = src.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let Ok(rd) = fs::read_dir(dir) else { return Vec::new() }; + let mut out: Vec<(PathBuf, bool)> = rd + .flatten() + .filter_map(|e| { + let n = e.file_name().to_string_lossy().to_string(); + let (base, ext) = n.rsplit_once('.')?; + if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() { + return None; + } + if base == name { + Some((e.path(), false)) + } else if base == stem { + Some((e.path(), true)) + } else { + None + } + }) + .collect(); + out.sort(); + out +} + +/// Put `src` into `dir` as `name` (-1, -2… when taken) with its sidecars, leaving the source +/// untouched. On failure nothing is left at the destination. +pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let (stem, ext) = match name.rsplit_once('.') { + Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), + _ => (name.to_string(), String::new()), + }; + let mut dst = None; + for i in 0..100_000u32 { + let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) }; + match link_or_copy(src, &d) { + Ok(()) => { + dst = Some(d); + break; + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(format!("move {}: {e} (the original is kept)", src.display())), + } + } + let Some(dst) = dst else { return Err(format!("move {}: no free name in {}", src.display(), dir.display())) }; + let mut placed = Placed { src: src.to_path_buf(), dst, sidecars: Vec::new() }; + for (sc, stem_named) in sidecars_of(src) { + let sc_ext = sc.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "xmp".into()); + let sc_dst = + if stem_named { placed.dst.with_extension(&sc_ext) } else { PathBuf::from(format!("{}.{sc_ext}", placed.dst.to_string_lossy())) }; + let r = if sc_dst.exists() { + match same_bytes(&sc, &sc_dst) { + Ok(true) => Ok(false), + Ok(false) => Err(format!("a different sidecar is already at {} (the original is kept)", sc_dst.display())), + Err(e) => Err(format!("{}: {e} (the original is kept)", sc_dst.display())), + } + } else { + link_or_copy(&sc, &sc_dst).map(|()| true).map_err(|e| format!("move {}: {e} (the original is kept)", sc.display())) + }; + match r { + Ok(created) => placed.sidecars.push(PlacedSidecar { src: sc, dst: sc_dst, created, stem: stem_named }), + Err(e) => { + rollback(&placed); + return Err(e); + } + } + } + Ok(placed) +} + +/// Undo a [`place`]: remove what it created at the destination (the sources were never touched). +pub(crate) fn rollback(p: &Placed) { + for sc in p.sidecars.iter().filter(|s| s.created) { + let _ = fs::remove_file(&sc.dst); + } + let _ = fs::remove_file(&p.dst); +} + +/// Remove a placed photo's source (its catalog record is committed and saved): only when the +/// destination is still there with the same size. Then its sidecars, unless another photo still +/// uses a shared one. `Err` = the source was kept (why); `Ok` lists sidecars kept (path, why). +pub(crate) fn finish(p: &Placed) -> Result, String> { + let same = match (fs::metadata(&p.src), fs::metadata(&p.dst)) { + (Ok(s), Ok(d)) => s.is_file() && d.is_file() && s.len() == d.len(), + _ => false, + }; + if !same { + return Err(format!("{} is missing or changed: the original is kept", p.dst.display())); + } + if injected(Fault::FailRemove) { + return Err("could not remove the original (injected): it stays where it was".into()); + } + fs::remove_file(&p.src).map_err(|e| format!("could not remove the original ({e}): it stays where it was"))?; + let mut kept = Vec::new(); + for sc in &p.sidecars { + let path = sc.src.to_string_lossy().to_string(); + if !same_bytes(&sc.src, &sc.dst).unwrap_or(false) { + kept.push((path, format!("{} is missing or changed: the sidecar is kept", sc.dst.display()))); + continue; + } + if sc.stem + && let Some(other) = sibling_photo(&sc.src) + { + kept.push((path, format!("also the sidecar of {other}: kept beside it"))); + continue; + } + if let Err(e) = fs::remove_file(&sc.src) { + kept.push((path, format!("could not remove the sidecar ({e})"))); + } + } + Ok(kept) +} + +/// A photo (by extension) still beside a stem-named sidecar, sharing its stem. +fn sibling_photo(sidecar: &Path) -> Option { + let dir = sidecar.parent()?; + let stem = sidecar.file_stem()?.to_string_lossy().to_string(); + fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find_map(|f| { + let ok = f.file_stem().is_some_and(|s| s.to_string_lossy() == stem) && crate::import::is_supported(&f); + ok.then(|| f.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()) + }) +} + +/// `dst` = `src` without touching `src`: a hard link, else a verified copy. Never overwrites +/// (`AlreadyExists` when `dst` is taken). +fn link_or_copy(src: &Path, dst: &Path) -> io::Result<()> { + let force_copy = injected(Fault::NoLink) || injected(Fault::FailWrite) || injected(Fault::Corrupt); + if !force_copy && !is_symlink(src) { + match fs::hard_link(src, dst) { + Ok(()) => { + let (a, b) = (fs::metadata(src)?, fs::metadata(dst)?); + if a.len() == b.len() { + return Ok(()); + } + let _ = fs::remove_file(dst); + return Err(io::Error::other("the link doesn't match the original")); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(e), + // another volume, or no hard links there (FAT/exFAT cards, some shares): copy + Err(_) => {} + } + } + copy_verified(src, dst) +} + +/// Copy into a new file, sync it to disk and compare it with the original; on any failure the +/// partial copy is removed. +fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> { + let mut out = OpenOptions::new().write(true).create_new(true).open(dst)?; + let r = (|| { + let mut inp = File::open(src)?; + if injected(Fault::FailWrite) { + out.write_all(b"partial")?; + return Err(io::Error::other("write failed (injected)")); + } + io::copy(&mut inp, &mut out)?; + if injected(Fault::Corrupt) { + out.write_all(b"!")?; + } + out.sync_all()?; + if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) { + let _ = out.set_modified(m); + } + if !same_bytes(src, dst)? { + return Err(io::Error::other("the copy differs from the original")); + } + Ok(()) + })(); + drop(out); + if r.is_err() { + let _ = fs::remove_file(dst); + } + r +} + +/// Do two files hold the same bytes? +pub(crate) fn same_bytes(a: &Path, b: &Path) -> io::Result { + if fs::metadata(a)?.len() != fs::metadata(b)?.len() { + return Ok(false); + } + let (mut fa, mut fb) = (File::open(a)?, File::open(b)?); + let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]); + loop { + let (na, nb) = (fill(&mut fa, &mut ba)?, fill(&mut fb, &mut bb)?); + if na != nb || ba.get(..na) != bb.get(..nb) { + return Ok(false); + } + if na == 0 { + return Ok(true); + } + } +} + +/// Read until `buf` is full or the file ends. +fn fill(f: &mut File, buf: &mut [u8]) -> io::Result { + let mut n = 0; + while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) { + match f.read(rest) { + Ok(0) => break, + Ok(k) => n += k, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(n) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index adbdc495..d32dfe27 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod export; pub mod files; pub mod guard; pub mod import; +mod import_move; pub mod library; pub mod media; pub mod memory; @@ -600,6 +601,8 @@ mod tests_export; #[cfg(test)] mod tests_import; #[cfg(test)] +mod tests_import_move; +#[cfg(test)] mod tests_libops; #[cfg(test)] mod tests_library; diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs new file mode 100644 index 00000000..59591065 --- /dev/null +++ b/crates/engine/src/tests_import_move.rs @@ -0,0 +1,278 @@ +//! Import → Move: files (and their sidecars) end up at the destination, and a source is removed +//! only when its move fully succeeded. Temp dirs only. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::Session; +use crate::import_move::{Fault, inject}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-move-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn write_png(path: &Path, seed: u8) { + let (w, h) = (40usize, 24usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn xmp(rating: u8) -> String { + format!( + r#""# + ) +} + +fn session() -> Session { + let mut s = Session::new().with_fs(); + // undated files are filed by the import time + s.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + s +} + +fn import_move(s: &mut Session, paths: &[&Path], dest: &Path, extra: Value) -> Value { + let mut p = + json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::>(), "mode": "move", "destination": dest.to_string_lossy()}); + if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + s.execute("library.import", &p).unwrap() +} + +fn len(v: &Value, key: &str) -> usize { + v[key].as_array().map(Vec::len).unwrap_or(0) +} + +fn photo_paths(s: &Session) -> Vec { + let mut v: Vec = s + .catalog + .photos() + .filter_map(|p| match &p.source { + lightcraft_catalog::Source::File { path } => Some(path.clone()), + _ => None, + }) + .collect(); + v.sort(); + v +} + +fn files_under(d: &Path) -> Vec { + fn walk(d: &Path, base: &Path, out: &mut Vec) { + for e in std::fs::read_dir(d).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + walk(&p, base, out); + } else { + out.push(p.strip_prefix(base).unwrap().to_string_lossy().replace('\\', "/")); + } + } + } + let mut out = Vec::new(); + walk(d, d, &mut out); + out.sort(); + out +} + +/// Same volume: renamed into the folder template, sidecars (both namings) along, sources gone, +/// the catalog points at the new files; undo forgets the photos but leaves the files. +#[test] +fn move_into_a_folder_template_with_sidecars() { + let base = temp_dir("same"); + let (card, dest) = (base.join("card"), base.join("Photos")); + write_png(&card.join("a.png"), 1); + std::fs::write(card.join("a.xmp"), xmp(3)).unwrap(); + write_png(&card.join("b.png"), 2); + std::fs::write(card.join("b.png.xmp"), xmp(4)).unwrap(); + let a_bytes = std::fs::read(card.join("a.png")).unwrap(); + let mut s = session(); + let r = import_move(&mut s, &[&card], &dest, json!({"organize": "{date:%Y}/{date:%Y%m%d}", "rename": "{date:%Y%m%d}_{seq:3}"})); + assert_eq!(len(&r, "imported"), 2, "{r}"); + assert_eq!(len(&r, "moved"), 2, "{r}"); + assert_eq!(len(&r, "kept"), 0, "{r}"); + assert_eq!( + files_under(&dest), + ["2026/20260114/20260114_001.png", "2026/20260114/20260114_001.xmp", "2026/20260114/20260114_002.png", "2026/20260114/20260114_002.png.xmp"] + ); + assert_eq!(files_under(&card), Vec::::new(), "the card is empty"); + assert_eq!(std::fs::read(dest.join("2026/20260114/20260114_001.png")).unwrap(), a_bytes); + let day = dest.join("2026").join("20260114"); + assert_eq!(photo_paths(&s), [day.join("20260114_001.png").to_string_lossy(), day.join("20260114_002.png").to_string_lossy()]); + let mut ratings: Vec = s.catalog.photos().map(|p| p.rating).collect(); + ratings.sort(); + assert_eq!(ratings, [3, 4], "the sidecars were read"); + assert_eq!(r["moved"][0]["sidecars"][0], day.join("20260114_001.xmp").to_string_lossy().as_ref()); + // undo: the photos leave the library, the moved files stay where they are now + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.len(), 0); + assert_eq!(files_under(&dest).len(), 4); + let _ = std::fs::remove_dir_all(&base); +} + +/// Another volume (no hard links): copied, verified and only then the source removed. +#[test] +fn move_across_volumes_copies_then_removes() { + let base = temp_dir("xvol"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a.png"), 1); + std::fs::write(card.join("a.xmp"), xmp(2)).unwrap(); + let bytes = std::fs::read(card.join("a.png")).unwrap(); + let mut s = session(); + inject(Fault::NoLink); + let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"})); + inject(Fault::None); + assert_eq!(len(&r, "moved"), 1, "{r}"); + assert_eq!(files_under(&dest), ["a.png", "a.xmp"]); + assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), bytes); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(std::fs::metadata(dest.join("a.png")).unwrap().nlink(), 1, "a real copy, not a link"); + } + assert!(!card.join("a.png").exists() && !card.join("a.xmp").exists()); + let _ = std::fs::remove_dir_all(&base); +} + +/// A copy that fails while writing, or comes out different, leaves nothing behind and keeps the +/// source; nothing is catalogued. +#[test] +fn a_failed_or_corrupt_copy_keeps_the_source() { + for fault in [Fault::FailWrite, Fault::Corrupt] { + let base = temp_dir(&format!("fail-{fault:?}")); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a.png"), 1); + std::fs::write(card.join("a.xmp"), xmp(2)).unwrap(); + let bytes = std::fs::read(card.join("a.png")).unwrap(); + let mut s = session(); + inject(fault); + let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"})); + inject(Fault::None); + assert_eq!(len(&r, "failed"), 1, "{fault:?}: {r}"); + assert_eq!(len(&r, "moved"), 0, "{fault:?}: {r}"); + assert_eq!(s.catalog.len(), 0); + assert_eq!(std::fs::read(card.join("a.png")).unwrap(), bytes, "{fault:?}: source intact"); + assert!(card.join("a.xmp").is_file()); + assert_eq!(files_under(&dest), Vec::::new(), "{fault:?}: no partial copy left"); + let _ = std::fs::remove_dir_all(&base); + } +} + +/// A source that can't be removed (a read-only card) stays and is reported; the photo is +/// imported from its copy. +#[test] +fn a_source_that_cannot_be_removed_is_kept_and_reported() { + let base = temp_dir("ro"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a.png"), 1); + let mut s = session(); + inject(Fault::FailRemove); + let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"})); + inject(Fault::None); + assert_eq!(len(&r, "imported"), 1, "{r}"); + assert_eq!(len(&r, "moved"), 0, "{r}"); + assert_eq!(len(&r, "kept"), 1, "{r}"); + assert!(r["kept"][0]["reason"].as_str().unwrap().contains("could not remove"), "{r}"); + assert!(card.join("a.png").is_file() && dest.join("a.png").is_file()); + assert_eq!(photo_paths(&s), [dest.join("a.png").to_string_lossy()]); + let _ = std::fs::remove_dir_all(&base); +} + +/// Duplicates, unreadable files and files that fail (here: a different sidecar already at the +/// destination) keep their sources; the rest of the batch still moves. +#[test] +fn duplicates_and_failures_keep_their_sources() { + let base = temp_dir("dup"); + let (lib_src, card, dest) = (base.join("old"), base.join("card"), base.join("out")); + write_png(&lib_src.join("known.png"), 7); + let mut s = session(); + s.execute("library.import", &json!({"paths": [lib_src.to_string_lossy()]})).unwrap(); + std::fs::create_dir_all(&card).unwrap(); + std::fs::copy(lib_src.join("known.png"), card.join("same.png")).unwrap(); // same bytes + write_png(&card.join("a.png"), 1); + std::fs::write(card.join("a.xmp"), xmp(1)).unwrap(); + write_png(&card.join("b.png"), 2); + std::fs::write(card.join("broken.jpg"), "garbage").unwrap(); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::write(dest.join("a.xmp"), xmp(5)).unwrap(); // someone else's sidecar + let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"})); + assert_eq!(len(&r, "duplicates"), 1, "{r}"); + assert_eq!(len(&r, "failed"), 2, "{r}"); // broken.jpg (unreadable) and a.png (sidecar clash) + assert_eq!(len(&r, "moved"), 1, "{r}"); + assert_eq!(r["moved"][0]["from"], card.join("b.png").to_string_lossy().as_ref()); + assert_eq!(files_under(&card), ["a.png", "a.xmp", "broken.jpg", "same.png"]); + assert_eq!(files_under(&dest), ["a.xmp", "b.png"], "a.png's copy was taken back"); + assert_eq!(std::fs::read_to_string(dest.join("a.xmp")).unwrap(), xmp(5), "never overwritten"); + let _ = std::fs::remove_dir_all(&base); +} + +/// A taken name gets -1 (never overwritten), its sidecar follows the new name; a file already +/// inside the destination is added where it is, not moved. +#[test] +fn collisions_are_numbered_and_files_in_the_destination_stay() { + let base = temp_dir("coll"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a.png"), 1); + std::fs::write(card.join("a.xmp"), xmp(2)).unwrap(); + write_png(&dest.join("a.png"), 9); // a different photo, same name + let theirs = std::fs::read(dest.join("a.png")).unwrap(); + write_png(&dest.join("inbox/c.png"), 3); + let mut s = session(); + let r = import_move(&mut s, &[&card, &dest.join("inbox")], &dest, json!({"organize": "flat"})); + assert_eq!(len(&r, "imported"), 2, "{r}"); + assert_eq!(len(&r, "moved"), 1, "{r}"); + assert_eq!(len(&r, "kept"), 1, "{r}"); + assert_eq!(files_under(&dest), ["a-1.png", "a-1.xmp", "a.png", "inbox/c.png"]); + assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), theirs); + assert!(photo_paths(&s).contains(&dest.join("inbox").join("c.png").to_string_lossy().to_string())); + let _ = std::fs::remove_dir_all(&base); +} + +/// A stem-named sidecar shared with a photo that isn't moved is copied along but stays beside +/// that photo. +#[test] +fn a_shared_sidecar_stays_with_the_photo_left_behind() { + let base = temp_dir("shared"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("x.png"), 1); + std::fs::write(card.join("x.tif"), "the other half (not imported)").unwrap(); + std::fs::write(card.join("x.xmp"), xmp(2)).unwrap(); + let mut s = session(); + let r = import_move(&mut s, &[&card.join("x.png")], &dest, json!({"organize": "flat"})); + assert_eq!(len(&r, "moved"), 1, "{r}"); + assert_eq!(files_under(&dest), ["x.png", "x.xmp"]); + assert_eq!(files_under(&card), ["x.tif", "x.xmp"]); + assert!(r["kept"][0]["reason"].as_str().unwrap().contains("x.tif"), "{r}"); + let _ = std::fs::remove_dir_all(&base); +} + +/// Into the open library's Originals/ (default destination): the records are saved before the +/// sources go, and are there after reopening. Move needs somewhere to go and never browses. +#[test] +fn move_into_the_library_is_saved_before_sources_go() { + let base = temp_dir("lib"); + let (card, lib) = (base.join("card"), base.join("lib")); + write_png(&card.join("a.png"), 1); + let mut s = session(); + let p = json!({"paths": [card.to_string_lossy()], "mode": "move"}); + assert!(s.execute("library.import", &p).is_err(), "no destination, no library"); + let browse = json!({"paths": [card.to_string_lossy()], "mode": "move", "local": true, "destination": base.join("x").to_string_lossy()}); + assert!(s.execute("library.import", &browse).is_err(), "browsing never moves"); + assert!(card.join("a.png").is_file()); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &p).unwrap(); + assert_eq!(len(&r, "moved"), 1, "{r}"); + assert!(!card.join("a.png").exists()); + let want = lib.join("Originals").join("2026").join("2026-01-14").join("a.png"); + assert!(want.is_file()); + drop(s); + let mut again = Session::new().with_fs(); + again.open_library(&lib, false).unwrap(); + assert_eq!(photo_paths(&again), [want.to_string_lossy()]); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 0bf78908..517326ed 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -92,6 +92,27 @@ fn import_tool_copies_with_a_folder_template() { let _ = std::fs::remove_dir_all(&base); } +/// The `import` helper moves: renamed into the folder template, the source removed. +#[test] +fn import_tool_moves_with_a_folder_template() { + let base = std::env::temp_dir().join(format!("lc-mcp-import-move-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join("sample.png"), png).unwrap(); + let mut b = Headless::demo(); + b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(), + "organize": "{date:%Y}/{date:%Y%m%d}", "rename": "{date:%Y%m%d}_{seq:3}"}); + let r = call_tool(&mut b, "import", &args); + assert!(!r.is_error, "{r:?}"); + assert!(dest.join("2026").join("20260114").join("20260114_001.png").is_file()); + assert!(!src.join("sample.png").exists(), "moved"); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn command_tools_run_commands() { let mut b = Headless::demo(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 77f610ef..7e2e212b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -99,10 +99,10 @@ pub fn helper_tools(has_ui: bool) -> Vec { json!({ "paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}, - "mode": {"type": "string", "enum": ["add", "copy"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/)"}, - "destination": {"type": "string", "description": "Copy: destination folder"}, - "organize": {"type": "string", "description": "Copy: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, - "rename": {"type": "string", "description": "Copy: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} + "mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"}, + "destination": {"type": "string", "description": "Copy or move: destination folder"}, + "organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, + "rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} }), &["paths"], ), diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ea1467af..893c3a40 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1323,6 +1323,57 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// Import review, Move: an explicit choice with its explanation, the confirm button says + /// Move; the files land in the folder template, renamed, and leave the card. + #[test] + fn import_move_into_a_folder_template() { + let base = std::env::temp_dir().join(format!("lc-ui-import-move-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("Photos")); + std::fs::create_dir_all(&src).unwrap(); + for i in 0..2u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); + } + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let mut app = LightcraftApp::new(session, services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + for id in ["button:importMove", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import dialog") }; + assert!(opts.copy && opts.move_files, "Move chosen"); + opts.organize = "custom".into(); + opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); + opts.rename = "{date:%Y%m%d}_{seq:3}".into(); + let opts = opts.clone(); + let sep = std::path::MAIN_SEPARATOR; + let example = crate::import::example_destination(&h.app, &opts).expect("example"); + assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}20260114_001.png", dest.to_string_lossy())); + h.step(); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importModeHelp"), "the Move explanation is shown"); + assert!(!r.to_string().contains("check:importDng"), "no Copy as DNG when moving"); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let day = dest.join("2026").join("20260114"); + assert!(day.join("20260114_001.png").is_file() && day.join("20260114_002.png").is_file()); + assert_eq!(std::fs::read_dir(&src).unwrap().count(), 0, "moved off the card"); + let _ = std::fs::remove_dir_all(&base); + } + /// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one /// inserts it at the text cursor (not appended, not replacing the template). #[test] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 717cb727..a098d26f 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1,8 +1,8 @@ //! 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 +//! place / copy or move 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. @@ -29,6 +29,9 @@ pub struct ImportDialog { pub checked: Vec, /// Copy into the library (else add in place). pub copy: bool, + /// With `copy`: move instead — the originals are removed from the source once each copy is + /// verified and catalogued (`library.import` mode `move`). + pub move_files: bool, /// Existing album to add to. pub album: Option, /// …or a new album with this name. @@ -98,6 +101,9 @@ pub struct ImportTask { pub imported: usize, pub duplicates: usize, pub failed: usize, + /// Move: originals moved, and sources left in place (reported by the engine with a reason). + pub moved: usize, + pub kept: usize, undo0: usize, first: Option, /// Reading a folder for the Local view: the photos stay out of the library, and nothing is @@ -289,7 +295,12 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?; album = r["id"].as_u64(); } - let mut params = json!({"mode": if d.copy { "copy" } else { "add" }, "keywords": d.keywords()}); + let mode = match (d.copy, d.move_files) { + (false, _) => "add", + (true, false) => "copy", + (true, true) => "move", + }; + let mut params = json!({"mode": mode, "keywords": d.keywords()}); if let Some(a) = album { params["album"] = json!(a); } @@ -310,7 +321,7 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result params["rename"] = json!(d.rename.trim()); params["renameStart"] = json!(1); } - if d.dng { + if d.dng && !d.move_files { params["dng"] = json!(true); } } @@ -341,6 +352,11 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { task.imported += len("imported"); task.duplicates += len("duplicates"); task.failed += len("failed"); + task.moved += len("moved"); + task.kept += len("kept"); + for k in v["kept"].as_array().into_iter().flatten() { + log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or("")); + } if task.first.is_none() { task.first = v["imported"].as_array().and_then(|a| a.first()).and_then(Value::as_u64); } @@ -367,7 +383,14 @@ 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!("Imported {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }); + let plural = |n: usize| if n == 1 { "" } else { "s" }; + let mut msg = format!("Imported {} photo{}", task.imported, plural(task.imported)); + if task.params["mode"] == "move" { + msg.push_str(&format!(" · {} moved", task.moved)); + if task.kept > 0 { + msg.push_str(&format!(" · {} original{} left at the source", task.kept, plural(task.kept))); + } + } if task.duplicates > 0 { msg.push_str(&format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); } @@ -448,24 +471,36 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { 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; + d.move_files = false; } let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); - let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy)).inner; + let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy && !d.move_files)).inner; if r.on_hover_text("Copy the files (into the library's Originals/, or a folder you choose)").clicked() { d.copy = true; + d.move_files = false; + } + let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", "Move", d.copy && d.move_files)).inner; + if r.on_hover_text("Move the files (into the library's Originals/, or a folder you choose), removing them from the source").clicked() { + d.copy = true; + d.move_files = 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." + let r = ui.label( + egui::RichText::new(match (d.copy, d.move_files) { + (true, false) => { + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." + } + (true, true) => { + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are." + } + _ => "Add in place: the library references the files where they are; nothing is copied or moved.", }) - .color(t.text_dim) + .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) .small(), ); + register(ui.ctx(), "label:importModeHelp", r.rect); if d.copy { - field(ui, "Copy to", |ui| { + field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| { ui.spacing_mut().item_spacing.x = 4.0; let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() }; ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); @@ -527,10 +562,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .small(), ); } - field(ui, "Raw files", |ui| { - let r = ui.checkbox(&mut d.dng, "Copy as DNG"); - register(ui.ctx(), "check:importDng", r.rect); - }); + if !d.move_files { + field(ui, "Raw files", |ui| { + let r = ui.checkbox(&mut d.dng, "Copy as DNG"); + register(ui.ctx(), "check:importDng", r.rect); + }); + } let rename_id = egui::Id::new("import-rename"); let tags_open = field(ui, "Rename", |ui| { ui.spacing_mut().item_spacing.x = 4.0; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 3a71a55d..71fa5644 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -720,7 +720,8 @@ 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!("Import {n} Photo{}", if n == 1 { "" } else { "s" }); + let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; + add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }); add_label.as_str() } Dialog::Merge { .. } => "Merge", diff --git a/docs/mcp.md b/docs/mcp.md index 32c155fa..9a239c46 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -76,7 +76,7 @@ During development you can also point the client at `cargo run --release -p ligh |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now | | `run_command {command, params?}` | Run any command by id | -| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) | +| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) | | `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) | | `select_photos {ids, active?, mode?}` | Set the selection / active photo | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | diff --git a/docs/parity.md b/docs/parity.md index e8344864..16f5fa09 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) | 10 | 2 | 1 | 1 | 3/4 (75%) | 4/4 (100%) | +| A. Import (IMP) | 11 | 2 | 1 | 1 | 3/4 (75%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 4/4 (100%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 30 | 9 | 40 | 9 | — | 20/21 (95%) | -| **Total** | 385 | 25 | 91 | 37 | 194/198 (98%) | 137/145 (94%) | +| **Total** | 386 | 25 | 91 | 37 | 194/198 (98%) | 138/146 (95%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 501 in-scope rows — P0 99.0% of 198 · P1 96.2% of 145 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.4%** of 502 in-scope rows — P0 99.0% of 198 · P1 96.2% of 146 · P2 39.2% of 158. ## Top gaps @@ -99,7 +99,7 @@ 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 (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-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 / move, 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 → 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) | @@ -112,6 +112,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | +| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched) | ## B. Library management (LIB) diff --git a/docs/whats-new.md b/docs/whats-new.md index 1657c786..cfdd963f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -14,6 +14,9 @@ - Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets. - Colour-label filter with several labels at once; expandable folder tree in Local. - Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG. +- Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g. + `Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its + copy is verified and in the library. Duplicates and files that fail stay where they were. - Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews. - Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}. - Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.