mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
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 <noreply@anthropic.com>
279 lines
12 KiB
Rust
279 lines
12 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);
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|