Merge pull request #77 from storytold/fix/issue-29-move-import

Issue #29: Import: Move mode with rename and template folders
This commit is contained in:
Brandon Thomas 2026-10-04 16:15:40 -04:00 committed by GitHub
commit 5525ae667a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 850 additions and 44 deletions

View File

@ -748,7 +748,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
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) => {

View File

@ -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<u64>,
pub duplicates: Vec<Duplicate>,
/// Move: the originals that were moved (and removed from the source).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub moved: Vec<Moved>,
/// 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<Kept>,
/// (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<String>,
}
/// 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<crate::import_move::Placed> = 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, &copy_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<crate::import_move::Placed>, log0: usize, report: &mut ImportReport) {
// (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here)
let auto: Vec<Op> = 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 {

View File

@ -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<PlacedSidecar>,
}
#[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<Fault> = 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<Placed, String> {
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<Vec<(String, String)>, 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<String> {
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<bool> {
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<usize> {
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)
}

View File

@ -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;

View File

@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"#
)
}
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::<Vec<_>>(), "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<String> {
let mut v: Vec<String> = 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<String> {
fn walk(d: &Path, base: &Path, out: &mut Vec<String>) {
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::<String>::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<u8> = 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::<String>::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);
}

View File

@ -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();

View File

@ -99,10 +99,10 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
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"],
),

View File

@ -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]

View File

@ -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<bool>,
/// 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<u64>,
/// …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<u64>,
/// 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<Value, String>
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<Value, String>
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;

View File

@ -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",

View File

@ -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 |

View File

@ -21,7 +21,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
<!-- parity:summary -->
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---:|---:|---:|---:|---:|---:|
| A. Import (IMP) | 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.
<!-- /parity:summary -->
## 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)

View File

@ -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.