mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Issue #29: Import: Move mode with rename and template folders
library.import mode `move` (UI: Transfer ▸ Move, MCP import tool): reuses Copy's destination, folder organisation (incl. custom templates) and rename template. Each photo and its XMP sidecars (both namings) are placed without touching the source (hard link on the same volume, else a new file that is copied, synced and compared byte for byte; taken names get -1, -2…, never overwritten). Sources are removed only after the catalog records are committed and saved, and only when the destination is still intact; failed, duplicate, unchecked and in-library files keep their sources; sources that can't be removed are kept and reported (`kept`). Undo removes the catalog records and leaves the moved files at the destination. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ba5fff320b
commit
589741b21e
13 files changed
+850
-44
No files matched your search
@@ -735,7 +735,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");
|
||||
@@ -745,7 +745,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) => {
|
||||
|
||||
+118
-15
@@ -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;
|
||||
@@ -582,6 +583,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"],
|
||||
),
|
||||
|
||||
@@ -1319,6 +1319,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",
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
+5
-4
@@ -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 | 29 | 9 | 41 | 9 | — | 20/21 (95%) |
|
||||
| **Total** | 384 | 25 | 92 | 37 | 194/198 (98%) | 137/145 (94%) |
|
||||
| **Total** | 385 | 25 | 92 | 37 | 194/198 (98%) | 138/146 (95%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 501 in-scope rows — P0 99.0% of 198 · P1 96.2% of 145 · P2 38.6% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 502 in-scope rows — P0 99.0% of 198 · P1 96.2% of 146 · P2 38.6% 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}.
|
||||
|
||||
|
||||
Reference in new issue
Block a user