diff --git a/README.md b/README.md index 746d4280..812a12ac 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima ```sh git clone https://github.com/storytold/lightcraft && cd lightcraft cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos) -cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned) +cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned, duplicates skipped) cargo run --release -p lightcraft -- --memory # a throwaway in-memory demo session (writes nothing) cargo run --release -p lightcraft -- --control 7980 # with the automation channel cargo xtask ci # fmt, clippy, tests, layering, wasm checks diff --git a/apps/lightcraft/examples/gen_photos.rs b/apps/lightcraft/examples/gen_photos.rs new file mode 100644 index 00000000..ee7ba9a5 --- /dev/null +++ b/apps/lightcraft/examples/gen_photos.rs @@ -0,0 +1,45 @@ +//! Generate a folder of procedural test photos (JPEG) for import/startup benchmarks. +//! +//! `cargo run --release -p lightcraft --example gen_photos -- OUT_DIR [COUNT=200] [LONG_EDGE=2000]` +//! +//! Each photo is a `lightcraft-scenes` demo scene rendered through the pipeline with a varied +//! look (exposure, temperature, vibrance), so every file has distinct bytes. The output is our own +//! procedural content; keep it out of the repository (write to a scratch or gitignored folder). + +use lightcraft_engine::Session; +use serde_json::json; + +fn main() { + let mut args = std::env::args().skip(1); + let out = std::path::PathBuf::from(args.next().expect("usage: gen_photos OUT_DIR [COUNT] [LONG_EDGE]")); + let count: usize = args.next().and_then(|c| c.parse().ok()).unwrap_or(200); + let edge: usize = args.next().and_then(|c| c.parse().ok()).unwrap_or(2000); + std::fs::create_dir_all(&out).expect("create output folder"); + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + let t0 = std::time::Instant::now(); + for i in 0..count { + let id = ids[i % ids.len()]; + let round = i / ids.len(); + s.execute("library.select", &json!({"ids": [id.0]})).expect("select"); + s.execute("develop.reset", &json!({})).expect("reset"); + let v = json!({ + "light.exposure": (round as f64 * 0.37).sin() * 0.8, + "wb.temp": 6500.0 + (round as f64 * 1.3).cos() * 1500.0, + "color.vibrance": (round * 13 % 50) as f64, + }); + let _ = s.execute("develop.set", &json!({"values": v})); + let img = s.render_now(id, edge, edge).expect("render"); + let bytes = lightcraft_codecs::encode_jpeg( + &lightcraft_codecs::EncodeImage::rgba8(&img.image), + 88, + lightcraft_codecs::ChromaSubsampling::S420, + &lightcraft_codecs::EncodeMeta::default(), + ) + .expect("encode"); + let dir = out.join(format!("roll-{:02}", i / 50 + 1)); + std::fs::create_dir_all(&dir).expect("mkdir"); + std::fs::write(dir.join(format!("LCG{:05}.jpg", i + 1)), bytes).expect("write"); + } + eprintln!("wrote {count} photos ({edge} px) to {} in {:.1} s", out.display(), t0.elapsed().as_secs_f64()); +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 5c2a2265..c23b0a58 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -92,29 +92,9 @@ fn services() -> Services { } } -/// Expand folders (recursively) into photo files. -fn collect(path: &std::path::Path, out: &mut Vec) { - if path.is_dir() { - if let Ok(rd) = std::fs::read_dir(path) { - let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect(); - v.sort(); - for p in v { - collect(&p, out); - } - } - } else if path.extension().is_some_and(|e| { - matches!( - e.to_string_lossy().to_lowercase().as_str(), - "jpg" | "jpeg" | "png" | "tif" | "tiff" | "webp" | "dng" | "cr2" | "nef" | "arw" | "psd" | "jxl" | "gif" | "bmp" - ) - }) { - out.push(path.to_string_lossy().to_string()); - } -} - /// The persistent library session (or an in-memory one with `--memory` / if the library can't open). fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> Session { - let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs(); + let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(); if in_memory { return fallback(); } @@ -123,7 +103,7 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo return fallback(); }; let t0 = std::time::Instant::now(); - let mut s = Session::new().with_fs(); + let mut s = Session::new().with_fs().with_system_clock(); match s.open_library(&dir, seed_demo) { Ok(r) => { let (replayed, torn) = (r.replayed, r.torn_bytes); @@ -160,7 +140,7 @@ fn main() -> eframe::Result { println!("lightcraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - _ => collect(std::path::Path::new(&a), &mut files), + _ => files.push(a), } } let options = eframe::NativeOptions { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 7ec7c3cd..5030286e 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -364,24 +364,39 @@ pub fn specs() -> Vec { ok() }), // ---- import - cmd!("library.import", "Add Photos…", ["File"], Some("Cmd+Shift+I"), "{paths: [path], album?: albumId}", always, |s, p| { - let paths: Vec = p - .get("paths") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_default(); - if paths.is_empty() { - return Err(bad("library.import", "no paths")); + cmd!( + "library.import", + "Add Photos…", + ["File"], + Some("Cmd+Shift+I"), + "{paths: [file or folder (recursive)], mode?: add|copy (copy into the library's Originals/), album?: albumId} → {imported, duplicates, failed}", + always, + |s, p| { + let paths: Vec = p + .get("paths") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + if paths.is_empty() { + return Err(bad("library.import", "no paths")); + } + 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)"))), + }; + let report = crate::import::import(s, &paths, mode)?; + if let Some(a) = p.get("album").and_then(Value::as_u64) + && !report.imported.is_empty() + { + s.execute("album.addPhotos", &json!({"id": a, "ids": report.imported}))?; + } + if let Some(f) = report.imported.first() { + s.selection = Selection::single(PhotoId(*f)); + } + Ok(serde_json::to_value(&report).unwrap_or_default()) } - let imported = crate::media::import_paths(s, &paths)?; - if let Some(a) = p.get("album").and_then(Value::as_u64) { - s.execute("album.addPhotos", &json!({"id": a, "ids": imported.iter().map(|i| i.0).collect::>()}))?; - } - if let Some(f) = imported.first() { - s.selection = Selection::single(*f); - } - Ok(json!({"imported": imported.iter().map(|i| i.0).collect::>()})) - }), + ), // ---- persistence cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| { let photos = s.catalog.len(); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 0e79c9c9..f4a4a08a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -40,6 +40,7 @@ fn ext_upper(name: &str) -> String { /// Probe a file's bytes: kind, dimensions (oriented), metadata. pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { + let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); let m = lightcraft_meta::extract(bytes); let (meta, captured) = meta_of(&m); if lightcraft_raw::probe(bytes).is_some() { @@ -62,6 +63,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { captured, meta, as_shot_wb, + content_hash, }); } let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; @@ -79,7 +81,17 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { "" => format!("{fmt:?}").to_uppercase(), e => e.to_string(), }; - Ok(ProbeInfo { width: w, height: h, format, kind: MediaKind::Image, file_size: bytes.len() as u64, captured, meta, as_shot_wb: None }) + Ok(ProbeInfo { + width: w, + height: h, + format, + kind: MediaKind::Image, + file_size: bytes.len() as u64, + captured, + meta, + as_shot_wb: None, + content_hash, + }) } /// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs new file mode 100644 index 00000000..8432537c --- /dev/null +++ b/crates/engine/src/import.rs @@ -0,0 +1,267 @@ +//! Import: files and folders (recursive) → photos. +//! +//! 1. Expand folders recursively into supported files (hidden entries and the library's own +//! folder are skipped), sorted for a stable order. +//! 2. Skip paths already in the catalog. +//! 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. +//! 6. Commit all new photos as one undoable op. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde::Serialize; + +use crate::Session; +use crate::media::ProbeInfo; + +/// File extensions LightCraft imports (lower case). +pub const EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "heic", + "avif", +]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ImportMode { + /// Reference the files where they are. + #[default] + Add, + /// Copy the files into the library's `Originals/` folder. + Copy, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Duplicate { + pub path: String, + /// The photo that already has these bytes (or this path). + pub existing: Option, + /// `"path"` (already imported from there) or `"content"` (same bytes elsewhere). + pub reason: &'static str, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct ImportReport { + pub imported: Vec, + pub duplicates: Vec, + /// (path, error) + pub failed: Vec<(String, String)>, + /// Files found after expanding folders. + pub scanned: usize, +} + +pub fn is_supported(path: &Path) -> bool { + path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) +} + +/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) +/// is never descended into. +pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { + fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec, top: bool) { + let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')); + if (hidden && !top) || skip.is_some_and(|s| p == s) { + return; + } + if p.is_dir() { + let Ok(rd) = std::fs::read_dir(p) else { return }; + let mut v: Vec = rd.flatten().map(|e| e.path()).collect(); + v.sort(); + for c in v { + walk(&c, skip, out, false); + } + } else if top || is_supported(p) { + // explicitly named files are attempted even with an unknown extension (sniffed) + out.push(p.to_string_lossy().to_string()); + } + } + let mut out = Vec::new(); + for p in paths { + walk(Path::new(p), skip, &mut out, true); + } + let mut seen = std::collections::HashSet::new(); + out.retain(|p| seen.insert(p.clone())); + out +} + +fn probe_all(s: &Session, paths: &[String]) -> Vec> { + let Some(probe) = s.media.file_probe.clone() else { + return paths + .iter() + .map(|p| { + Ok(ProbeInfo { + format: Path::new(p).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(), + ..Default::default() + }) + }) + .collect(); + }; + #[cfg(not(target_arch = "wasm32"))] + { + let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1)); + if n > 1 { + let mut results: Vec>> = vec![None; paths.len()]; + let next = std::sync::atomic::AtomicUsize::new(0); + let out = std::sync::Mutex::new(&mut results); + std::thread::scope(|sc| { + for _ in 0..n { + sc.spawn(|| { + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if i >= paths.len() { + break; + } + let r = probe(&paths[i]); + out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); + } + }); + } + }); + return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect(); + } + } + paths.iter().map(|p| probe(p)).collect() +} + +/// `Originals/YYYY/YYYY-MM-DD/name`, made unique. +fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result { + let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); + let year = day.get(..4).unwrap_or("undated"); + let dir = lib.join("Originals").join(year).join(day); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()); + let (stem, ext) = match name.rsplit_once('.') { + Some((s, e)) => (s.to_string(), format!(".{e}")), + None => (name.clone(), String::new()), + }; + let mut dst = dir.join(&name); + let mut i = 1; + while dst.exists() { + dst = dir.join(format!("{stem}-{i}{ext}")); + i += 1; + } + std::fs::copy(src, &dst).map_err(|e| format!("copy {src}: {e}"))?; + Ok(dst.to_string_lossy().to_string()) +} + +/// Import files/folders. See the module docs. +pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { + let lib_dir = s.library.as_ref().map(|l| l.dir.clone()); + if mode == ImportMode::Copy && lib_dir.is_none() { + return Err(crate::EngineError::Other("copying into the library needs an open library".into())); + } + let files = expand(paths, lib_dir.as_deref()); + let mut report = ImportReport { scanned: files.len(), ..Default::default() }; + + // existing paths and content hashes + let mut by_path: HashMap<&str, PhotoId> = HashMap::new(); + let mut by_hash: HashMap = HashMap::new(); + for p in s.catalog.photos() { + if let Source::File { path } = &p.source { + by_path.insert(path.as_str(), p.id); + } + if let Some(h) = &p.content_hash { + by_hash.insert(h.clone(), p.id); + } + } + let mut todo = Vec::new(); + for f in files { + match by_path.get(f.as_str()) { + Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }), + None => todo.push(f), + } + } + + let probed = probe_all(s, &todo); + let now = (s.clock)(); + let mut ops = Vec::new(); + for (path, info) in todo.into_iter().zip(probed) { + let info = match info { + Ok(i) => i, + Err(e) => { + log::warn!("import {path}: {e}"); + report.failed.push((path, e)); + continue; + } + }; + if let Some(h) = &info.content_hash + && let Some(id) = by_hash.get(h) + { + report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" }); + continue; + } + let stored = match (mode, &lib_dir) { + (ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => { + match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) { + Ok(p) => p, + Err(e) => { + report.failed.push((path, e)); + continue; + } + } + } + _ => path.clone(), + }; + let id = s.catalog.alloc_photo_id(); + if let Some(h) = &info.content_hash { + by_hash.insert(h.clone(), id); + } + let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); + let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now); + p.kind = info.kind; + p.file_size = info.file_size; + p.captured = info.captured; + p.meta = info.meta; + p.as_shot_wb = info.as_shot_wb; + p.content_hash = info.content_hash; + if let Some((t, tint)) = info.as_shot_wb { + p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint)); + } + report.imported.push(id.0); + 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 })?; + } + Ok(report) +} + +/// 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 { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + civil(secs) + } + #[cfg(target_arch = "wasm32")] + { + "2026-01-01T00:00:00".to_string() + } +} + +/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian (H. Hinnant's algorithm). +pub fn civil(secs: i64) -> String { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60) +} + +impl Session { + /// Use the system clock for import/edit times (native hosts). + pub fn with_system_clock(mut self) -> Self { + self.clock = Box::new(system_clock); + self + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66736588..ddc6f784 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod cmd; pub mod demo; pub mod files; +pub mod import; pub mod library; pub mod media; pub mod presets; @@ -306,4 +307,6 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_import; +#[cfg(test)] mod tests_library; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 5ac7c94e..2d56b459 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -299,49 +299,8 @@ pub struct ProbeInfo { pub captured: Option, pub meta: lightcraft_catalog::Meta, pub as_shot_wb: Option<(f64, f64)>, + /// Hash of the file's bytes (hex), for duplicate detection. + pub content_hash: Option, } pub type FileProbe = Arc Result + Send + Sync>; - -/// Register files as photos (duplicates by path are skipped). Returns the new ids. -pub fn import_paths(s: &mut crate::Session, paths: &[String]) -> crate::Result> { - let now = (s.clock)(); - let mut ops = Vec::new(); - let mut ids = Vec::new(); - for path in paths { - let exists = s.catalog.photos().any(|p| matches!(&p.source, Source::File { path: q } if q == path)); - if exists { - continue; - } - let info = match &s.media.file_probe { - Some(probe) => match probe(path) { - Ok(i) => i, - Err(e) => { - log::warn!("import {path}: {e}"); - continue; - } - }, - None => ProbeInfo { - format: std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(), - ..Default::default() - }, - }; - let id = s.catalog.alloc_photo_id(); - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); - let mut p = Photo::new(id, Source::File { path: path.clone() }, &name, &info.format, info.width, info.height, &now); - p.kind = info.kind; - p.file_size = info.file_size; - p.captured = info.captured; - p.meta = info.meta; - p.as_shot_wb = info.as_shot_wb; - if let Some((t, tint)) = info.as_shot_wb { - p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint)); - } - ids.push(id); - ops.push(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }); - } - if !ops.is_empty() { - s.commit("Add Photos", lightcraft_catalog::Op::Batch { ops })?; - } - Ok(ids) -} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs new file mode 100644 index 00000000..6e1289e3 --- /dev/null +++ b/crates/engine/src/tests_import.rs @@ -0,0 +1,103 @@ +//! Import: recursive folders, duplicate detection by content, add vs copy into the library. + +use std::path::Path; + +use serde_json::{Value, json}; + +use crate::Session; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A small procedural PNG (distinct per seed). +fn write_png(path: &Path, seed: u8) { + let (w, h) = (48usize, 32usize); + 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 ids(v: &Value, key: &str) -> usize { + v[key].as_array().map(Vec::len).unwrap_or(0) +} + +#[test] +fn recursive_import_with_duplicates() { + let src = temp_dir("src"); + write_png(&src.join("a.png"), 1); + write_png(&src.join("trip/b.png"), 2); + write_png(&src.join("trip/day2/c.PNG"), 3); + std::fs::copy(src.join("a.png"), src.join("trip/a-copy.png")).unwrap(); // same bytes + write_png(&src.join(".hidden/x.png"), 9); + std::fs::write(src.join("notes.txt"), "not a photo").unwrap(); + std::fs::write(src.join("trip/broken.jpg"), "garbage").unwrap(); + + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["scanned"], 5, "{r}"); // a, b, c, a-copy, broken (hidden + txt skipped) + assert_eq!(ids(&r, "imported"), 3, "{r}"); + assert_eq!(ids(&r, "duplicates"), 1, "{r}"); + assert_eq!(r["duplicates"][0]["reason"], "content"); + assert_eq!(ids(&r, "failed"), 1, "{r}"); + assert_eq!(s.catalog.len(), 3); + assert!(s.catalog.photos().all(|p| p.content_hash.as_ref().is_some_and(|h| h.len() == 32) && p.width == 48)); + + // importing again: everything is a duplicate (by path), one undo step for the first import + let r2 = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(ids(&r2, "imported"), 0); + assert_eq!(r2["duplicates"].as_array().unwrap().iter().filter(|d| d["reason"] == "path").count(), 3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.len(), 0); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn copy_into_library_and_persist() { + let src = temp_dir("copysrc"); + let lib = temp_dir("copylib"); + write_png(&src.join("one.png"), 4); + write_png(&src.join("sub/one.png"), 5); // same name, different content + let mut s = Session::new().with_fs(); + assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).is_err(), "copy needs a library"); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap(); + assert_eq!(ids(&r, "imported"), 2, "{r}"); + let paths: Vec = s + .catalog + .photos() + .map(|p| match &p.source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!(), + }) + .collect(); + for p in &paths { + assert!(Path::new(p).starts_with(lib.join("Originals")), "{p}"); + assert!(Path::new(p).exists()); + } + assert_ne!(paths[0], paths[1], "unique names"); + // originals deleted from the source: the library copies remain usable after a restart + std::fs::remove_dir_all(&src).unwrap(); + drop(s); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.catalog.len(), 2); + let id = s.catalog.photos().next().unwrap().id; + assert!(s.render_now(id, 32, 32).is_ok()); + // the library folder itself is never re-imported + let r = s.execute("library.import", &json!({"paths": [lib.to_string_lossy()]})).unwrap(); + assert_eq!(r["scanned"], 0, "{r}"); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn civil_dates() { + assert_eq!(crate::import::civil(0), "1970-01-01T00:00:00"); + assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00"); + assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20"); +}