mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 22:17:53 -06:00
Copy import used exists() then std::fs::copy: no sync, no comparison, and a file appearing at the chosen name in between was replaced. It now goes through import_move::copy_new, which reuses Move's copy_verified: a new file (create_new, retrying -1, -2… on AlreadyExists), synced and compared byte for byte with the source; a copy that fails or differs is removed and reported as a failed import. Copy as DNG's copy step uses the same path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
323 lines
15 KiB
Rust
323 lines
15 KiB
Rust
//! 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);
|
|
}
|
|
}
|
|
|
|
/// Issue #96: Import → Copy used a plain unverified copy (and check-then-copy naming that could
|
|
/// replace a file). It now gets the same verified copy as Move: a copy that fails or comes out
|
|
/// different is removed and reported as a failed import, nothing is catalogued, and the card
|
|
/// is untouched; a good copy never replaces an existing file.
|
|
#[test]
|
|
fn copy_import_verifies_each_copy() {
|
|
let import_copy = |s: &mut Session, card: &Path, dest: &Path| {
|
|
s.execute(
|
|
"library.import",
|
|
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
|
|
)
|
|
.unwrap()
|
|
};
|
|
for fault in [Fault::FailWrite, Fault::Corrupt] {
|
|
let base = temp_dir(&format!("copyfail-{fault:?}"));
|
|
let (card, dest) = (base.join("card"), base.join("out"));
|
|
write_png(&card.join("a.png"), 3);
|
|
let bytes = std::fs::read(card.join("a.png")).unwrap();
|
|
let mut s = session();
|
|
inject(fault);
|
|
let r = import_copy(&mut s, &card, &dest);
|
|
inject(Fault::None);
|
|
assert_eq!(len(&r, "failed"), 1, "{fault:?}: {r}");
|
|
assert_eq!(len(&r, "imported"), 0, "{fault:?}: {r}");
|
|
assert!(r.to_string().contains("the original is untouched"), "{r}");
|
|
assert_eq!(s.catalog.len(), 0);
|
|
assert_eq!(std::fs::read(card.join("a.png")).unwrap(), bytes, "{fault:?}: source intact");
|
|
assert_eq!(files_under(&dest), Vec::<String>::new(), "{fault:?}: no bad copy left");
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
// a good copy: verified, and a taken name gets -1 instead of being replaced
|
|
let base = temp_dir("copyok");
|
|
let (card, dest) = (base.join("card"), base.join("out"));
|
|
write_png(&card.join("a.png"), 4);
|
|
std::fs::create_dir_all(&dest).unwrap();
|
|
std::fs::write(dest.join("a.png"), b"someone else's file").unwrap();
|
|
let mut s = session();
|
|
let r = import_copy(&mut s, &card, &dest);
|
|
assert_eq!(len(&r, "imported"), 1, "{r}");
|
|
assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), b"someone else's file");
|
|
assert_eq!(std::fs::read(dest.join("a-1.png")).unwrap(), std::fs::read(card.join("a.png")).unwrap());
|
|
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);
|
|
}
|