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:
commit
5525ae667a
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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, ©_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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue